Compare commits
6 changed files with 13 additions and 565 deletions
|
|
@ -71,57 +71,6 @@ 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
|
||||
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
|
||||
|
||||
`start` takes an optional `goal`. Without one a session is a single turn,
|
||||
|
|
|
|||
|
|
@ -19,5 +19,4 @@
|
|||
pub mod mcp;
|
||||
pub mod mcp_config;
|
||||
pub mod paths;
|
||||
pub mod role;
|
||||
pub mod session;
|
||||
|
|
|
|||
|
|
@ -62,16 +62,6 @@ struct StartArgs {
|
|||
/// file, not an inline string, so a large recipe can't blow past a
|
||||
/// shell argument length limit.
|
||||
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:
|
||||
/// a generic "carry out your instructions" nudge — the real task detail
|
||||
/// belongs in `prompt_file`, not here.
|
||||
|
|
@ -211,9 +201,7 @@ impl SubagentMcp {
|
|||
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), \
|
||||
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. 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 \
|
||||
way it stops, one todo is pushed at the end and `status` says which. See the \
|
||||
`base:claude-subagents` skill for when to reach for this."
|
||||
)]
|
||||
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
|
||||
|
|
@ -224,7 +212,6 @@ impl SubagentMcp {
|
|||
model: args.model,
|
||||
effort: args.effort,
|
||||
prompt_file: args.prompt_file,
|
||||
role: args.role,
|
||||
trigger: args.trigger,
|
||||
dir: args.dir,
|
||||
goal: args.goal,
|
||||
|
|
@ -559,45 +546,6 @@ 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]
|
||||
async fn an_unminted_signal_token_is_a_bare_404() {
|
||||
// No enumeration: a token this daemon never issued gets the same
|
||||
|
|
|
|||
|
|
@ -30,18 +30,6 @@ pub fn state_dir() -> PathBuf {
|
|||
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
|
||||
/// subagent `--mcp-config` file into (see `crate::mcp_config`). Delegates to
|
||||
/// the shared resolver so this and `hive-agent`'s own harness dir always
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
//! 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::os::unix::process::ExitStatusExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -945,86 +945,6 @@ 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
|
||||
/// resolution `hive_claude::Claude` itself uses, so a lookup here can't
|
||||
/// disagree with what the driver does a moment later.
|
||||
|
|
@ -1046,11 +966,6 @@ pub struct StartRequest {
|
|||
pub effort: Option<String>,
|
||||
/// File holding the subagent's task instructions.
|
||||
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.
|
||||
pub trigger: String,
|
||||
/// Working directory for the session; `None` inherits the daemon's.
|
||||
|
|
@ -1090,10 +1005,6 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
|||
let name = req.name.as_str();
|
||||
validate_name(name)?;
|
||||
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) {
|
||||
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
|
||||
}
|
||||
|
|
@ -1116,13 +1027,10 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
|||
let result = start_reserved(
|
||||
state,
|
||||
name,
|
||||
SpawnSpec {
|
||||
model: req.model,
|
||||
effort: req.effort,
|
||||
prompt_file: req.prompt_file,
|
||||
role,
|
||||
},
|
||||
&trigger,
|
||||
req.model,
|
||||
req.effort,
|
||||
&req.prompt_file,
|
||||
trigger,
|
||||
dir.as_deref(),
|
||||
);
|
||||
if result.is_err() {
|
||||
|
|
@ -1131,21 +1039,6 @@ pub fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
|||
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
|
||||
/// already closed the TOCTOU window — split out so `start` can release the
|
||||
/// reservation on any error path here without duplicating that logic per
|
||||
|
|
@ -1153,19 +1046,18 @@ struct SpawnSpec {
|
|||
fn start_reserved(
|
||||
state: &Arc<State>,
|
||||
name: &str,
|
||||
spec: SpawnSpec,
|
||||
trigger: &str,
|
||||
model: Option<String>,
|
||||
effort: Option<String>,
|
||||
prompt_file: &str,
|
||||
trigger: String,
|
||||
dir: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
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(
|
||||
name,
|
||||
spec.model,
|
||||
spec.effort,
|
||||
system_prompt.as_deref(),
|
||||
model,
|
||||
effort,
|
||||
Some(prompt_file),
|
||||
dir,
|
||||
Some(&signal_url),
|
||||
);
|
||||
|
|
@ -2044,7 +1936,6 @@ mod tests {
|
|||
model: None,
|
||||
effort: None,
|
||||
prompt_file: "/tmp/prompt.md".to_owned(),
|
||||
role: None,
|
||||
trigger: "trigger".to_owned(),
|
||||
dir: None,
|
||||
goal: None,
|
||||
|
|
@ -2141,144 +2032,6 @@ 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.
|
||||
fn spawned_tools(config: &Config) -> &str {
|
||||
let flag = config
|
||||
|
|
|
|||
Loading…
Reference in a new issue