hive-agent-mcp, hive-c0re, hive-sh4re: drop agent param from get_loose_ends

get_loose_ends now always returns the caller's own loose ends, for every
caller including the manager (ruth) — there is no separate manager
surface, ruth is a normal agent with different default capabilities.

- AgentGetLooseEndsArgs removed; get_loose_ends takes no args.
- Wire Request::GetLooseEnds collapses from an Option<String> target to
  a unit variant.
- hive-c0re's handle_get_loose_ends drops the "*" hive-wide branch and
  the subtree/capability resolver (resolve_agent_state_target); both
  are gone since there is no longer a target to resolve.
- loose_ends::hive_wide and Capability::QueryAgentState removed as
  dead code — their only callers were the two functions above.
- is_descendant_of is untouched (still used by lifecycle_handlers.rs
  and schedules.rs independently of this change).
- Docs updated: docs/turn-loop/mcp.md, docs/web-ui/dashboard.md,
  docs/process/conventions.md (Loose-ends wire shape + capabilities
  table), plus the doc comments in hive-core-agent-sock, mcp_config.rs
  and capabilities.rs that described the old shape.

Refs #4480
This commit is contained in:
atlas 2026-09-19 20:08:08 +02:00 • committed by mara
commit 729c5b4f42
12 changed files with 41 additions and 178 deletions

View file

@ -181,14 +181,12 @@ privileged bot, etc.) can land later without breaking existing
handlers. Each row carries enough context that the caller renders handlers. Each row carries enough context that the caller renders
it directly as a bulleted list, no follow-up fetch needed. it directly as a bulleted list, no follow-up fetch needed.
Per-flavour scoping is uniform across the two cancellable variants: `GetLooseEnds` takes no target — it always returns the caller's own
rows, on both the agent and manager sockets alike (the manager, `ruth`,
- **agent-flavour** `GetLooseEnds` only surfaces rows the calling is a normal agent here, just auto-deployed with different default
agent has standing in. `Approval` rows only appear when the capabilities). `Approval` rows only appear when the calling agent is
calling agent is the manager (sub-agents don't submit the manager (sub-agents don't submit approvals). `Reminder` rows are
approvals). `Reminder` rows are scoped to `owner == self`. scoped to `owner == self`.
- **manager-flavour** `GetLooseEnds` lists every pending row in
the swarm — full audit view.
Per-variant fields: Per-variant fields:
@ -364,7 +362,6 @@ that allows the underlying resource access.
|---|---| |---|---|
| `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` | | `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` |
| `read_host_journal` | registers the `get_host_journal` MCP tool + serves `GET /journal-host` requests | | `read_host_journal` | registers the `get_host_journal` MCP tool + serves `GET /journal-host` requests |
| `query_agent_state` | may call `get_loose_ends` / `CountPendingReminders` targeting non-child agents |
**Config storage** — per-agent capabilities live in **Config storage** — per-agent capabilities live in
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`. `/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`.

View file

@ -75,20 +75,15 @@ outcomes](README.md#turn-outcomes)) but via a generic "call
payload shapes and routing logic in payload shapes and routing logic in
[`docs/agent-lifecycle/approvals.md` § Helper events](../agent-lifecycle/approvals.md#helper-events-to-the-submitting-agent). [`docs/agent-lifecycle/approvals.md` § Helper events](../agent-lifecycle/approvals.md#helper-events-to-the-submitting-agent).
**Inbox** (`inbox` group): `get_loose_ends(agent?)`, **Inbox** (`inbox` group): `get_loose_ends()`,
`cancel_loose_end(kind, id)`, `remind(message, delay_seconds? | `cancel_loose_end(kind, id)`, `remind(message, delay_seconds? |
at_unix_timestamp?)`. at_unix_timestamp?)`.
- `get_loose_ends(agent?)` — list scheduled reminders, pending - `get_loose_ends()` — list scheduled reminders, pending approvals you
approvals you submitted, and active local tasks published by submitted, and active local tasks published by external MCP daemons
external MCP daemons (for example running bash tasks from (for example running bash tasks from `hive-bash-daemon`). Each row
`hive-bash-daemon`). Each row carries an id + kind for carries an id + kind for `cancel_loose_end`. Always your own threads —
`cancel_loose_end`. Omit `agent` to list your own threads. Pass there is no way to target another agent.
`agent: "<name>"` to inspect any agent in your subtree — a child, a
child's child, every agent below them (always accessible per topology
enforcement); a name outside the subtree requires the
`query_agent_state` capability. The `"*"` hive-wide query isn't
available on the agent socket.
- `cancel_loose_end` — hard-delete a `reminder`, cancel a pending - `cancel_loose_end` — hard-delete a `reminder`, cancel a pending
`approval` row, or clear a `todo` row (loose-ends-v2). Agents may `approval` row, or clear a `todo` row (loose-ends-v2). Agents may
only cancel rows they own; the `approval` kind is further restricted only cancel rows they own; the `approval` kind is further restricted

View file

@ -440,7 +440,6 @@ The current capabilities are:
|------|--------| |------|--------|
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent | | `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent |
| `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container | | `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container |
| `query_agent_state` | allows `get_loose_ends(agent: "<name>")` calls targeting other agents |
Each row is one agent. Columns are the capability names returned by Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking `GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking

View file

@ -131,19 +131,6 @@ pub struct CancelLooseEndArgs {
pub id: i64, pub id: i64,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AgentGetLooseEndsArgs {
/// Whose loose ends to list. Omit (or `null`) for your own. You may
/// 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
/// agent socket.
#[serde(default)]
pub agent: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateMetaInputsArgs { pub struct UpdateMetaInputsArgs {
/// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`).

View file

@ -24,7 +24,7 @@ mod args;
mod render; mod render;
pub use args::{ pub use args::{
AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, AckUntilArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs,
CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs,
MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestSchedulePromptArgs, SendArgs, SetStatusArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestSchedulePromptArgs, SendArgs, SetStatusArgs,
UpdateMetaInputsArgs, UpdateMetaInputsArgs,
@ -248,25 +248,20 @@ impl AgentServer {
the same row on its own regardless of whether you marked it done while it was \ the same row on its own regardless of whether you marked it done while it was \
still running, so doing that buys nothing and just costs a redundant call later. \ still running, so doing that buys nothing and just costs a redundant call later. \
Todos cap at 40 rows; a trailer line says how many more are pending — mark the \ Todos cap at 40 rows; a trailer line says how many more are pending — mark the \
reviewed ones done with `mark_todos_done`, then call again for the rest.\n\ reviewed ones done with `mark_todos_done`, then call again for the rest."
Pass `agent: \"<name>\"` to inspect a specific peer agent's threads. Direct \
child agents are always accessible. For non-children, the `query_agent_state` \
capability is required — without it the request is rejected with an error."
)] )]
async fn get_loose_ends(&self, Parameters(args): Parameters<AgentGetLooseEndsArgs>) -> String { async fn get_loose_ends(&self) -> String {
run_tool_envelope("get_loose_ends", String::new(), async move { run_tool_envelope("get_loose_ends", String::new(), async move {
let is_self_query = args.agent.is_none();
let (resp, retries) = self let (resp, retries) = self
.dispatch(hive_core_agent_sock::Request::GetLooseEnds { agent: args.agent }) .dispatch(hive_core_agent_sock::Request::GetLooseEnds)
.await; .await;
// Extract the vec so we can augment before rendering. // Extract the vec so we can augment before rendering.
let mut loose_ends = match resp { let mut loose_ends = match resp {
Ok(hive_core_agent_sock::Response::LooseEnds { loose_ends }) => loose_ends, Ok(hive_core_agent_sock::Response::LooseEnds { loose_ends }) => loose_ends,
other => return annotate_retries(reply_err(other, "get_loose_ends"), retries), other => return annotate_retries(reply_err(other, "get_loose_ends"), retries),
}; };
// Prepend matrix unread entry for self-queries only (can't // Prepend matrix unread entry.
// reach another agent's matrix daemon from here). if let Some(unread_rooms) = matrix_unread_summary().await {
if is_self_query && let Some(unread_rooms) = matrix_unread_summary().await {
let total = u32::try_from(unread_rooms.len()).unwrap_or(u32::MAX); let total = u32::try_from(unread_rooms.len()).unwrap_or(u32::MAX);
if total > 0 { if total > 0 {
let summary = format_matrix_summary(&unread_rooms); let summary = format_matrix_summary(&unread_rooms);
@ -279,16 +274,12 @@ impl AgentServer {
); );
} }
} }
// Merge the harness's local todos (loose-ends v2) for self-queries. // Merge the harness's local todos (loose-ends v2).
// The harness owns the todo store in-container; another agent's if let Some(todos) = local_todos().await {
// todos aren't reachable from here (same as matrix above).
if is_self_query && let Some(todos) = local_todos().await {
loose_ends.extend(todos); loose_ends.extend(todos);
} }
// Merge local pending reminders — same self-query-only // Merge local pending reminders.
// restriction: a manager asking for a child's loose-ends no longer if let Some(reminders) = local_reminders().await {
// sees the child's reminders, matching the todos precedent above).
if is_self_query && let Some(reminders) = local_reminders().await {
loose_ends.extend(reminders); loose_ends.extend(reminders);
} }
annotate_retries(render_loose_ends(&loose_ends), retries) annotate_retries(render_loose_ends(&loose_ends), retries)

View file

@ -358,7 +358,7 @@ impl Surface for AgentSurface {
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) { async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
let threads = match hive_sock_client::request::<_, Response>( let threads = match hive_sock_client::request::<_, Response>(
socket, socket,
&Request::GetLooseEnds { agent: None }, &Request::GetLooseEnds,
CONTROL_SOCKET_RETRY, CONTROL_SOCKET_RETRY,
) )
.await .await

View file

@ -49,12 +49,9 @@ fn allowed_capability_tools() -> Vec<String> {
let t = token.trim().to_ascii_lowercase(); let t = token.trim().to_ascii_lowercase();
match t.as_str() { match t.as_str() {
"read_host_journal" => tools.push("get_host_journal".to_owned()), "read_host_journal" => tools.push("get_host_journal".to_owned()),
// manage_root_agent / query_agent_state don't expose new MCP // manage_root_agent doesn't expose a new MCP tool: it gates
// tools: manage_root_agent gates existing lifecycle tools via // existing lifecycle tools via topology enforcement instead.
// topology enforcement; query_agent_state unlocks the `agent` "manage_root_agent" => {}
// field in get_loose_ends / count_pending_reminders /
// reminder_rollup (c0re enforces the cap server-side).
"manage_root_agent" | "query_agent_state" => {}
unknown => { unknown => {
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
} }

View file

@ -7,8 +7,7 @@
//! //!
//! ```json //! ```json
//! { //! {
//! "atlas": ["read_host_journal"], //! "atlas": ["read_host_journal"]
//! "ruth": ["query_agent_state"]
//! } //! }
//! ``` //! ```
//! //!

View file

@ -1,8 +1,8 @@
//! Loose-ends aggregator. Walks the `approvals` table once per call and //! Loose-ends aggregator. Walks the `approvals` table once per call and
//! assembles a `Vec<LooseEnd>` for either a single agent (`for_agent`) or //! assembles a `Vec<LooseEnd>` for a single agent (`for_agent`) — always
//! the whole hive (`hive_wide`). `Request::GetLooseEnds` from either the //! the caller's own. `Request::GetLooseEnds` from either the agent or
//! agent or manager socket lands here so the routing logic + age-seconds //! manager socket lands here so the age-seconds derivation stays in one
//! derivation stay in one place. Reminders are agent-local (in-container //! place. Reminders are agent-local (in-container
//! `hive-agent::reminders` store) and never sourced from here. The //! `hive-agent::reminders` store) and never sourced from here. The
//! `ask`/`answer` MCP tools, their wire protocol (`hive-c0re::questions`, //! `ask`/`answer` MCP tools, their wire protocol (`hive-c0re::questions`,
//! `stores::operator_questions`), and the operator dashboard's questions //! `stores::operator_questions`), and the operator dashboard's questions
@ -75,21 +75,3 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
} }
Ok(out) Ok(out)
} }
/// Hive-wide loose-ends view: EVERY pending approval. Manager surface
/// only; sub-agents can't see each other's threads via the agent surface
/// (`for_agent` filters by name).
pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
let now = Utc::now().timestamp();
let mut out = Vec::new();
for a in coord.approvals.pending()? {
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent.to_string(),
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at.timestamp()),
});
}
Ok(out)
}

