prompts+docs: the lifecycle tools reach the whole subtree, not just direct children

`require_descendant` (`socket_server/mod.rs:666`) authorises
kill/start/restart/update/get_logs with `topology::is_descendant_of` — the
caller's whole subtree, itself included. That has been true since `53b4e752`
(#1865), whose message says "a parent owns its whole subtree; the root covers
every agent as a consequence, no positional privilege", and two tests pin it
(`is_descendant_of_in_grandchild`, `is_descendant_of_in_self_is_true`).

The prose never followed. The four lifecycle tool descriptions, their
`// IMPORTANT:` comments, `docs/tools/lifecycle.md`, the tools README,
hive-agent-mcp's README and the system prompt every agent is rendered from all
still said "direct children only" — while `list_containers`, four tools away in
the same file, said "direct children + their subtrees".

`lifecycle.md` also taught the model #1865 deleted: "Privileged agents (for
example ruth) may operate on any sub-agent — the topology scope applies to all
others." There is no privileged class to belong to; ruth reaches every agent
because the check is transitive and everything sits under it.

Same drift on the state-query side: `resolve_agent_state_target` is
subtree-scoped by the same commit, so `get_loose_ends`' argument doc, the
`QueryAgentState` capability doc and `docs/turn-loop/mcp.md` were all telling a
parent it needs a capability to read a grandchild's threads.

Two smaller corrections found on the way:

* `list_containers` returns the caller itself. `is_descendant_of` is true for
  `candidate == ancestor` and `handle_list_descendants` filters the topology
  with it; called from a leaf agent it answers one row, that agent.
* `request_init_config` accepts any unused name — the requester becomes its
  parent — or an existing agent already in the caller's subtree, not "a direct
  child". The editing surface is narrower than the guard, though: only direct
  children's config repos are bind-mounted, so re-seeding deeper in the subtree
  leaves no local copy to edit. `lifecycle.md` now says so.

The prompt's other stale claim, the dead `request_apply_commit`, is #4226 and
was fixed independently by damocles in #4227 while this was being gated. This
branch keeps only the scope wording on that line.

Closes #4225.
This commit is contained in:
atlas 2026-09-11 15:20:47 +02:00
commit 0a80f21003
10 changed files with 93 additions and 66 deletions

View file

@ -10,8 +10,8 @@ would hit. HTTP is the sole transport; there is no stdio mode here.
This is where the core hyperhive tool surface lives: `send`, `recv`,
`remind`, `get_loose_ends`, `set_status`,
`get_agent_meta`, lifecycle (`kill`/`start`/`restart`/`update` on
direct children), scheduling, and the approval-request tools. Reach
`get_agent_meta`, lifecycle (`kill`/`start`/`restart`/`update` on the
agent's own subtree), scheduling, and the approval-request tools. Reach
for this crate when you're adding or changing a built-in tool rather
than an `extraMcpServers` add-on — those are separate stdio bridges
(see `hive-bash-mcp`, `hive-matrix-mcp`) that dial the harness socket

View file

@ -172,7 +172,8 @@ pub struct CancelLooseEndArgs {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AgentGetLooseEndsArgs {
/// Whose loose ends to list. Omit (or `null`) for your own. You may
/// also pass a direct child agent's name without any extra capability.
/// also pass the name of any agent in your subtree — a child, a child's
/// child, and so on down — without any extra capability.
/// Pass any other agent name to inspect their threads — requires the
/// `query_agent_state` capability; without it the request is rejected
/// with an error. The `"*"` hive-wide value is not available on the

View file

@ -560,11 +560,12 @@ impl AgentServer {
// IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. hive-c0re enforces the topology check
// server-side: the call is rejected unless `name` is a direct child.
// server-side: the call is rejected unless `name` is in the caller's
// subtree.
#[tool(
description = "Restart a direct child sub-agent container (stop + start). \
Only succeeds if `name` is a direct child of this agent in the topology \
tree the server enforces this. No approval required."
description = "Restart a sub-agent container (stop + start). Only succeeds if `name` \
is somewhere in this agent's subtree a child, a child's child, and so on down \
the topology tree which the server enforces. No approval required."
)]
async fn restart(&self, Parameters(args): Parameters<RestartArgs>) -> String {
let log = format!("{args:?}");
@ -583,11 +584,14 @@ impl AgentServer {
// IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. hive-c0re enforces the topology check
// server-side: the call is rejected unless `name` is a direct child.
#[tool(description = "Stop a direct child sub-agent container (graceful). \
Only succeeds if `name` is a direct child of this agent in the topology \
tree the server enforces this. No approval required. \
State dir is kept; recreating the agent reuses prior config + credentials.")]
// server-side: the call is rejected unless `name` is in the caller's
// subtree.
#[tool(
description = "Stop a sub-agent container (graceful). Only succeeds if `name` \
is somewhere in this agent's subtree a child, a child's child, and so on down \
the topology tree which the server enforces. No approval required. \
State dir is kept; recreating the agent reuses prior config + credentials."
)]
async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String {
let log = format!("{args:?}");
let name = args.name.clone();
@ -602,12 +606,14 @@ impl AgentServer {
// IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. hive-c0re enforces the topology check
// server-side: the call is rejected unless `name` is a direct child.
// server-side: the call is rejected unless `name` is in the caller's
// subtree.
#[tool(
description = "Rebuild a direct child sub-agent: re-applies the current hyperhive \
flake + agent.nix and restarts the container. Only succeeds if `name` is a direct \
child of this agent in the topology tree the server enforces this. \
No approval required. Idempotent use when a child needs its config reapplied."
description = "Rebuild a sub-agent: re-applies the current hyperhive flake + agent.nix \
and restarts the container. Only succeeds if `name` is somewhere in this agent's \
subtree a child, a child's child, and so on down the topology tree which the \
server enforces. No approval required. Idempotent use when an agent needs its \
config reapplied."
)]
async fn update(&self, Parameters(args): Parameters<UpdateArgs>) -> String {
let log = format!("{args:?}");
@ -625,11 +631,12 @@ impl AgentServer {
}
// IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. Returns all topological descendants of the
// calling agent with their running status.
// is granted to this agent. Returns the calling agent's whole subtree,
// itself included, with running status.
#[tool(
description = "List all containers that are topological descendants of this agent \
(direct children + their subtrees). Requires the `lifecycle` tool group. \
description = "List this agent's whole subtree — children, their children, and so on \
down with running status. The calling agent is part of its own subtree, so it \
appears in the listing too. Requires the `lifecycle` tool group. \
Returns every known descendant regardless of running state check the `running` \
field to distinguish live from stopped containers. Ordered by topology depth \
(parents before children), then alphabetically within each tier."
@ -705,20 +712,20 @@ impl AgentServer {
// IMPORTANT: this tool is only available when the `approvals` tool group
// is configured for the agent (`HIVE_TOOL_GROUPS` contains `approvals`).
// hive-c0re performs a topology check server-side: only direct children
// of the calling agent are accepted; all other names are rejected.
#[tool(
description = "Create a brand-new direct child agent's config repo and queue an \
// hive-c0re performs a topology check server-side: an unused `name` is
// accepted from any caller (the requester becomes its parent); an existing
// agent is accepted only from inside the caller's subtree.
#[tool(description = "Create a new agent's config repo and queue an \
`InitConfig` approval for the operator to review. Requires the `approvals` tool \
group. `name` must be a direct child of this agent in the topology tree. Fails if a \
config repo for that child already exists. This tool **creates the repo** and \
group. `name` must be either unused in which case you become its parent or an \
agent already in your subtree, whose config you are re-seeding. Fails if a \
config repo for that agent already exists. This tool **creates the repo** and \
nothing else: on approval hive-c0re seeds it with a default `agent.nix`, and \
approving the follow-up `Spawn` creates the container. \
Every config change the child's first one included goes through a PR on its \
config repo, made from a clone you take yourself, reviewed + approved by the \
operator. `/agents/<name>/config` is a **read-only copy** for reading a config, \
never an editing surface."
)]
never an editing surface.")]
async fn request_init_config(
&self,
Parameters(args): Parameters<RequestInitConfigArgs>,
@ -746,10 +753,13 @@ impl AgentServer {
// IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. hive-c0re enforces the topology check
// server-side: the call is rejected unless `name` is a direct child.
#[tool(description = "Start a stopped direct child sub-agent container. \
Only succeeds if `name` is a direct child of this agent in the topology \
tree the server enforces this. No approval required.")]
// server-side: the call is rejected unless `name` is in the caller's
// subtree.
#[tool(
description = "Start a stopped sub-agent container. Only succeeds if `name` \
is somewhere in this agent's subtree a child, a child's child, and so on down \
the topology tree which the server enforces. No approval required."
)]
async fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
let log = format!("{args:?}");
let name = args.name.clone();