Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
12 changed files with 184 additions and 47 deletions

View file

@ -181,12 +181,14 @@ 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.
`GetLooseEnds` takes no target — it always returns the caller's own Per-flavour scoping is uniform across the two cancellable variants:
rows, on both the agent and manager sockets alike (the manager, `ruth`,
is a normal agent here, just deployed automatically with different - **agent-flavour** `GetLooseEnds` only surfaces rows the calling
default capabilities). `Approval` rows only appear when the calling agent is agent has standing in. `Approval` rows only appear when the
the manager (sub-agents don't submit approvals). `Reminder` rows are calling agent is the manager (sub-agents don't submit
scoped to `owner == self`. approvals). `Reminder` rows are scoped to `owner == self`.
- **manager-flavour** `GetLooseEnds` lists every pending row in
the swarm — full audit view.
Per-variant fields: 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` | | `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,15 +75,20 @@ 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()`, **Inbox** (`inbox` group): `get_loose_ends(agent?)`,
`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()` — list scheduled reminders, pending approvals you - `get_loose_ends(agent?)` — list scheduled reminders, pending
submitted, and active local tasks published by external MCP daemons approvals you submitted, and active local tasks published by
(for example running bash tasks from `hive-bash-daemon`). Each row external MCP daemons (for example running bash tasks from
carries an id + kind for `cancel_loose_end`. Always your own threads — `hive-bash-daemon`). Each row carries an id + kind for
there is no way to target another agent. `cancel_loose_end`. Omit `agent` to list your own threads. Pass
`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,6 +440,7 @@ 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,6 +131,19 @@ 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,9 +24,10 @@ mod args;
mod render; mod render;
pub use args::{ pub use args::{
AckUntilArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, CreateRepoArgs, AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs,
EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, MarkTodosDoneArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs,
RecvArgs, RemindArgs, RequestSchedulePromptArgs, SendArgs, SetStatusArgs, UpdateMetaInputsArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestSchedulePromptArgs, SendArgs, SetStatusArgs,
UpdateMetaInputsArgs,
}; };
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; 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 \ 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." reviewed ones done with `mark_todos_done`, then call again for the rest.\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 { 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) .dispatch(hive_core_agent_sock::Request::GetLooseEnds { agent: args.agent })
.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. // Prepend matrix unread entry for self-queries only (can't
if let Some(unread_rooms) = matrix_unread_summary().await { // 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); 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);
@ -273,12 +279,16 @@ impl AgentServer {
); );
} }
} }
// Merge the harness's local todos (loose-ends v2). // Merge the harness's local todos (loose-ends v2) for self-queries.
if let Some(todos) = local_todos().await { // 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); loose_ends.extend(todos);
} }
// Merge local pending reminders. // Merge local pending reminders — same self-query-only
if let Some(reminders) = local_reminders().await { // 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); 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, &Request::GetLooseEnds { agent: None },
CONTROL_SOCKET_RETRY, CONTROL_SOCKET_RETRY,
) )
.await .await

View file

@ -49,9 +49,12 @@ 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 doesn't expose a new MCP tool: it gates // manage_root_agent / query_agent_state don't expose new MCP
// existing lifecycle tools via topology enforcement instead. // tools: manage_root_agent gates existing lifecycle tools via
"manage_root_agent" => {} // 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 => { unknown => {
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
} }

View file

@ -7,7 +7,8 @@
//! //!
//! ```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 a single agent (`for_agent`) — always //! assembles a `Vec<LooseEnd>` for either a single agent (`for_agent`) or
//! the caller's own. `Request::GetLooseEnds` from either the agent or //! the whole hive (`hive_wide`). `Request::GetLooseEnds` from either the
//! manager socket lands here so the age-seconds derivation stays in one //! agent or manager socket lands here so the routing logic + age-seconds
//! place. Reminders are agent-local (in-container //! derivation stay in one 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,3 +75,21 @@ 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

@ -180,10 +180,9 @@ pub(crate) fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
/// Handle the subset of `Request` variants that are identical on both /// Handle the subset of `Request` variants that are identical on both
/// the agent socket and the manager socket. Returns `Some(response)` for /// the agent socket and the manager socket. Returns `Some(response)` for
/// every variant it handles; returns `None` for the remaining variants — /// every variant it handles; returns `None` for variants with socket-specific
/// `ListDescendants` and the orchestration verbs (schedules / meta-inputs), /// semantics (e.g. `GetLooseEnds`, where the manager can target other
/// which need per-verb tool-group gating — or for host-admin / unknown /// agents) or for manager-only variants.
/// requests invalid on either socket.
/// ///
/// The unified `dispatch` calls this first; the remaining arms (which gate /// The unified `dispatch` calls this first; the remaining arms (which gate
/// on topology / capabilities / tool-groups) are handled there. /// 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 /// 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,7 +550,11 @@ 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,
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 // 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,
@ -648,9 +652,29 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
} }
} }
/// `GetLooseEnds` — always the caller's own loose ends. /// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree
fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response { /// descendant resolve freely (a parent sees its subtree, the root sees all);
match crate::loose_ends::for_agent(coord, agent) { /// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep
/// 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:#}"),
@ -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`]. /// 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,11 +63,16 @@ 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 — always the caller's own. Same shape on the agent /// Loose-ends view. On the agent socket: `None` = self; direct
/// and manager sockets; the manager (`ruth`) is a normal agent here, it /// children are always accessible; non-children require the
/// just sees its own threads like anyone else. See /// `query_agent_state` capability — rejected with an error otherwise;
/// `docs/process/conventions.md::Loose-ends wire shape`. /// `"*"` is always rejected (use the manager socket). On the manager
GetLooseEnds, /// 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<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,12 +367,25 @@ 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] = &[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. /// Short human-readable description suitable for a tooltip or help text.
#[must_use] #[must_use]
@ -382,6 +395,9 @@ 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"
}
} }
} }
} }