Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9deb34081 | ||
|
|
85ac62968e | ||
|
|
657875b2fa |
6 changed files with 565 additions and 13 deletions
|
|
@ -71,6 +71,57 @@ after a restart has to re-supply `dir` when the session lives anywhere
|
||||||
other than the daemon's own working directory — and a restart is the
|
other than the daemon's own working directory — and a restart is the
|
||||||
situation you reach for `continue` in most often.
|
situation you reach for `continue` in most often.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
A `start` may name a **role**: `role: "reviewer"` runs that subagent under
|
||||||
|
the spawning agent's own `reviewer.md`. Role files live in one directory
|
||||||
|
per agent, `subagent_roles/` inside that agent's state directory, one
|
||||||
|
markdown file per role, and the `role` argument is the filename without
|
||||||
|
its extension.
|
||||||
|
|
||||||
|
One convention directory of named files, because a named set enumerates.
|
||||||
|
Anything that watches roles — a check for a safety clause that has gone
|
||||||
|
stale, an operator asking what this agent can dispatch — lists the
|
||||||
|
directory and has the answer. Per-agent rather than hive-wide: the clauses
|
||||||
|
a role carries come out of that one agent's own prompt files, and the
|
||||||
|
daemon only ever spawns on that agent's behalf.
|
||||||
|
|
||||||
|
**The task is the turn, never a standing system prompt — with or without a
|
||||||
|
role.** A task baked into the system prompt re-asserts itself as an
|
||||||
|
instruction on every later turn of the session (`continue`, `goal`), not
|
||||||
|
just the one the caller wrote it for — the system prompt is the
|
||||||
|
subagent's standing identity, not a one-shot channel, so the daemon always
|
||||||
|
reads the task instructions (`prompt_file`) and folds them ahead of the
|
||||||
|
turn's own prompt instead, whether or not the session has a role. The
|
||||||
|
caller's own task file stays untouched either way.
|
||||||
|
|
||||||
|
A role additionally names a standing identity: its text, alone, becomes a
|
||||||
|
per-session file under the harness directory, and `--append-system-prompt-file`
|
||||||
|
points at that — nothing about the task ever reaches it. Without a role
|
||||||
|
there is no such identity to hold a file open for, so the daemon omits
|
||||||
|
`--append-system-prompt-file` altogether rather than pointing it at
|
||||||
|
anything task-shaped.
|
||||||
|
|
||||||
|
⚠️ This applies to every dispatch, not just role-bearing ones — no agent
|
||||||
|
ships roles yet, so today it's the only path in real use. Before this fix
|
||||||
|
the no-role path put the task straight into the system prompt, same as
|
||||||
|
every `start` before roles existed at all.
|
||||||
|
|
||||||
|
### A role with no file refuses the call
|
||||||
|
|
||||||
|
A named role with no file **fails the `start`** and names the roles the
|
||||||
|
directory does hold. No fallback, no spawn, no reservation — the refusal
|
||||||
|
lands before the daemon claims the session name, so a caller that fixes
|
||||||
|
the name may retry it immediately.
|
||||||
|
|
||||||
|
The reason is the state every agent starts in. The directory starts
|
||||||
|
empty: hyperhive ships no role files, and nothing creates one until an
|
||||||
|
agent writes its own, so a name with no file behind it counts as the
|
||||||
|
ordinary first-run answer rather than a rare corruption. Falling back to a spawn
|
||||||
|
without the role would hand that subagent a prompt missing every clause
|
||||||
|
the role existed to carry, and would do it quietly. An empty role file
|
||||||
|
draws the same refusal, for the same reason.
|
||||||
|
|
||||||
## Goals, and turns toward them
|
## Goals, and turns toward them
|
||||||
|
|
||||||
`start` takes an optional `goal`. Without one a session is a single turn,
|
`start` takes an optional `goal`. Without one a session is a single turn,
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod mcp_config;
|
pub mod mcp_config;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
pub mod role;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,16 @@ struct StartArgs {
|
||||||
/// file, not an inline string, so a large recipe can't blow past a
|
/// file, not an inline string, so a large recipe can't blow past a
|
||||||
/// shell argument length limit.
|
/// shell argument length limit.
|
||||||
prompt_file: String,
|
prompt_file: String,
|
||||||
|
/// Which named role this subagent runs as — `reviewer` loads your own
|
||||||
|
/// `reviewer.md` role file, the short name being the filename without
|
||||||
|
/// its extension. The role becomes the subagent's system prompt, ahead
|
||||||
|
/// of the task in `prompt_file`: it says what this subagent *is* and
|
||||||
|
/// what it must never do, where the task says what to do this once.
|
||||||
|
/// Roles are per-agent, so the set you can name is your own. Omit it
|
||||||
|
/// for the task instructions alone. **A role you do not have is an
|
||||||
|
/// error, not a spawn** — the reply lists the roles you do have.
|
||||||
|
#[serde(default)]
|
||||||
|
role: Option<String>,
|
||||||
/// Written to the subagent's stdin as its first turn's prompt. Default:
|
/// Written to the subagent's stdin as its first turn's prompt. Default:
|
||||||
/// a generic "carry out your instructions" nudge — the real task detail
|
/// a generic "carry out your instructions" nudge — the real task detail
|
||||||
/// belongs in `prompt_file`, not here.
|
/// belongs in `prompt_file`, not here.
|
||||||
|
|
@ -201,7 +211,9 @@ impl SubagentMcp {
|
||||||
configures for it. Pass `goal` to make this a multi-turn run: the daemon re-prompts \
|
configures for it. Pass `goal` to make this a multi-turn run: the daemon re-prompts \
|
||||||
the subagent toward that goal each time a turn ends, up to `max_turns` (default 5), \
|
the subagent toward that goal each time a turn ends, up to `max_turns` (default 5), \
|
||||||
stopping early when the subagent reports the goal reached or asks for help. Whichever \
|
stopping early when the subagent reports the goal reached or asks for help. Whichever \
|
||||||
way it stops, one todo is pushed at the end and `status` says which. See the \
|
way it stops, one todo is pushed at the end and `status` says which. Pass `role` to \
|
||||||
|
run it as one of your own named roles, whose file becomes its system prompt — a role \
|
||||||
|
you do not have refuses the whole call rather than spawning without it. See the \
|
||||||
`base:claude-subagents` skill for when to reach for this."
|
`base:claude-subagents` skill for when to reach for this."
|
||||||
)]
|
)]
|
||||||
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
|
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
|
||||||
|
|
@ -212,6 +224,7 @@ impl SubagentMcp {
|
||||||
model: args.model,
|
model: args.model,
|
||||||
effort: args.effort,
|
effort: args.effort,
|
||||||
prompt_file: args.prompt_file,
|
prompt_file: args.prompt_file,
|
||||||
|
role: args.role,
|
||||||
trigger: args.trigger,
|
trigger: args.trigger,
|
||||||
dir: args.dir,
|
dir: args.dir,
|
||||||
goal: args.goal,
|
goal: args.goal,
|
||||||
|
|
@ -546,6 +559,45 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_start_call_that_names_no_role_still_parses() {
|
||||||
|
// Backwards compatibility at the boundary it actually has to hold
|
||||||
|
// at: the JSON a caller sends. Every `start` written before roles
|
||||||
|
// existed names no role, so the argument has to be optional in the
|
||||||
|
// schema *and* absent-tolerant in the deserializer, which are two
|
||||||
|
// separate ways this could regress.
|
||||||
|
let args: StartArgs = serde_json::from_value(serde_json::json!({
|
||||||
|
"name": "batch-1",
|
||||||
|
"prompt_file": "/tmp/prompt.md",
|
||||||
|
}))
|
||||||
|
.expect("a pre-role `start` payload still deserializes");
|
||||||
|
assert_eq!(args.role, None, "no role named means no role");
|
||||||
|
|
||||||
|
let schema =
|
||||||
|
serde_json::to_value(schemars::schema_for!(StartArgs)).expect("a schema serializes");
|
||||||
|
let required = schema
|
||||||
|
.get("required")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
!required.iter().any(|r| r == "role"),
|
||||||
|
"`role` must stay optional — a required one breaks every existing call. Required: \
|
||||||
|
{required:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_start_call_carries_the_role_it_names() {
|
||||||
|
let args: StartArgs = serde_json::from_value(serde_json::json!({
|
||||||
|
"name": "batch-1",
|
||||||
|
"prompt_file": "/tmp/prompt.md",
|
||||||
|
"role": "reviewer",
|
||||||
|
}))
|
||||||
|
.expect("a payload naming a role deserializes");
|
||||||
|
assert_eq!(args.role.as_deref(), Some("reviewer"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn an_unminted_signal_token_is_a_bare_404() {
|
async fn an_unminted_signal_token_is_a_bare_404() {
|
||||||
// No enumeration: a token this daemon never issued gets the same
|
// No enumeration: a token this daemon never issued gets the same
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ pub fn state_dir() -> PathBuf {
|
||||||
PathBuf::from(format!("/agents/{label}/state"))
|
PathBuf::from(format!("/agents/{label}/state"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where this agent's named subagent role files live, one markdown file per
|
||||||
|
/// role under the agent's own [`state_dir`]. Per-agent rather than
|
||||||
|
/// hive-wide: the clauses a role carries came out of one agent's own prompt
|
||||||
|
/// files, and this daemon only ever spawns on behalf of that agent. The
|
||||||
|
/// directory is a convention, not a registry — it is created by whoever
|
||||||
|
/// writes a role into it, and its absence simply means this agent ships no
|
||||||
|
/// roles yet (see `crate::role`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn subagent_roles_dir() -> PathBuf {
|
||||||
|
state_dir().join("subagent_roles")
|
||||||
|
}
|
||||||
|
|
||||||
/// Harness-internal scratch dir this daemon writes its own generated
|
/// Harness-internal scratch dir this daemon writes its own generated
|
||||||
/// subagent `--mcp-config` file into (see `crate::mcp_config`). Delegates to
|
/// subagent `--mcp-config` file into (see `crate::mcp_config`). Delegates to
|
||||||
/// the shared resolver so this and `hive-agent`'s own harness dir always
|
/// the shared resolver so this and `hive-agent`'s own harness dir always
|
||||||
|
|
|
||||||
189
hive-subagent-mcp/src/role.rs
Normal file
189
hive-subagent-mcp/src/role.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
//! 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::os::unix::process::ExitStatusExt as _;
|
use std::os::unix::process::ExitStatusExt as _;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, PoisonError};
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|
@ -945,6 +945,86 @@ fn build_config(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which file `--append-system-prompt-file` is pointed at, if any, and the
|
||||||
|
/// prompt this turn actually opens with, for one spawn.
|
||||||
|
///
|
||||||
|
/// **The task is always the turn, never a standing system prompt — with or
|
||||||
|
/// without a role.** A task baked into the system prompt re-asserts itself
|
||||||
|
/// as an instruction on every later turn of the session (`continue`,
|
||||||
|
/// `goal`), not just the one the caller wrote it for; the system prompt is
|
||||||
|
/// the subagent's standing identity, not a one-shot channel. So the task
|
||||||
|
/// instructions (`prompt_file`) are always read and folded ahead of
|
||||||
|
/// `trigger` instead, the one channel that has always carried them.
|
||||||
|
///
|
||||||
|
/// No role: that folded trigger is the whole story — `None` comes back for
|
||||||
|
/// the system-prompt file, since there is no role text to put there.
|
||||||
|
/// A role: its text, alone, becomes the system-prompt file under
|
||||||
|
/// [`crate::paths::harness_dir`] — nothing about the task reaches it.
|
||||||
|
/// Per session, like the generated `--mcp-config` beside it, so two
|
||||||
|
/// concurrent `start`s never share a system-prompt file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Unreadable task instructions, or (with a role) a harness dir that
|
||||||
|
/// cannot be written. Both refuse the spawn rather than dropping either
|
||||||
|
/// half — see `docs/tools/subagent.md`.
|
||||||
|
fn system_prompt_and_trigger(
|
||||||
|
name: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
prompt_file: &str,
|
||||||
|
trigger: &str,
|
||||||
|
) -> anyhow::Result<(Option<PathBuf>, String)> {
|
||||||
|
// `harness_dir` panics outside a container (`HYPERHIVE_HARNESS_DIR`
|
||||||
|
// unset) — read lazily, only when a role means a system-prompt file
|
||||||
|
// actually needs somewhere to land. `compose_prompt` only touches `dir`
|
||||||
|
// inside its `Some(role)` branch, so a no-role call never forces this,
|
||||||
|
// in tests or otherwise.
|
||||||
|
let dir = role.map_or_else(PathBuf::new, |_| crate::paths::harness_dir());
|
||||||
|
compose_prompt(&dir, name, role, prompt_file, trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`system_prompt_and_trigger`] with the harness dir as an argument rather
|
||||||
|
/// than an ambient container-only environment read — the split a test uses
|
||||||
|
/// to control where the role-only file lands.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// See [`system_prompt_and_trigger`].
|
||||||
|
fn compose_prompt(
|
||||||
|
dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
prompt_file: &str,
|
||||||
|
trigger: &str,
|
||||||
|
) -> anyhow::Result<(Option<PathBuf>, String)> {
|
||||||
|
let task = std::fs::read_to_string(prompt_file).map_err(|e| {
|
||||||
|
anyhow::anyhow!("reading the task instructions at {prompt_file} failed: {e}")
|
||||||
|
})?;
|
||||||
|
let trigger = format!("{}\n\n{trigger}", task.trim_end());
|
||||||
|
let Some(role) = role else {
|
||||||
|
return Ok((None, trigger));
|
||||||
|
};
|
||||||
|
let path = render_role_prompt(dir, name, role)?;
|
||||||
|
Ok((Some(path), trigger))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `name`'s system-prompt file — `role`'s text, alone — into `dir`,
|
||||||
|
/// returning the file's path. Split from [`compose_prompt`] so the
|
||||||
|
/// directory is an argument rather than an ambient container-only
|
||||||
|
/// environment read.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// A `dir` that cannot be created or written.
|
||||||
|
fn render_role_prompt(dir: &Path, name: &str, role: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| anyhow::anyhow!("creating {} failed: {e}", dir.display()))?;
|
||||||
|
let path = dir.join(format!("subagent-system-prompt-{name}.md"));
|
||||||
|
std::fs::write(&path, role)
|
||||||
|
.map_err(|e| anyhow::anyhow!("writing {} failed: {e}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// The [`SessionStore`] a subagent's turn actually runs against — same
|
/// The [`SessionStore`] a subagent's turn actually runs against — same
|
||||||
/// resolution `hive_claude::Claude` itself uses, so a lookup here can't
|
/// resolution `hive_claude::Claude` itself uses, so a lookup here can't
|
||||||
/// disagree with what the driver does a moment later.
|
/// disagree with what the driver does a moment later.
|
||||||
|
|
@ -966,6 +1046,11 @@ pub struct StartRequest {
|
||||||
pub effort: Option<String>,
|
pub effort: Option<String>,
|
||||||
/// File holding the subagent's task instructions.
|
/// File holding the subagent's task instructions.
|
||||||
pub prompt_file: String,
|
pub prompt_file: String,
|
||||||
|
/// Which named role this subagent runs as — a file in this agent's own
|
||||||
|
/// role directory, named without its extension. `None` is the
|
||||||
|
/// pre-role shape: the task instructions alone. A name with no file
|
||||||
|
/// refuses the whole `start` (see [`crate::role`]).
|
||||||
|
pub role: Option<String>,
|
||||||
/// The first turn's prompt. A goal, when given, is appended to it.
|
/// The first turn's prompt. A goal, when given, is appended to it.
|
||||||
pub trigger: String,
|
pub trigger: String,
|
||||||
/// Working directory for the session; `None` inherits the daemon's.
|
/// Working directory for the session; `None` inherits the daemon's.
|
||||||
|
|
@ -1005,6 +1090,10 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
||||||
let name = req.name.as_str();
|
let name = req.name.as_str();
|
||||||
validate_name(name)?;
|
validate_name(name)?;
|
||||||
check_model(req.model.as_deref(), available_models().as_deref())?;
|
check_model(req.model.as_deref(), available_models().as_deref())?;
|
||||||
|
// Before `reserve`, so a role this agent does not have costs the caller
|
||||||
|
// an error and nothing else: no name claimed, no session archived, no
|
||||||
|
// process spawned.
|
||||||
|
let role = req.role.as_deref().map(crate::role::load).transpose()?;
|
||||||
if !state.reserve(name) {
|
if !state.reserve(name) {
|
||||||
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
|
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
|
||||||
}
|
}
|
||||||
|
|
@ -1027,10 +1116,13 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
||||||
let result = start_reserved(
|
let result = start_reserved(
|
||||||
state,
|
state,
|
||||||
name,
|
name,
|
||||||
req.model,
|
SpawnSpec {
|
||||||
req.effort,
|
model: req.model,
|
||||||
&req.prompt_file,
|
effort: req.effort,
|
||||||
trigger,
|
prompt_file: req.prompt_file,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
&trigger,
|
||||||
dir.as_deref(),
|
dir.as_deref(),
|
||||||
);
|
);
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
|
|
@ -1039,6 +1131,21 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How one spawn is shaped, as against which session it is for: the four
|
||||||
|
/// values `start` has already resolved by the time it commits to running.
|
||||||
|
/// A struct rather than four more parameters — the role made
|
||||||
|
/// `start_reserved`'s list long enough to be both unreadable and a lint,
|
||||||
|
/// the same reason [`StartRequest`] exists.
|
||||||
|
struct SpawnSpec {
|
||||||
|
model: Option<String>,
|
||||||
|
effort: Option<String>,
|
||||||
|
/// The caller's task-instruction file, as given.
|
||||||
|
prompt_file: String,
|
||||||
|
/// The named role's *text*, already loaded — `start` reads it before
|
||||||
|
/// reserving the name, so an unknown role never gets this far.
|
||||||
|
role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The slow, fallible part of `start`, run only after `reserve` has
|
/// The slow, fallible part of `start`, run only after `reserve` has
|
||||||
/// already closed the TOCTOU window — split out so `start` can release the
|
/// already closed the TOCTOU window — split out so `start` can release the
|
||||||
/// reservation on any error path here without duplicating that logic per
|
/// reservation on any error path here without duplicating that logic per
|
||||||
|
|
@ -1046,18 +1153,19 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
||||||
fn start_reserved(
|
fn start_reserved(
|
||||||
state: &Arc<State>,
|
state: &Arc<State>,
|
||||||
name: &str,
|
name: &str,
|
||||||
model: Option<String>,
|
spec: SpawnSpec,
|
||||||
effort: Option<String>,
|
trigger: &str,
|
||||||
prompt_file: &str,
|
|
||||||
trigger: String,
|
|
||||||
dir: Option<&str>,
|
dir: Option<&str>,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let signal_url = state.mint_signal_url(name);
|
let signal_url = state.mint_signal_url(name);
|
||||||
|
let (system_prompt, trigger) =
|
||||||
|
system_prompt_and_trigger(name, spec.role.as_deref(), &spec.prompt_file, trigger)?;
|
||||||
|
let system_prompt = system_prompt.map(|p| p.to_string_lossy().into_owned());
|
||||||
let config = build_config(
|
let config = build_config(
|
||||||
name,
|
name,
|
||||||
model,
|
spec.model,
|
||||||
effort,
|
spec.effort,
|
||||||
Some(prompt_file),
|
system_prompt.as_deref(),
|
||||||
dir,
|
dir,
|
||||||
Some(&signal_url),
|
Some(&signal_url),
|
||||||
);
|
);
|
||||||
|
|
@ -1936,6 +2044,7 @@ mod tests {
|
||||||
model: None,
|
model: None,
|
||||||
effort: None,
|
effort: None,
|
||||||
prompt_file: "/tmp/prompt.md".to_owned(),
|
prompt_file: "/tmp/prompt.md".to_owned(),
|
||||||
|
role: None,
|
||||||
trigger: "trigger".to_owned(),
|
trigger: "trigger".to_owned(),
|
||||||
dir: None,
|
dir: None,
|
||||||
goal: None,
|
goal: None,
|
||||||
|
|
@ -2032,6 +2141,144 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scratch file holding `body`, named after `tag` so parallel tests
|
||||||
|
/// never share one.
|
||||||
|
fn scratch_file(tag: &str, body: &str) -> PathBuf {
|
||||||
|
let path = std::env::temp_dir().join(format!("hive-subagent-session-test-{tag}.md"));
|
||||||
|
std::fs::write(&path, body).expect("a scratch file is writable");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_a_role_the_task_still_moves_out_of_the_system_prompt() {
|
||||||
|
// The principle the role fix was made on is general: a task baked
|
||||||
|
// into the system prompt re-asserts itself on every later turn, not
|
||||||
|
// just the one it was written for, whether or not the session has a
|
||||||
|
// role. So the no-role path folds the task into the trigger too —
|
||||||
|
// it must be ABSENT from whatever reaches
|
||||||
|
// `--append-system-prompt-file` and PRESENT in the trigger, the
|
||||||
|
// same shape the role-path tests below pin for the role case.
|
||||||
|
let task = scratch_file("no-role-task", "rebase the branch and report");
|
||||||
|
let (path, trigger) = system_prompt_and_trigger("n", None, &task.to_string_lossy(), "go")
|
||||||
|
.expect("a readable task file composes even with no role");
|
||||||
|
assert_eq!(
|
||||||
|
path, None,
|
||||||
|
"no role means no system-prompt file at all — there is no role \
|
||||||
|
text to put in one, and the task must not go in one either"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
trigger.contains("rebase the branch"),
|
||||||
|
"the task must reach the subagent as the turn's own prompt: {trigger:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let system_prompt = path.map(|p| p.to_string_lossy().into_owned());
|
||||||
|
let config = build_config("n", None, None, system_prompt.as_deref(), None, None);
|
||||||
|
assert!(
|
||||||
|
!config
|
||||||
|
.extra_args
|
||||||
|
.contains(&"--append-system-prompt-file".to_owned()),
|
||||||
|
"the task must not reach claude as a standing system prompt: {:?}",
|
||||||
|
config.extra_args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_role_s_system_prompt_file_holds_the_role_and_never_the_task() {
|
||||||
|
// A task rendered into the system prompt re-asserts itself as an
|
||||||
|
// instruction on every later turn of the session, not just the one
|
||||||
|
// it was written for — the system prompt is the subagent's
|
||||||
|
// standing identity, not a one-shot channel. Asserting the role is
|
||||||
|
// present is not enough to catch that regression; a merged file
|
||||||
|
// would pass that check too. The task's absence from this file is
|
||||||
|
// the assertion that actually pins the bug.
|
||||||
|
let task = scratch_file("role-only-task", "rebase the branch and report");
|
||||||
|
let dir = std::env::temp_dir().join("hive-subagent-session-test-role-only");
|
||||||
|
let (path, _trigger) = compose_prompt(
|
||||||
|
&dir,
|
||||||
|
"role-only-run",
|
||||||
|
Some("you are a reviewer; block on anything unsafe"),
|
||||||
|
&task.to_string_lossy(),
|
||||||
|
"go",
|
||||||
|
)
|
||||||
|
.expect("a role and a readable task file compose");
|
||||||
|
let path = path.expect("a role always renders a system-prompt file");
|
||||||
|
let body = std::fs::read_to_string(&path).expect("the system-prompt file is readable");
|
||||||
|
assert!(
|
||||||
|
body.contains("you are a reviewer"),
|
||||||
|
"the role must reach the system prompt: {body:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!body.contains("rebase the branch"),
|
||||||
|
"the task must NOT reach the system-prompt file: {body:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_role_still_delivers_the_task_as_the_turns_own_prompt() {
|
||||||
|
// The other half of the same fix: dropping the task from the
|
||||||
|
// system prompt must not drop it altogether — it goes back to
|
||||||
|
// being the turn's prompt, the same channel it reached the
|
||||||
|
// subagent by before roles existed.
|
||||||
|
let task = scratch_file("turn-prompt-task", "rebase the branch and report");
|
||||||
|
let dir = std::env::temp_dir().join("hive-subagent-session-test-turn-prompt");
|
||||||
|
let (_path, trigger) = compose_prompt(
|
||||||
|
&dir,
|
||||||
|
"turn-prompt-run",
|
||||||
|
Some("you are a reviewer"),
|
||||||
|
&task.to_string_lossy(),
|
||||||
|
"Carry out the task described in your instructions.",
|
||||||
|
)
|
||||||
|
.expect("a role and a readable task file compose");
|
||||||
|
assert!(
|
||||||
|
trigger.contains("rebase the branch"),
|
||||||
|
"the task must reach the subagent as the turn's own prompt: {trigger:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_role_with_an_unreadable_task_file_refuses_rather_than_dropping_either() {
|
||||||
|
let dir = std::env::temp_dir().join("hive-subagent-session-test-unreadable");
|
||||||
|
let err = compose_prompt(
|
||||||
|
&dir,
|
||||||
|
"n",
|
||||||
|
Some("a role"),
|
||||||
|
"/tmp/no-such-task-file-here.md",
|
||||||
|
"go",
|
||||||
|
)
|
||||||
|
.expect_err("an unreadable task file cannot silently become a role-only prompt");
|
||||||
|
assert!(err.to_string().contains("task instructions"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_start_naming_an_unknown_role_errors_and_reserves_nothing() {
|
||||||
|
// The error path end to end: the refusal happens before anything is
|
||||||
|
// claimed, so an unknown role costs a message and nothing else —
|
||||||
|
// no reservation to release, no archived prior session, no spawn.
|
||||||
|
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
|
||||||
|
let mut req = start_request("unknown-role-run");
|
||||||
|
req.role = Some("no-such-role-ships-anywhere".to_owned());
|
||||||
|
let err = start(&state, req).expect_err("a role this agent lacks must refuse the start");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("no-such-role-ships-anywhere"),
|
||||||
|
"the refusal names the role that was asked for: {err}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.occupancy("unknown-role-run"),
|
||||||
|
None,
|
||||||
|
"a refused start leaves the name free"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_start_naming_an_unusable_role_refuses_before_touching_the_filesystem() {
|
||||||
|
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
|
||||||
|
let mut req = start_request("bad-role-name");
|
||||||
|
req.role = Some("../../etc/passwd".to_owned());
|
||||||
|
let err = start(&state, req).expect_err("a role name that is not an identifier refuses");
|
||||||
|
assert!(err.to_string().contains("invalid role name"), "{err}");
|
||||||
|
assert_eq!(state.occupancy("bad-role-name"), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// The `--tools` value as it reaches the spawned argv.
|
/// The `--tools` value as it reaches the spawned argv.
|
||||||
fn spawned_tools(config: &Config) -> &str {
|
fn spawned_tools(config: &Config) -> &str {
|
||||||
let flag = config
|
let flag = config
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue