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.
This commit is contained in:
parent
aaedff20a9
commit
657875b2fa
6 changed files with 545 additions and 11 deletions
|
|
@ -62,6 +62,16 @@ 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.
|
||||
|
|
@ -201,7 +211,9 @@ 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. 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."
|
||||
)]
|
||||
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
|
||||
|
|
@ -212,6 +224,7 @@ 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,
|
||||
|
|
@ -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]
|
||||
async fn an_unminted_signal_token_is_a_bare_404() {
|
||||
// No enumeration: a token this daemon never issued gets the same
|
||||
|
|
|
|||
Loading…
Reference in a new issue