View file

@ -541,7 +541,6 @@ fn handle_requeue_inflight(
/// Unified dispatch for every socket connection — per-agent sockets and the /// Unified dispatch for every socket connection — per-agent sockets and the
/// (now pure-transport) manager socket alike. There is no privilege bit; /// (now pure-transport) manager socket alike. There is no privilege bit;
/// authority derives uniformly from the caller's identity: hive-wide /// authority derives uniformly from the caller's identity: hive-wide
/// agent-state queries require the `QueryAgentState` capability; hive-wide
/// orchestration verbs (schedules / meta-inputs) require the matching /// orchestration verbs (schedules / meta-inputs) require the matching
/// tool-group (the grantable capability). /// tool-group (the grantable capability).
async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response { async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
@ -550,11 +549,7 @@ async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Respo
} }
match req { match req {
Request::ListDescendants => handle_list_descendants(coord, agent).await, Request::ListDescendants => handle_list_descendants(coord, agent).await,
// Agent-state queries: own subtree is free; other agents + the Request::GetLooseEnds => handle_get_loose_ends(coord, agent),
// hive-wide `"*"` sweep require `QueryAgentState`.
Request::GetLooseEnds { agent: target } => {
handle_get_loose_ends(coord, agent, target.as_deref())
}
// Orchestration verbs — gated per-verb on tool-group membership // Orchestration verbs — gated per-verb on tool-group membership
// (see `dispatch_orchestration`). // (see `dispatch_orchestration`).
_ => dispatch_orchestration(req, agent, coord).await, _ => dispatch_orchestration(req, agent, coord).await,
@ -652,29 +647,9 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
} }
} }
/// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree /// `GetLooseEnds` — always the caller's own loose ends.
/// descendant resolve freely (a parent sees its subtree, the root sees all); fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response {
/// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep match crate::loose_ends::for_agent(coord, agent) {
/// gated on `QueryAgentState`.
fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str, target: Option<&str>) -> Response {
let result = if target == Some("*") {
if !crate::capabilities::has_cap(
agent,
hive_sh4re::permissions::Capability::QueryAgentState,
) {
return Response::Err {
message: "query_agent_state capability required for hive-wide loose ends"
.to_owned(),
};
}
crate::loose_ends::hive_wide(coord)
} else {
match resolve_agent_state_target(agent, target) {
Ok(name) => crate::loose_ends::for_agent(coord, name),
Err(message) => return Response::Err { message },
}
};
match result {
Ok(loose_ends) => Response::LooseEnds { loose_ends }, Ok(loose_ends) => Response::LooseEnds { loose_ends },
Err(e) => Response::Err { Err(e) => Response::Err {
message: format!("{e:#}"), message: format!("{e:#}"),
@ -773,44 +748,6 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
} }
} }
/// Resolve the target agent name for a *named* `GetLooseEnds` query. Rules:
///
/// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
/// - `Some(descendant)` in the caller's subtree (the root's subtree is the whole hive) → allowed, no extra capability.
/// - `Some(other)` outside the subtree → requires the `query_agent_state` capability; error otherwise.
/// - `Some("*")` → rejected here; the hive-wide sweep is handled by `handle_get_loose_ends` under the same `query_agent_state` gate.
fn resolve_agent_state_target<'a>(
caller: &'a str,
target: Option<&'a str>,
) -> Result<&'a str, String> {
match target {
None => Ok(caller),
Some("*") => Err(
"hive-wide query (agent=\"*\") is only valid for loose-ends; \
not available for this query"
.to_owned(),
),
Some(name) => {
// Own subtree (the root covers all) is visible without extra
// capability; `is_descendant_of` returns true for `name == caller`.
if crate::topology::is_descendant_of(name, caller) {
return Ok(name);
}
if crate::capabilities::has_cap(
caller,
hive_sh4re::permissions::Capability::QueryAgentState,
) {
Ok(name)
} else {
Err(format!(
"agent `{caller}` cannot query `{name}`: not in its subtree and \
`query_agent_state` capability is not granted"
))
}
}
}
}
/// Field-named journal-query knobs for [`dispatch_host_journal`]. /// Field-named journal-query knobs for [`dispatch_host_journal`].
/// Borrows straight from the matched `GetHostJournal` request variant. /// Borrows straight from the matched `GetHostJournal` request variant.
pub struct HostJournalArgs<'a> { pub struct HostJournalArgs<'a> {

View file

@ -63,16 +63,11 @@ pub enum Request {
/// Non-mutating — pulls from the broker without delivering. The /// Non-mutating — pulls from the broker without delivering. The
/// per-agent web UI uses this to render its own inbox section. /// per-agent web UI uses this to render its own inbox section.
Recent { limit: u64 }, Recent { limit: u64 },
/// Loose-ends view. On the agent socket: `None` = self; direct /// Loose-ends view — always the caller's own. Same shape on the agent
/// children are always accessible; non-children require the /// and manager sockets; the manager (`ruth`) is a normal agent here, it
/// `query_agent_state` capability — rejected with an error otherwise; /// just sees its own threads like anyone else. See
/// `"*"` is always rejected (use the manager socket). On the manager /// `docs/process/conventions.md::Loose-ends wire shape`.
/// socket: `None` = manager self, `"*"` = hive-wide, any name = GetLooseEnds,
/// that agent. See `docs/process/conventions.md::Loose-ends wire shape`.
GetLooseEnds {
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
},
/// Set a free-text status string visible on the dashboard. The harness /// Set a free-text status string visible on the dashboard. The harness
/// writes `{state_dir}/hyperhive-status` locally before sending this /// writes `{state_dir}/hyperhive-status` locally before sending this
/// request; hive-c0re just triggers a dashboard rescan on receipt. /// request; hive-c0re just triggers a dashboard rescan on receipt.

View file

@ -367,25 +367,12 @@ pub enum Capability {
/// MCP tool `get_host_journal` is only registered in the harness /// MCP tool `get_host_journal` is only registered in the harness
/// when this capability is present. /// when this capability is present.
ReadHostJournal, ReadHostJournal,
/// Agent can query agents outside its own subtree via `GetLooseEnds`,
/// `CountPendingReminders`, and `ReminderRollup` on the agent
/// socket. Without this capability, targeting an agent outside the
/// caller's subtree is rejected with an error (the caller itself and
/// every descendant are always accessible without any capability).
/// The `"*"` hive-wide value is not
/// available on the agent socket even with this capability — use the
/// manager socket for swarm-wide scans.
QueryAgentState,
} }
impl Capability { impl Capability {
/// Every known capability in a stable order. Use this to enumerate /// Every known capability in a stable order. Use this to enumerate
/// columns in the permissions UI or validate incoming capability strings. /// columns in the permissions UI or validate incoming capability strings.
pub const ALL: &'static [Self] = &[ pub const ALL: &'static [Self] = &[Self::ManageRootAgent, Self::ReadHostJournal];
Self::ManageRootAgent,
Self::ReadHostJournal,
Self::QueryAgentState,
];
/// Short human-readable description suitable for a tooltip or help text. /// Short human-readable description suitable for a tooltip or help text.
#[must_use] #[must_use]
@ -395,9 +382,6 @@ impl Capability {
"lifecycle-manage the root/manager agent on hive crash recovery" "lifecycle-manage the root/manager agent on hive crash recovery"
} }
Self::ReadHostJournal => "read host journald via get_host_journal MCP tool", Self::ReadHostJournal => "read host journald via get_host_journal MCP tool",
Self::QueryAgentState => {
"query non-child agents' loose ends and reminder state via get_loose_ends"
}
} }
} }
} }