feat(#1021): query_agent_state capability for agent socket GetLooseEnds/CountPendingReminders/ReminderRollup
This commit is contained in:
parent
9a1014f195
commit
4834ca413c
3 changed files with 109 additions and 30 deletions
|
|
@ -661,11 +661,14 @@ impl AgentServer {
|
||||||
at turn start to remember what you owe / what's owed to you without scrolling \
|
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 \
|
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 \
|
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 — 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<AgentGetLooseEndsArgs>) -> String {
|
||||||
run_tool_envelope("get_loose_ends", String::new(), async move {
|
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);
|
let mut out = annotate_retries(format_loose_ends(resp), retries);
|
||||||
// Append any local bash tasks still in pending/running state so
|
// Append any local bash tasks still in pending/running state so
|
||||||
// the agent sees all outstanding work in one call.
|
// the agent sees all outstanding work in one call.
|
||||||
|
|
@ -1056,6 +1059,18 @@ pub struct GetLooseEndsArgs {
|
||||||
pub agent: Option<String>,
|
pub agent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct RequestApplyCommitArgs {
|
pub struct RequestApplyCommitArgs {
|
||||||
/// Logical agent name whose config repo the commit lives in.
|
/// 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
|
// manage_root_agent doesn't expose new MCP tools (it gates
|
||||||
// existing lifecycle tools via the topology enforcement).
|
// existing lifecycle tools via the topology enforcement).
|
||||||
"manage_root_agent" => {}
|
"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 => {
|
unknown => {
|
||||||
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
|
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -307,26 +307,40 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
match req {
|
match req {
|
||||||
AgentRequest::GetLooseEnds { .. } => match crate::loose_ends::for_agent(coord, agent) {
|
AgentRequest::GetLooseEnds { agent: target } => {
|
||||||
Ok(loose_ends) => AgentResponse::LooseEnds { loose_ends },
|
let name = resolve_agent_state_target(agent, target.as_deref());
|
||||||
Err(e) => AgentResponse::Err {
|
match name {
|
||||||
message: format!("{e:#}"),
|
Ok(name) => match crate::loose_ends::for_agent(coord, name) {
|
||||||
},
|
Ok(loose_ends) => AgentResponse::LooseEnds { loose_ends },
|
||||||
},
|
Err(e) => AgentResponse::Err {
|
||||||
AgentRequest::CountPendingReminders { .. } => {
|
message: format!("{e:#}"),
|
||||||
match coord.broker.count_pending_reminders_for(agent) {
|
},
|
||||||
Ok(count) => AgentResponse::PendingRemindersCount { count },
|
|
||||||
Err(e) => AgentResponse::Err {
|
|
||||||
message: format!("{e:#}"),
|
|
||||||
},
|
},
|
||||||
|
Err(message) => AgentResponse::Err { message },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AgentRequest::ReminderRollup { since_secs, .. } => {
|
AgentRequest::CountPendingReminders { agent: target } => {
|
||||||
match coord.broker.reminder_rollup_for(agent, *since_secs) {
|
let name = resolve_agent_state_target(agent, target.as_deref());
|
||||||
Ok(stats) => AgentResponse::ReminderRollup(stats),
|
match name {
|
||||||
Err(e) => AgentResponse::Err {
|
Ok(name) => match coord.broker.count_pending_reminders_for(name) {
|
||||||
message: format!("{e:#}"),
|
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.
|
// 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")
|
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
|
/// Resolve the `due_at` unix timestamp for a Remind request. Returns
|
||||||
/// distinct error messages for each failure mode (overflow on
|
/// distinct error messages for each failure mode (overflow on
|
||||||
/// `InSeconds`, pre-epoch clock, `i64` cast wrap) so the caller can tell
|
/// `InSeconds`, pre-epoch clock, `i64` cast wrap) so the caller can tell
|
||||||
|
|
|
||||||
|
|
@ -373,18 +373,21 @@ pub enum Request {
|
||||||
file_path: Option<String>,
|
file_path: Option<String>,
|
||||||
},
|
},
|
||||||
/// Loose-ends view. On the agent socket, scoped to the calling agent
|
/// Loose-ends view. On the agent socket, scoped to the calling agent
|
||||||
/// (the `agent` field is ignored — agents can only see their own
|
/// unless the agent holds the `query_agent_state` capability, in
|
||||||
/// loose ends). On the manager socket, `agent = None` scopes to the
|
/// which case `Some("<name>")` targets a specific agent's threads
|
||||||
/// manager itself, `Some("*")` is hive-wide, `Some("<name>")` is
|
/// (the `"*"` hive-wide value is manager-only). On the manager
|
||||||
/// that agent's loose ends. See
|
/// socket, `agent = None` scopes to the manager itself,
|
||||||
/// `docs/conventions.md::Loose-ends wire shape`.
|
/// `Some("*")` is hive-wide, `Some("<name>")` is that agent's
|
||||||
|
/// loose ends. See `docs/conventions.md::Loose-ends wire shape`.
|
||||||
GetLooseEnds {
|
GetLooseEnds {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
},
|
},
|
||||||
/// Count of pending (un-delivered) reminders. On the agent socket
|
/// Count of pending (un-delivered) reminders. On the agent socket,
|
||||||
/// always scoped to the calling agent. On the manager socket,
|
/// scoped to the calling agent unless the agent holds
|
||||||
/// `agent = None` means self, `Some("<name>")` means that agent.
|
/// `query_agent_state`, in which case `Some("<name>")` targets
|
||||||
|
/// that agent. On the manager socket, `agent = None` means self,
|
||||||
|
/// `Some("<name>")` means that agent.
|
||||||
/// Used by the harness's per-turn stats sink.
|
/// Used by the harness's per-turn stats sink.
|
||||||
CountPendingReminders {
|
CountPendingReminders {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -392,15 +395,20 @@ pub enum Request {
|
||||||
},
|
},
|
||||||
/// Reminder statistics: counts of scheduled, delivered, and pending
|
/// Reminder statistics: counts of scheduled, delivered, and pending
|
||||||
/// reminders over a time window. `since_secs` filters to reminders
|
/// reminders over a time window. `since_secs` filters to reminders
|
||||||
/// created in the last N seconds (0 = all). On the manager socket
|
/// created in the last N seconds (0 = all). On the agent socket,
|
||||||
/// `agent = None` means self, `Some("<name>")` means that agent.
|
/// scoped to the calling agent unless the agent holds
|
||||||
|
/// `query_agent_state`, in which case `Some("<name>")` targets
|
||||||
|
/// that agent. On the manager socket, `agent = None` means self,
|
||||||
|
/// `Some("<name>")` means that agent.
|
||||||
ReminderRollup {
|
ReminderRollup {
|
||||||
/// Only count reminders created in the last N seconds from now.
|
/// Only count reminders created in the last N seconds from now.
|
||||||
/// Pass 0 to include all reminders.
|
/// Pass 0 to include all reminders.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
since_secs: u64,
|
since_secs: u64,
|
||||||
/// Whose reminders to roll up. `None` = the caller's own.
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -909,6 +917,14 @@ 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 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 {
|
impl Capability {
|
||||||
|
|
@ -918,6 +934,7 @@ impl Capability {
|
||||||
match self {
|
match self {
|
||||||
Self::ManageRootAgent => "manage_root_agent",
|
Self::ManageRootAgent => "manage_root_agent",
|
||||||
Self::ReadHostJournal => "read_host_journal",
|
Self::ReadHostJournal => "read_host_journal",
|
||||||
|
Self::QueryAgentState => "query_agent_state",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue