diff --git a/docs/process/conventions.md b/docs/process/conventions.md index 5c69f540..cafcc929 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -181,12 +181,14 @@ privileged bot, etc.) can land later without breaking existing handlers. Each row carries enough context that the caller renders it directly as a bulleted list, no follow-up fetch needed. -`GetLooseEnds` takes no target — it always returns the caller's own -rows, on both the agent and manager sockets alike (the manager, `ruth`, -is a normal agent here, just deployed automatically with different -default capabilities). `Approval` rows only appear when the calling agent is -the manager (sub-agents don't submit approvals). `Reminder` rows are -scoped to `owner == self`. +Per-flavour scoping is uniform across the two cancellable variants: + +- **agent-flavour** `GetLooseEnds` only surfaces rows the calling + agent has standing in. `Approval` rows only appear when the + calling agent is the manager (sub-agents don't submit + approvals). `Reminder` rows are scoped to `owner == self`. +- **manager-flavour** `GetLooseEnds` lists every pending row in + the swarm — full audit view. Per-variant fields: @@ -362,6 +364,7 @@ that allows the underlying resource access. |---|---| | `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 | +| `query_agent_state` | may call `get_loose_ends` / `CountPendingReminders` targeting non-child agents | **Config storage** — per-agent capabilities live in `/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`. diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index e254d0fd..99be1f11 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -75,15 +75,20 @@ outcomes](README.md#turn-outcomes)) but via a generic "call payload shapes and routing logic in [`docs/agent-lifecycle/approvals.md` § Helper events](../agent-lifecycle/approvals.md#helper-events-to-the-submitting-agent). -**Inbox** (`inbox` group): `get_loose_ends()`, +**Inbox** (`inbox` group): `get_loose_ends(agent?)`, `cancel_loose_end(kind, id)`, `remind(message, delay_seconds? | at_unix_timestamp?)`. -- `get_loose_ends()` — list scheduled reminders, pending approvals you - submitted, and active local tasks published by external MCP daemons - (for example running bash tasks from `hive-bash-daemon`). Each row - carries an id + kind for `cancel_loose_end`. Always your own threads — - there is no way to target another agent. +- `get_loose_ends(agent?)` — list scheduled reminders, pending + approvals you submitted, and active local tasks published by + external MCP daemons (for example running bash tasks from + `hive-bash-daemon`). Each row carries an id + kind for + `cancel_loose_end`. Omit `agent` to list your own threads. Pass + `agent: ""` 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 `approval` row, or clear a `todo` row (loose-ends-v2). Agents may only cancel rows they own; the `approval` kind is further restricted diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 47ccbf1e..ca780d0c 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -440,6 +440,7 @@ The current capabilities are: |------|--------| | `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 | +| `query_agent_state` | allows `get_loose_ends(agent: "")` calls targeting other agents | Each row is one agent. Columns are the capability names returned by `GET /api/capabilities` as `caps: Vec`. Checking or unchecking diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 2c3b4ac7..7843649f 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -131,6 +131,19 @@ pub struct CancelLooseEndArgs { 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, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateMetaInputsArgs { /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index c4ba2e45..dbd767e4 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -24,9 +24,10 @@ mod args; mod render; pub use args::{ - AckUntilArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, CreateRepoArgs, - EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, MarkTodosDoneArgs, - RecvArgs, RemindArgs, RequestSchedulePromptArgs, SendArgs, SetStatusArgs, UpdateMetaInputsArgs, + AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, + CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, + MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestSchedulePromptArgs, SendArgs, SetStatusArgs, + UpdateMetaInputsArgs, }; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; @@ -247,20 +248,25 @@ impl AgentServer { 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. \ 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." + reviewed ones done with `mark_todos_done`, then call again for the rest.\n\ + Pass `agent: \"\"` 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) -> String { + async fn get_loose_ends(&self, Parameters(args): Parameters) -> String { run_tool_envelope("get_loose_ends", String::new(), async move { + let is_self_query = args.agent.is_none(); let (resp, retries) = self - .dispatch(hive_core_agent_sock::Request::GetLooseEnds) + .dispatch(hive_core_agent_sock::Request::GetLooseEnds { agent: args.agent }) .await; // Extract the vec so we can augment before rendering. let mut loose_ends = match resp { Ok(hive_core_agent_sock::Response::LooseEnds { loose_ends }) => loose_ends, other => return annotate_retries(reply_err(other, "get_loose_ends"), retries), }; - // Prepend matrix unread entry. - if let Some(unread_rooms) = matrix_unread_summary().await { + // Prepend matrix unread entry for self-queries only (can't + // reach another agent's matrix daemon from here). + if is_self_query && let Some(unread_rooms) = matrix_unread_summary().await { let total = u32::try_from(unread_rooms.len()).unwrap_or(u32::MAX); if total > 0 { let summary = format_matrix_summary(&unread_rooms); @@ -273,12 +279,16 @@ impl AgentServer { ); } } - // Merge the harness's local todos (loose-ends v2). - if let Some(todos) = local_todos().await { + // Merge the harness's local todos (loose-ends v2) for self-queries. + // The harness owns the todo store in-container; another agent's + // todos aren't reachable from here (same as matrix above). + if is_self_query && let Some(todos) = local_todos().await { loose_ends.extend(todos); } - // Merge local pending reminders. - if let Some(reminders) = local_reminders().await { + // Merge local pending reminders — same self-query-only + // restriction: a manager asking for a child's loose-ends no longer + // sees the child's reminders, matching the todos precedent above). + if is_self_query && let Some(reminders) = local_reminders().await { loose_ends.extend(reminders); } annotate_retries(render_loose_ends(&loose_ends), retries) diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index a1537602..d75ba543 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -358,7 +358,7 @@ impl Surface for AgentSurface { async fn post_turn_counts(socket: &Path) -> (Option, Option) { let threads = match hive_sock_client::request::<_, Response>( socket, - &Request::GetLooseEnds, + &Request::GetLooseEnds { agent: None }, CONTROL_SOCKET_RETRY, ) .await diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index f24fbb7e..deff0150 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -49,9 +49,12 @@ fn allowed_capability_tools() -> Vec { let t = token.trim().to_ascii_lowercase(); match t.as_str() { "read_host_journal" => tools.push("get_host_journal".to_owned()), - // manage_root_agent doesn't expose a new MCP tool: it gates - // existing lifecycle tools via topology enforcement instead. - "manage_root_agent" => {} + // manage_root_agent / query_agent_state don't expose new MCP + // tools: manage_root_agent gates existing lifecycle tools via + // topology enforcement; query_agent_state unlocks the `agent` + // field in get_loose_ends / count_pending_reminders / + // reminder_rollup (c0re enforces the cap server-side). + "manage_root_agent" | "query_agent_state" => {} unknown => { tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); } diff --git a/hive-c0re/src/agent_config/capabilities.rs b/hive-c0re/src/agent_config/capabilities.rs index 8855e8fb..998c408c 100644 --- a/hive-c0re/src/agent_config/capabilities.rs +++ b/hive-c0re/src/agent_config/capabilities.rs @@ -7,7 +7,8 @@ //! //! ```json //! { -//! "atlas": ["read_host_journal"] +//! "atlas": ["read_host_journal"], +//! "ruth": ["query_agent_state"] //! } //! ``` //! diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index 6cc8ad03..408ee926 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -1,8 +1,8 @@ //! Loose-ends aggregator. Walks the `approvals` table once per call and -//! assembles a `Vec` for a single agent (`for_agent`) — always -//! the caller's own. `Request::GetLooseEnds` from either the agent or -//! manager socket lands here so the age-seconds derivation stays in one -//! place. Reminders are agent-local (in-container +//! assembles a `Vec` for either a single agent (`for_agent`) or +//! the whole hive (`hive_wide`). `Request::GetLooseEnds` from either the +//! agent or manager socket lands here so the routing logic + age-seconds +//! derivation stay in one place. Reminders are agent-local (in-container //! `hive-agent::reminders` store) and never sourced from here. The //! `ask`/`answer` MCP tools, their wire protocol (`hive-c0re::questions`, //! `stores::operator_questions`), and the operator dashboard's questions @@ -75,3 +75,21 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { } 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> { + 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) +} diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 5084cd30..141c0655 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -180,10 +180,9 @@ pub(crate) fn recv_timeout(wait_seconds: Option) -> std::time::Duration { /// Handle the subset of `Request` variants that are identical on both /// the agent socket and the manager socket. Returns `Some(response)` for -/// every variant it handles; returns `None` for the remaining variants — -/// `ListDescendants` and the orchestration verbs (schedules / meta-inputs), -/// which need per-verb tool-group gating — or for host-admin / unknown -/// requests invalid on either socket. +/// every variant it handles; returns `None` for variants with socket-specific +/// semantics (e.g. `GetLooseEnds`, where the manager can target other +/// agents) or for manager-only variants. /// /// The unified `dispatch` calls this first; the remaining arms (which gate /// on topology / capabilities / tool-groups) are handled there. @@ -542,6 +541,7 @@ fn handle_requeue_inflight( /// Unified dispatch for every socket connection — per-agent sockets and the /// (now pure-transport) manager socket alike. There is no privilege bit; /// 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 /// tool-group (the grantable capability). async fn dispatch(req: &Request, agent: &str, coord: &Arc) -> Response { @@ -550,7 +550,11 @@ async fn dispatch(req: &Request, agent: &str, coord: &Arc) -> Respo } match req { Request::ListDescendants => handle_list_descendants(coord, agent).await, - Request::GetLooseEnds => handle_get_loose_ends(coord, agent), + // Agent-state queries: own subtree is free; other agents + the + // 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 // (see `dispatch_orchestration`). _ => dispatch_orchestration(req, agent, coord).await, @@ -648,9 +652,29 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option { } } -/// `GetLooseEnds` — always the caller's own loose ends. -fn handle_get_loose_ends(coord: &Arc, agent: &str) -> Response { - match crate::loose_ends::for_agent(coord, agent) { +/// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree +/// descendant resolve freely (a parent sees its subtree, the root sees all); +/// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep +/// gated on `QueryAgentState`. +fn handle_get_loose_ends(coord: &Arc, 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 }, Err(e) => Response::Err { message: format!("{e:#}"), @@ -749,6 +773,44 @@ 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`]. /// Borrows straight from the matched `GetHostJournal` request variant. pub struct HostJournalArgs<'a> { diff --git a/hive-core-agent-sock/src/lib.rs b/hive-core-agent-sock/src/lib.rs index 83634056..9731b6d6 100644 --- a/hive-core-agent-sock/src/lib.rs +++ b/hive-core-agent-sock/src/lib.rs @@ -63,11 +63,16 @@ pub enum Request { /// Non-mutating — pulls from the broker without delivering. The /// per-agent web UI uses this to render its own inbox section. Recent { limit: u64 }, - /// Loose-ends view — always the caller's own. Same shape on the agent - /// and manager sockets; the manager (`ruth`) is a normal agent here, it - /// just sees its own threads like anyone else. See - /// `docs/process/conventions.md::Loose-ends wire shape`. - GetLooseEnds, + /// Loose-ends view. On the agent socket: `None` = self; direct + /// children are always accessible; non-children require the + /// `query_agent_state` capability — rejected with an error otherwise; + /// `"*"` is always rejected (use the manager socket). On the manager + /// socket: `None` = manager self, `"*"` = hive-wide, any name = + /// that agent. See `docs/process/conventions.md::Loose-ends wire shape`. + GetLooseEnds { + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option, + }, /// Set a free-text status string visible on the dashboard. The harness /// writes `{state_dir}/hyperhive-status` locally before sending this /// request; hive-c0re just triggers a dashboard rescan on receipt. diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 8c8b4276..37de1f75 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -367,12 +367,25 @@ pub enum Capability { /// MCP tool `get_host_journal` is only registered in the harness /// when this capability is present. 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 { /// Every known capability in a stable order. Use this to enumerate /// columns in the permissions UI or validate incoming capability strings. - pub const ALL: &'static [Self] = &[Self::ManageRootAgent, Self::ReadHostJournal]; + pub const ALL: &'static [Self] = &[ + Self::ManageRootAgent, + Self::ReadHostJournal, + Self::QueryAgentState, + ]; /// Short human-readable description suitable for a tooltip or help text. #[must_use] @@ -382,6 +395,9 @@ impl Capability { "lifecycle-manage the root/manager agent on hive crash recovery" } 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" + } } } }