From 4834ca413cb31bf9474865b38735ab68b9bf366a Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 21:21:25 +0200 Subject: [PATCH] feat(#1021): query_agent_state capability for agent socket GetLooseEnds/CountPendingReminders/ReminderRollup --- hive-ag3nt/src/mcp.rs | 26 ++++++++++-- hive-c0re/src/agent_server.rs | 74 +++++++++++++++++++++++++++-------- hive-sh4re/src/lib.rs | 39 ++++++++++++------ 3 files changed, 109 insertions(+), 30 deletions(-) diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 13e249eb..fd24e7e4 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -661,11 +661,14 @@ impl AgentServer { at turn start to remember what you owe / what's owed to you without scrolling \ inbox history. Output is a short bulleted list with ids, ages in seconds, and \ the relevant context. Each `question` or `reminder` row can be cancelled by \ - passing its id + kind to `cancel_loose_end`. Empty result is reported clearly." + passing its id + kind to `cancel_loose_end`. Empty result is reported clearly.\n\ + Pass `agent: \"\"` to inspect a specific peer agent's threads — requires \ + the `query_agent_state` capability; without it the field is ignored and results \ + are scoped to you." )] - 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 (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: None }).await; + let (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent }).await; let mut out = annotate_retries(format_loose_ends(resp), retries); // Append any local bash tasks still in pending/running state so // the agent sees all outstanding work in one call. @@ -1056,6 +1059,18 @@ pub struct GetLooseEndsArgs { pub agent: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AgentGetLooseEndsArgs { + /// Whose loose ends to list. Omit (or `null`) for your own. Pass a + /// specific agent name to inspect that agent's threads — requires + /// the `query_agent_state` capability; without it the field is + /// ignored and results are scoped to you. The `"*"` hive-wide value + /// is not available on the agent socket; use the manager socket for + /// swarm-wide scans. + #[serde(default)] + pub agent: Option, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RequestApplyCommitArgs { /// Logical agent name whose config repo the commit lives in. @@ -1925,6 +1940,11 @@ fn allowed_capability_tools() -> Vec { // manage_root_agent doesn't expose new MCP tools (it gates // existing lifecycle tools via the topology enforcement). "manage_root_agent" => {} + // query_agent_state doesn't expose new MCP tools; it unlocks + // the `agent` field in get_loose_ends / count_pending_reminders + // / reminder_rollup on the agent socket (c0re enforces the cap + // server-side; the harness honours it by passing the field). + "query_agent_state" => {} unknown => { tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); } diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 11bdd5dc..2401b53d 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -307,26 +307,40 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> return resp; } match req { - AgentRequest::GetLooseEnds { .. } => match crate::loose_ends::for_agent(coord, agent) { - Ok(loose_ends) => AgentResponse::LooseEnds { loose_ends }, - Err(e) => AgentResponse::Err { - message: format!("{e:#}"), - }, - }, - AgentRequest::CountPendingReminders { .. } => { - match coord.broker.count_pending_reminders_for(agent) { - Ok(count) => AgentResponse::PendingRemindersCount { count }, - Err(e) => AgentResponse::Err { - message: format!("{e:#}"), + AgentRequest::GetLooseEnds { agent: target } => { + let name = resolve_agent_state_target(agent, target.as_deref()); + match name { + Ok(name) => match crate::loose_ends::for_agent(coord, name) { + Ok(loose_ends) => AgentResponse::LooseEnds { loose_ends }, + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, }, + Err(message) => AgentResponse::Err { message }, } } - AgentRequest::ReminderRollup { since_secs, .. } => { - match coord.broker.reminder_rollup_for(agent, *since_secs) { - Ok(stats) => AgentResponse::ReminderRollup(stats), - Err(e) => AgentResponse::Err { - message: format!("{e:#}"), + AgentRequest::CountPendingReminders { agent: target } => { + let name = resolve_agent_state_target(agent, target.as_deref()); + match name { + Ok(name) => match coord.broker.count_pending_reminders_for(name) { + Ok(count) => AgentResponse::PendingRemindersCount { count }, + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, }, + Err(message) => AgentResponse::Err { message }, + } + } + AgentRequest::ReminderRollup { since_secs, agent: target } => { + let name = resolve_agent_state_target(agent, target.as_deref()); + match name { + Ok(name) => match coord.broker.reminder_rollup_for(name, *since_secs) { + Ok(stats) => AgentResponse::ReminderRollup(stats), + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, + }, + Err(message) => AgentResponse::Err { message }, } } // Manager-only variants are not valid on the agent socket. @@ -610,6 +624,34 @@ fn auto_reminder_path(agent: &str) -> String { format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md") } +/// Resolve the target agent name for `GetLooseEnds`, `CountPendingReminders`, +/// and `ReminderRollup` on the agent socket. Returns the caller's own name +/// when no target is specified or when the caller lacks `query_agent_state`. +/// Returns an error string when the caller requests `"*"` (hive-wide scans +/// are manager-only) or requests another agent without the capability. +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 not available on the agent socket; \ + use the manager socket for swarm-wide scans".to_owned() + ), + Some(name) => { + if name == caller { + return Ok(caller); + } + if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { + Ok(name) + } else { + Err(format!( + "agent `{caller}` does not have the query_agent_state capability; \ + agent field ignored — grant the capability to query other agents" + )) + } + } + } +} + /// Resolve the `due_at` unix timestamp for a Remind request. Returns /// distinct error messages for each failure mode (overflow on /// `InSeconds`, pre-epoch clock, `i64` cast wrap) so the caller can tell diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index f0305874..e596fb36 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -373,18 +373,21 @@ pub enum Request { file_path: Option, }, /// Loose-ends view. On the agent socket, scoped to the calling agent - /// (the `agent` field is ignored — agents can only see their own - /// loose ends). On the manager socket, `agent = None` scopes to the - /// manager itself, `Some("*")` is hive-wide, `Some("")` is - /// that agent's loose ends. See - /// `docs/conventions.md::Loose-ends wire shape`. + /// unless the agent holds the `query_agent_state` capability, in + /// which case `Some("")` targets a specific agent's threads + /// (the `"*"` hive-wide value is manager-only). On the manager + /// socket, `agent = None` scopes to the manager itself, + /// `Some("*")` is hive-wide, `Some("")` is that agent's + /// loose ends. See `docs/conventions.md::Loose-ends wire shape`. GetLooseEnds { #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option, }, - /// Count of pending (un-delivered) reminders. On the agent socket - /// always scoped to the calling agent. On the manager socket, - /// `agent = None` means self, `Some("")` means that agent. + /// Count of pending (un-delivered) reminders. On the agent socket, + /// scoped to the calling agent unless the agent holds + /// `query_agent_state`, in which case `Some("")` targets + /// that agent. On the manager socket, `agent = None` means self, + /// `Some("")` means that agent. /// Used by the harness's per-turn stats sink. CountPendingReminders { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -392,15 +395,20 @@ pub enum Request { }, /// Reminder statistics: counts of scheduled, delivered, and pending /// reminders over a time window. `since_secs` filters to reminders - /// created in the last N seconds (0 = all). On the manager socket - /// `agent = None` means self, `Some("")` means that agent. + /// created in the last N seconds (0 = all). On the agent socket, + /// scoped to the calling agent unless the agent holds + /// `query_agent_state`, in which case `Some("")` targets + /// that agent. On the manager socket, `agent = None` means self, + /// `Some("")` means that agent. ReminderRollup { /// Only count reminders created in the last N seconds from now. /// Pass 0 to include all reminders. #[serde(default)] since_secs: u64, /// Whose reminders to roll up. `None` = the caller's own. - /// Manager socket only: `Some("")` = that agent's. + /// `Some("")` = that agent's (requires `query_agent_state` + /// capability on the agent socket; always available on the manager + /// socket). #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option, }, @@ -909,6 +917,14 @@ pub enum Capability { /// MCP tool `get_host_journal` is only registered in the harness /// when this capability is present. ReadHostJournal, + /// Agent can query another agent's state via `GetLooseEnds`, + /// `CountPendingReminders`, and `ReminderRollup` on the agent + /// socket. Without this capability the `agent` field in those + /// requests is ignored and results are scoped to the caller. + /// 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 { @@ -918,6 +934,7 @@ impl Capability { match self { Self::ManageRootAgent => "manage_root_agent", Self::ReadHostJournal => "read_host_journal", + Self::QueryAgentState => "query_agent_state", } } }