Compare commits

...
3 changed files with 116 additions and 31 deletions

View file

@ -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: \"<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) -> String {
async fn get_loose_ends(&self, Parameters(args): Parameters<AgentGetLooseEndsArgs>) -> 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<String>,
}
#[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.
/// 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; use the manager socket for swarm-wide scans.
#[serde(default)]
pub agent: Option<String>,
}
#[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<String> {
// 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");
}

View file

@ -307,26 +307,40 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
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,43 @@ 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. Rules:
///
/// - `None` → caller's own threads (always allowed).
/// - `Some(caller)` → same as `None`.
/// - `Some("<child>")` where child is a direct descendant of caller per
/// `topology.json` → allowed without any extra capability.
/// - `Some("<other>")` where other is not a child → requires the
/// `query_agent_state` capability; returns an error otherwise.
/// - `Some("*")` → always rejected (hive-wide scans are manager-only).
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);
}
// Direct children are visible to their parent without extra capability.
if crate::topology::children_of(caller).iter().any(|c| c == name) {
return Ok(name);
}
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) {
Ok(name)
} else {
Err(format!(
"agent `{caller}` cannot query `{name}`: not a direct child and \
`query_agent_state` capability is not granted"
))
}
}
}
}
/// 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

View file

@ -372,19 +372,20 @@ pub enum Request {
#[serde(default)]
file_path: Option<String>,
},
/// 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("<name>")` is
/// that agent's loose ends. See
/// `docs/conventions.md::Loose-ends wire shape`.
/// 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/conventions.md::Loose-ends wire shape`.
GetLooseEnds {
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
},
/// 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("<name>")` means that agent.
/// Count of pending (un-delivered) reminders. On the agent socket:
/// same target rules as `GetLooseEnds` (self/children free;
/// non-children require `query_agent_state`; `"*"` rejected).
/// On the manager socket: `None` = self, any name = that agent.
/// Used by the harness's per-turn stats sink.
CountPendingReminders {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -392,15 +393,19 @@ 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("<name>")` means that agent.
/// created in the last N seconds (0 = all). On the agent socket:
/// same target rules as `GetLooseEnds` (self/children free;
/// non-children require `query_agent_state`; `"*"` rejected).
/// On the manager socket: `None` = self, any name = 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("<name>")` = that agent's.
/// `Some("<name>")` = 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<String>,
},
@ -909,6 +914,14 @@ pub enum Capability {
/// MCP tool `get_host_journal` is only registered in the harness
/// when this capability is present.
ReadHostJournal,
/// Agent can query non-child agents via `GetLooseEnds`,
/// `CountPendingReminders`, and `ReminderRollup` on the agent
/// socket. Without this capability, targeting a non-child agent is
/// rejected with an error (direct children 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 {
@ -918,6 +931,7 @@ impl Capability {
match self {
Self::ManageRootAgent => "manage_root_agent",
Self::ReadHostJournal => "read_host_journal",
Self::QueryAgentState => "query_agent_state",
}
}
}