diff --git a/CLAUDE.md b/CLAUDE.md index eb4dbb56..3f72557e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ hive-ag3nt/ in-container harness crate; produces ONE `hive` model selection (persisted at /harness/hyperhive-model) src/turn.rs claude --print + stream-json pump; --compact retry; proactive compaction + auto session-reset - src/mcp.rs embedded MCP server (rmcp): `AgentServer { socket }` — single role, tool groups gate access + src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer src/forge_notify.rs Forgejo webhook subscriber: formats new-issue / new-PR / comment / review notifications as broker messages; embeds a body excerpt diff --git a/docs/conventions.md b/docs/conventions.md index 8240ae16..ba6714ed 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -308,8 +308,8 @@ the new `HIVE_TOOL_GROUPS` env var. Agents with no entry get no var. **Runtime resolution** — at session start the harness reads `HIVE_TOOL_GROUPS` (a comma-separated list of snake_case group names injected by the meta renderer from `tool-groups.json`). Unrecognised tokens are logged and skipped. Falls back -to `ToolGroup::AGENT_DEFAULT` (`messaging`, `meta`, `inbox`, `execution`) when -the var is absent or empty. +to `ToolGroup::AGENT_DEFAULT` (`messaging`, `meta`, `inbox`, `execution`) or +`ToolGroup::MANAGER_DEFAULT` (all groups) when the var is absent or empty. **Updating the surface** — when a new `#[tool]` fn is added to `HiveServer` in `hive-ag3nt/src/mcp.rs`, add its name to the matching `ToolGroup::tools()` diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 929aefb8..7521589f 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -81,17 +81,26 @@ Three subcommands: ### `Surface` trait + zero-sized type tags -`AgentRequest` / `AgentResponse` (= `ManagerRequest` / `ManagerResponse` — -type aliases) are the wire types. There is one role: agent. -`bin/hive.rs` factors the turn loop through a `Surface` trait with one -zero-sized impl (`AgentSurface`) wrapping: +`AgentRequest` / `AgentResponse` and `ManagerRequest` / +`ManagerResponse` are wire-disjoint, but the turn loop itself +(boot → recv → drive → ack/requeue → stats → continue-sentinel) +is identical regardless of role. `bin/hive.rs` factors that +sameness through a `Surface` trait with two zero-sized impls +(`AgentSurface`, `ManagerSurface`) wrapping: +- Per-role MCP `Flavor` constant (picks which system-prompt block + + tool registration goes into the spawned claude). +- Per-role `forge_notify::run` flag (picks `AgentRequest::Wake` + vs `ManagerRequest::Wake` so the broker socket accepts the + push). - One async method per wire op: `ack_turn`, `requeue_inflight`, `inbox_unread`, `post_turn_counts`, `send_to_parent`, `self_wake`, `recv_next`, `wake_external`. -`main()` calls `serve_main::` for all roles. The turn -loop (`serve_loop` / `handle_turn` / `wake`) has no per-role branches. +`main()`'s dispatch picks `serve_main::` vs +`serve_main::` and the turn logic stays in +lockstep by construction — there's no separate per-role copy of +`serve_loop` / `handle_turn` / `wake`. ### Boot wiring @@ -263,13 +272,14 @@ socket at `/run/hive/` once at startup: Passed via `--system-prompt-file`. **Marker grammar.** `` opens a block; matching - `` closes it. The renderer always uses role `agent`. - Blocks with other role tags are elided. Nesting is NOT supported — - a stray opener overrides until its closing tag (or end of file). A - mismatched closer is elided from the output but does NOT pop the - active role. Whitespace inside markers is tolerated + `` closes it. Nesting is NOT supported — a stray + opener overrides until its closing tag (or end of file). A + mismatched closer (`` inside a `role:agent` + block) is elided from the output but does NOT pop the active + role: suppression stays conservative so a typo can't dump + wrong-flavor content. Whitespace inside markers is tolerated (`` parses the same as ``). - Content outside any marker is always included. + Content outside any marker is always shared. **`hive_identity` / `swarm_identity` shape.** Each carries a leading space + backticked name (` on hive \`pr1ma\``, @@ -368,6 +378,7 @@ it as a stdio child via `--mcp-config`. The hyperhive socket name is the command runs asynchronously in a harness-managed tokio task. Stdout and stderr stream to `harness/bash-tasks/.{out,err}`. When the task completes (or times out, or the process errors), the harness wakes +<<<<<<< HEAD the agent with a summary body — handle on a future turn. Optional `timeout_secs`: pass a value for a deadline, or omit for no timeout (runs until natural exit). Requires the `execution` tool group. diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 273d0f0a..a23a5410 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -1,62 +1,157 @@ -You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine. + +You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + the manager can disambiguate; within your own hive the short form is fine. + + +You are the hyperhive manager `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`); within your own hive the short form is fine. + Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. `max` (default 1, cap 32) drains several queued messages in one call — the wake prompt tells you the pending count. -- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Use `to: ""` to address your structural parent without hardcoding their name — hive-c0re rewrites it at delivery time per `topology.json`, falling back to `operator` if you're a root agent. Use `to: ""` to fan-out to every direct child of yours per `topology.json` (no-op for leaf agents). Both sentinels let the operator reparent at runtime with zero change on your side. Optional `in_reply_to: ` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through a peer agent or contact the operator directly. +- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Use `to: ""` to address your structural parent without hardcoding their name — hive-c0re rewrites it at delivery time per `topology.json`, falling back to `operator` if you're a root agent. Use `to: ""` to fan-out to every direct child of yours per `topology.json` (no-op for leaf agents). Both sentinels let the operator reparent at runtime with zero change on your side. Optional `in_reply_to: ` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "root", …)`) which is always reachable. + - (some agents only) **extra MCP tools** surfaced as `mcp____` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. + + +- `mcp__hyperhive__request_init_config(name, description?)` — **step 1 of spawning a new agent.** Queues an `InitConfig` approval (≤9 char name). On operator approve, hive-c0re seeds the proposed config repo at `/agents//config/` with a default `agent.nix` template and delivers a `config_ready` system event to your inbox. You then review, edit, and commit `agent.nix` before calling `request_apply_commit`. +- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — **step 2 of spawning a new agent, and the only step for config changes.** Submit a commit sha from the agent's proposed config repo for operator approval. For a new agent this creates the container; for an existing agent it rebuilds with the new config. At submit time hive-c0re pins the commit as `proposal/` — your proposed branch can continue moving freely without affecting what the operator will build. +- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent. No approval required. +- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required. +- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required. +- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event. +- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves. +- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Even self-targeted schedules go through approval — the existing `remind` tool stays the quick no-approval self-wake path. Catch-up clamp: long downtime fires ONCE per recurring row on resume (skipped count surfaces in per-target `last_result`), not N stacked pulses. +- `mcp__hyperhive__cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (the schedule keeps firing for any remaining active targets, auto-cancels when every target is gone). Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree per topology.json. +- `mcp__hyperhive__fire_schedule_now(id)` — fire a scheduled prompt out of band. Runs the per-target fan-out once immediately. Recurring schedules keep their cadence intact (the manual fire is additive); one-shot schedules are CONSUMED by the manual fire (cancelled afterwards). Same authorization as `cancel_schedule`. +- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — partial-update a schedule's mutable fields. Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction; re-adding a previously-cancelled target drops the tombstone + history (operator intent: "fresh start"). Refuses cancelled rows. Same authorization as `cancel_schedule`. Note: clearing scalar fields (e.g. flipping recurring→one-shot) is operator-only via the dashboard PATCH — the agent surface only supports positive sets on `description` / `interval_seconds`. +- `mcp__hyperhive__list_schedules()` — snapshot every schedule in the queue (active + cancelled-but-not-reaped). Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. Use to look up an id before cancelling, or to audit upcoming wake-ups across the swarm. +- `mcp__hyperhive__get_logs(agent, lines?)` — fetch recent journal lines for a sub-agent container. Use to diagnose MCP-server registration failures, startup crashes, or harness issues you can't see from inside. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500). + - `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: ""`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot. -- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the operator calls `ask(to: "", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target. +- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the manager calls `ask(to: "", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target. + - `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology. -- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by ]` answer so the waiter unblocks), `"reminder"` (hard-deleted before it fires), or `"approval"` (withdraws a pending approval you submitted that got superseded — operator-approved path, so requires the `approvals` tool group). `id` from the matching `get_loose_ends` row or the original submission reply. +- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by ]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. (The third kind `"approval"` exists but is manager-only — sub-agents don't submit approvals so the surface refuses.) + + +- `mcp__hyperhive__get_loose_ends(agent?)` — loose ends. Omit `agent` for your own: pending approvals you submitted + unanswered questions where you are asker/target + your own pending reminders. Pass `agent: "*"` for a hive-wide sweep — every pending approval, unanswered question, and reminder across the swarm — to find stalled threads (sub-agent A asked B something three days ago and B never answered) before they rot. Pass `agent: ""` to inspect one agent's threads. Cheap server-side query. +- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel any question, reminder, or approval in the swarm. `kind` is `"question"` (bypasses the owner check used on sub-agents → hive-wide cleanup when an agent is offline / can't withdraw its own thread), `"reminder"` (same bypass), or `"approval"` (manager-only path → withdraws a pending approval YOU submitted that got superseded before the operator acted on it; the row resolves as `cancelled` and disappears from the operator's pending pane). + - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. - `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Single line, ≤200 chars — the dashboard renders this as a short chip, so longer multi-line text is rejected. Pass an empty string to clear. Persists across harness restarts. -- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Also returns the hive + swarm display names (`hive_name`, `swarm_name`) when the operator has configured `services.hyperhive.{hiveName, swarmName}`; both lines omitted when unset. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. +- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Also returns the hive + swarm display names (`hive_name`, `swarm_name`) when the operator has configured `services.hyperhive.{hiveName, swarmName}`; both lines omitted when unset. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. + - `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args. - `mcp__hyperhive__restart(name)` — *(requires `lifecycle` tool group)* restart a direct child sub-agent (stop + start). The server enforces topology: the call is rejected unless `name` is a direct child of yours per `topology.json`. No approval required. - `mcp__hyperhive__kill(name)` — *(requires `lifecycle` tool group)* stop a direct child sub-agent (graceful). Direct children only — server enforces topology. State dir kept; recreating reuses prior config + credentials. No approval required. -- `mcp__hyperhive__start(name)` — *(requires `lifecycle` tool group)* start a stopped direct child sub-agent. Direct children only — server enforces topology. No approval required. - `mcp__hyperhive__update(name)` — *(requires `lifecycle` tool group)* rebuild a direct child sub-agent: re-applies the current hyperhive flake + agent.nix and restarts it. Direct children only — server enforces topology. No approval required. Idempotent. - `mcp__hyperhive__list_containers()` — *(requires `lifecycle` tool group)* list all containers that are topological descendants of this agent (children + their subtrees). Returns each name with running/stopped status, ordered parents-first. Useful before kill/update/restart to check what's under you. - `mcp__hyperhive__request_init_config(name, description?)` — *(requires `approvals` tool group)* initialise a brand-new direct child agent's proposed config repo. Queues an `InitConfig` approval; on approval hive-c0re seeds `/agents//config/agent.nix`. `name` must be a direct child in the topology tree — server enforces. Fails if the config repo already exists (use `request_apply_commit` instead). - `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — *(requires `approvals` tool group)* submit a config commit for a direct child agent, queued for operator approval. `agent` must be a direct child in the topology tree — server enforces. `commit_ref` must be a 7-40 char hex sha (not a branch/tag name). On approval hive-c0re rebuilds the container with the pinned commit. -- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — *(requires `approvals` tool group)* queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves. -- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — *(requires `scheduling` tool group)* queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Catch-up clamp: long downtime fires ONCE per recurring row on resume. -- `mcp__hyperhive__cancel_schedule(id, targets?)` — *(requires `scheduling` tool group)* cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients. Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree. -- `mcp__hyperhive__fire_schedule_now(id)` — *(requires `scheduling` tool group)* fire a scheduled prompt out of band immediately. Recurring schedules keep their cadence intact; one-shot schedules are consumed. Same authorization as `cancel_schedule`. -- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — *(requires `scheduling` tool group)* partial-update a schedule's mutable fields. Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction. Refuses cancelled rows. Same authorization as `cancel_schedule`. -- `mcp__hyperhive__list_schedules()` — *(requires `scheduling` tool group)* snapshot every schedule in the queue. Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. -- `mcp__hyperhive__get_logs(agent, lines?)` — *(requires `diagnostics` tool group)* fetch recent journal lines for a sub-agent container. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500). -Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — ask a peer agent with the `approvals` tool group, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits. +Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `root`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. -Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line. +Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files the manager has split the config into. Read it to see exactly what defines you (declared packages, env vars, MCP servers) before asking the manager for a change, so you can point at the precise file and line. You cannot write here; all changes flow through the manager. + + -Approval boundary: lifecycle ops on *existing* direct children (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). *Creating* a new agent (two-step: `request_init_config` + `request_apply_commit`) and *changing* any agent's config (`request_apply_commit`) both go through the approval queue (requires `approvals` tool group). The operator only signs off on changes; you run the day-to-day. +Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (two-step: `request_init_config` + `request_apply_commit`) and *changing* any agent's config (`request_apply_commit`) both go through the approval queue. The operator only signs off on changes; you run the day-to-day. + +Your own editable config lives at `/agents/{label}/config/`; every sub-agent's lives at `/agents//config/`. `agent.nix` is a plain NixOS module function — `{ config, pkgs, lib, flakeInputs, ... }: { ... }`. Add packages, services, imports, sibling `.nix` files; the whole committed tree gets deployed together. + +`flake.nix` is mostly boilerplate (it exports `agent.nix` as `nixosModules.default` and forwards every flake input to the module as `flakeInputs`). **Don't touch the outputs block** — but you *can* edit the `inputs` block to pull in other flakes, which is the supported way to depend on out-of-tree packages (MCP servers, scrapers, anything not in nixpkgs): + +```nix +# flake.nix (manager-edited, inputs side only) +inputs.mcp-matrix.url = "github:foo/mcp-matrix"; +inputs.mcp-matrix.inputs.nixpkgs.follows = "nixpkgs"; # optional, reduce closure +``` + +```nix +# agent.nix — reference the input via flakeInputs +{ pkgs, flakeInputs, ... }: +let matrixPkg = flakeInputs.mcp-matrix.packages.${pkgs.system}.default; +in { + environment.systemPackages = [ matrixPkg ]; + hyperhive.extraMcpServers.matrix = { + command = "${matrixPkg}/bin/mcp-matrix"; + args = [ "--config" "/agents//state/matrix.toml" ]; # replace with the agent's label + allowedTools = [ "send_message" "join_room" ]; + }; +} +``` + +The new input's pinned sha lands in the agent's `flake.lock` (also tracked + part of the proposal). Build failures from a broken `flake.nix` surface as a `failed/` annotated tag, so the worst case is a rejected deploy — not a silently-broken agent. + +Each proposed repo has an `applied` git remote pre-configured pointing at the read-only mirror of what's deployed. Useful patterns: + +- `git -C /agents//config fetch applied` — refresh the local copy of every deployed/failed/denied tag. +- `git -C /agents//config log applied/main --oneline` — every successful deploy of this agent. +- `git -C /agents//config show applied/refs/tags/deployed/` — the tree that was deployed for approval ``. +- `git -C /agents//config show applied/refs/tags/failed/` — annotated tag body is the build error from a rejected rebuild. +- `git -C /agents//config show applied/refs/tags/denied/` — annotated tag body is the operator's reason for denial. +- `git -C /agents//config rebase applied/main` — base your in-flight work on whatever's actually deployed (useful after a failed/denied pile-up). + +System-wide view: `/meta/` is a read-only mirror of the deployed-agents flake. `git -C /meta log --oneline` is the deploy log for every agent across the swarm; `cat /meta/flake.lock` shows which sha each agent is pinned at right now. + +Tag scheme on every approval id: `proposal → approved → building → deployed | failed`, plus `denied` as a terminal alternative to `approved`. `applied/main` only advances on `deployed/*`, so a failed build does not corrupt the agent — submit a fix as a new commit and a fresh `request_apply_commit`. + +Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging: + +- Does it match what the agent actually needs to do its declared role? +- Is the package legitimate (no obviously-malicious names, no overly broad permissions)? +- Are there cheaper / safer alternatives that don't need a config edit? +- If the change has any ambiguity or could affect other agents / the host, surface the question to the operator (see below) instead of staging it yourself. + +You're the policy gate between sub-agents and the operator's approval queue — the operator clicks ◆ APPR0VE on your commits, so don't submit changes you wouldn't defend. + +Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask(question, options?)` when you need a decision (omit `to`, or pass `to: "operator"`). `ask` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as a `question_answered` system event. Prefer `ask` over an open-ended `send` for anything you actually need to wait on. Same primitive can target a sub-agent (`to: ""`) when you need a structured answer from a peer rather than free-form chat. Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `config_ready`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes: -- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents//config/agent.nix`, commit your changes, then call `request_apply_commit` with the commit sha. -- `needs_login` — agent has no claude session yet. Flag the operator if it's been long. -- `logged_in` — agent just completed login; first useful turn is imminent. +- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents//config/agent.nix`, commit your changes, then call `request_apply_commit` with the commit sha — this will create the container on approval (first spawn) and rebuild on every subsequent deploy. +- `needs_login` — agent has no claude session yet. You can't help directly (login is interactive OAuth on the operator side); flag the operator if it's been long. +- `logged_in` — agent just completed login; first useful turn is imminent. Good time to brief them on what to do. - `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval. - `container_crash` — restart with `start(name)`. If it crashes again, ask the operator. -- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up answers to questions you asked. +- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up the operator's answer to questions you asked. + Durable knowledge: + - write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets. + + + +- Your own: `/state/notes.md` (free-form) or anything else under `/state/`. Bind-mounted from the host — survives destroy/recreate. Claude's `--continue` session only carries short-term context; `/state/` is forever. Good place for a roster of active sub-agents, ongoing initiatives, decisions you've made. +- Sub-agents': every sub-agent has its own `/state/` too. From your container that's `/agents//state/` (your `/agents` mount is RW), so you can read what they've recorded and write notes for them when you need to leave a heads-up or task list. + Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts. + **Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate. **Hive knowledge**: `/knowledge` is a read-only bind-mount of the `internal/knowledge` repo on the forge. It contains hive-wide reference documents (conventions, runbooks, shared notes). Read files there for context; to contribute, fork `internal/knowledge` on the forge and open a PR. + **Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000//.git` (credentials are pre-configured). The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `list`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. + -Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — coordinate through shared space or a common parent. +Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent. + + + +Keep messages short — a few sentences each. For anything big (digests, agent rosters, plans, transcripts) write the payload to a file and `send` a short pointer: + +- To a sub-agent X: write to `/agents/X/state/` and tell them "see /agents/X/state/". +- To the operator: write to your own `/state/` (host path `/var/lib/hyperhive/agents/{label}/state/`) and tell them where to look. +- For shared artifacts (coordination, common reference data): write to `/shared/`. Only put things here you're willing to lose — other agents may delete them. + +A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time. + When your inbox has a message, handle it and stop. Don't narrate intent — act. diff --git a/hive-ag3nt/src/bin/hive.rs b/hive-ag3nt/src/bin/hive.rs index b90494ca..bcd7c71e 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -1,7 +1,10 @@ -//! Unified hyperhive harness binary. Dispatches one of three subcommands -//! (`serve` / `mcp` / `wake`). There is one role: agent. The `Surface` -//! trait + `AgentSurface` zero-sized type tag keeps the turn loop -//! generic and testable. Architecture lives in +//! Unified hyperhive harness binary. Picks role from `HIVE_ROLE` +//! (`"agent"` | `"manager"`), dispatches one of three subcommands +//! (`serve` / `mcp` / `wake`), and runs the turn loop through a +//! generic `Surface` trait so both wire surfaces stay in lockstep. +//! +//! Architecture (single-binary rationale, Surface-trait + zero-sized +//! type tags, boot wiring, turn-outcome branch) lives in //! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md). use std::path::{Path, PathBuf}; @@ -10,7 +13,7 @@ use std::time::Duration; use hive_ag3nt::web_ui::TurnLock; -use anyhow::Result; +use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::login::{self, LoginState}; @@ -18,10 +21,15 @@ use hive_ag3nt::turn_stats::TurnStats; use hive_ag3nt::{ DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui, }; -use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER}; +use hive_sh4re::{ + AgentRequest, AgentResponse, HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER, +}; #[derive(Parser)] -#[command(name = "hive", about = "hyperhive harness")] +#[command( + name = "hive", + about = "hyperhive harness — role from $HIVE_ROLE (agent|manager)" +)] struct Cli { /// Path to the per-agent MCP socket (bind-mounted from the host). #[arg(long, global = true, default_value = DEFAULT_SOCKET)] @@ -40,14 +48,16 @@ enum Cmd { #[arg(long, default_value_t = 1000)] poll_ms: u64, }, - /// Run the MCP server on stdio. Spawned by `claude` via + /// Run this role's MCP server on stdio. Spawned by `claude` via /// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back /// into the hyperhive broker. Mcp, /// Inject a wake-up event into this harness's inbox so the next /// turn fires with the given body. Intended for extra MCP servers /// / helpers (matrix bridge, scraper, webhook listener, etc.) that - /// need to nudge claude on external events. + /// need to nudge claude on external events. Available on both + /// agent and manager roles; mirrors the `AgentRequest::Wake` / + /// `ManagerRequest::Wake` pair already on the wire. Wake { #[arg(long)] from: String, @@ -57,6 +67,20 @@ enum Cmd { }, } +#[derive(Copy, Clone)] +enum Role { + Agent, + Manager, +} + +fn resolve_role() -> Result { + match std::env::var("HIVE_ROLE").as_deref() { + Ok("agent") | Err(_) => Ok(Role::Agent), + Ok("manager") => Ok(Role::Manager), + Ok(other) => bail!("unknown HIVE_ROLE={other:?}; expected 'agent' or 'manager'"), + } +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -67,11 +91,26 @@ async fn main() -> Result<()> { .init(); let cli = Cli::parse(); + let role = resolve_role()?; - match cli.cmd { - Cmd::Serve { poll_ms } => serve_main::(&cli.socket, poll_ms).await, - Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await, - Cmd::Wake { from, body } => wake::(&cli.socket, from, body).await, + // Generic dispatch: one `serve_main` / `wake` body, two + // monomorphisations driven by the `Surface` type parameter. See + // `docs/turn-loop.md::Surface trait + zero-sized type tags`. + match (role, cli.cmd) { + (Role::Agent, Cmd::Serve { poll_ms }) => { + serve_main::(&cli.socket, poll_ms).await + } + (Role::Manager, Cmd::Serve { poll_ms }) => { + serve_main::(&cli.socket, poll_ms).await + } + (Role::Agent, Cmd::Mcp) => mcp::serve_agent_stdio(cli.socket).await, + (Role::Manager, Cmd::Mcp) => mcp::serve_agent_stdio(cli.socket).await, + (Role::Agent, Cmd::Wake { from, body }) => { + wake::(&cli.socket, from, body).await + } + (Role::Manager, Cmd::Wake { from, body }) => { + wake::(&cli.socket, from, body).await + } } } @@ -147,11 +186,16 @@ enum RecvOutcome { TransportError, } -/// Wire surface abstraction. `AgentSurface` is the only impl — the trait -/// exists to keep the turn loop generic and testable. Every function that -/// talks to the broker goes through this so there are zero hard-coded -/// `AgentRequest` / `AgentResponse` references in the turn loop itself. +/// Per-role wire surface. Two impls — `AgentSurface`, `ManagerSurface` +/// — wrap the disjoint `Request`/`Response` enums plus a handful of +/// boot-time constants that vary by role. Every other function in this +/// binary that talks to the broker goes through this trait so the turn +/// loop itself has zero per-role branches. trait Surface { + /// MCP flavor passed to `TurnFiles::prepare`. Picks which static + /// system-prompt block + tool registration goes into the spawned + /// `claude` process. + const FLAVOR: mcp::Flavor; /// Ack the in-flight turn. Logs warnings on transport/broker /// errors but never propagates — turn loop continues either way. fn ack_turn(socket: &Path) -> impl Future; @@ -193,11 +237,12 @@ trait Surface { // ---------- AgentSurface ---------- -/// Zero-sized type tag for the agent wire surface. +/// Zero-sized type tag for the sub-agent wire surface. /// Talks `AgentRequest` / `AgentResponse`. struct AgentSurface; impl Surface for AgentSurface { + const FLAVOR: mcp::Flavor = mcp::Flavor::Agent; async fn ack_turn(socket: &Path) { match client::request::<_, AgentResponse>(socket, &AgentRequest::AckTurn).await { @@ -337,11 +382,159 @@ impl Surface for AgentSurface { } } +// ---------- ManagerSurface ---------- + +/// Zero-sized type tag for the manager wire surface. +/// Talks `ManagerRequest` / `ManagerResponse`. +struct ManagerSurface; + +impl Surface for ManagerSurface { + const FLAVOR: mcp::Flavor = mcp::Flavor::Manager; + + async fn ack_turn(socket: &Path) { + match client::request::<_, ManagerResponse>(socket, &ManagerRequest::AckTurn).await { + Ok(ManagerResponse::Ok) => {} + Ok(ManagerResponse::Err { message }) => { + tracing::warn!(%message, "ack_turn rejected by broker"); + } + Ok(other) => tracing::warn!(?other, "ack_turn unexpected response"), + Err(e) => tracing::warn!(error = ?e, "ack_turn transport error"), + } + } + + async fn requeue_inflight(socket: &Path) { + match client::request::<_, ManagerResponse>(socket, &ManagerRequest::RequeueInflight).await + { + Ok(ManagerResponse::Ok) => {} + Ok(ManagerResponse::Err { message }) => { + tracing::warn!(%message, "requeue_inflight rejected by broker"); + } + Ok(other) => tracing::warn!(?other, "requeue_inflight unexpected response"), + Err(e) => tracing::warn!(error = ?e, "requeue_inflight transport error"), + } + } + + async fn inbox_unread(socket: &Path) -> u64 { + match client::request::<_, ManagerResponse>(socket, &ManagerRequest::Status).await { + Ok(ManagerResponse::Status { unread }) => unread, + _ => 0, + } + } + + async fn post_turn_counts(socket: &Path) -> (Option, Option) { + let threads = match client::request::<_, ManagerResponse>( + socket, + &ManagerRequest::GetLooseEnds { agent: None }, + ) + .await + { + Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(), + _ => None, + }; + let reminders = match client::request::<_, ManagerResponse>( + socket, + &ManagerRequest::CountPendingReminders { agent: None }, + ) + .await + { + Ok(ManagerResponse::PendingRemindersCount { count }) => Some(count), + _ => None, + }; + (threads, reminders) + } + + async fn send_to_parent(socket: &Path, body: String) { + let res = client::request::<_, ManagerResponse>( + socket, + &ManagerRequest::Send { + to: hive_sh4re::PARENT_RECIPIENT.into(), + body, + in_reply_to: None, + }, + ) + .await; + if let Err(e) = res { + tracing::warn!(error = ?e, "failed to notify parent of turn failure"); + } + } + + async fn self_wake(socket: &Path) { + let res = client::request::<_, ManagerResponse>( + socket, + &ManagerRequest::Wake { + from: "self".into(), + body: "continue".into(), + transient: false, + }, + ) + .await; + match res { + Ok(ManagerResponse::Ok) => { + tracing::info!("request_next_turn: injected self-continue wake"); + } + Ok(ManagerResponse::Err { message }) => { + tracing::warn!(%message, "check_and_inject_continue: wake rejected"); + } + Err(e) => { + tracing::warn!(error = ?e, "check_and_inject_continue: wake transport error"); + } + _ => {} + } + } + + async fn recv_next(socket: &Path) -> RecvOutcome { + let recv: Result = client::request( + socket, + &ManagerRequest::Recv { + wait_seconds: Some(180), + max: None, + }, + ) + .await; + match recv { + Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => { + let first = messages.into_iter().next().expect("checked non-empty"); + RecvOutcome::Message(first) + } + Ok(ManagerResponse::Messages { .. }) => RecvOutcome::Empty, + Ok(ManagerResponse::Err { message }) => { + tracing::warn!(%message, "recv error"); + RecvOutcome::TransportError + } + Ok(other) => { + tracing::warn!(?other, "recv produced unexpected response kind"); + RecvOutcome::TransportError + } + Err(e) => { + tracing::warn!(error = ?e, "recv failed; retrying"); + RecvOutcome::TransportError + } + } + } + + async fn wake_external(socket: &Path, from: String, body: String) -> Result<()> { + let resp: ManagerResponse = client::request( + socket, + &ManagerRequest::Wake { + from, + body, + transient: false, + }, + ) + .await?; + match resp { + ManagerResponse::Ok => Ok(()), + ManagerResponse::Err { message } => anyhow::bail!("wake: {message}"), + other => anyhow::bail!("wake: unexpected response {other:?}"), + } + } +} + // ---------- generic turn loop ---------- -/// Boot — wires up the web UI, login state, stats, plugins, forge -/// notifier, and either drops into `serve_loop` directly (`Online`) or -/// parks on the login flow first (`NeedsLogin`). See +/// Per-role boot — wires up the web UI, login state, stats, plugins, +/// forge notifier, and either drops into `serve_loop` directly +/// (`Online`) or parks on the login flow first (`NeedsLogin`). See /// `docs/turn-loop.md::Boot wiring`. async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { let port = std::env::var("HIVE_PORT") @@ -366,12 +559,13 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { bus.seed_usage(ctx, cost); } } - let files = turn::TurnFiles::prepare(socket, &label).await?; + let files = turn::TurnFiles::prepare(socket, &label, S::FLAVOR).await?; let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(())); - // Plugin install failures come back as a Vec — route each - // through `` via the `send_to_parent` failure-notify path. - // The broker resolves `` per `topology::parent_of`; - // root agents fall through to operator. + // Plugin install runs role-agnostic: failures come back as a + // Vec and we route each through `` via the same + // `send_to_parent` failure-notify path the turn loop uses. The + // broker resolves `` per `topology::parent_of`; root + // agents and the manager fall through to operator. for failure in plugins::install_configured(socket).await { S::send_to_parent(socket, failure).await; } diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index ed66e7ed..dee9bee4 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -43,7 +43,8 @@ pub enum SocketReply { Recent(Vec), Logs(String), HostJournal(String), - /// `list_schedules` result — returned by `list_schedules` (scheduling tool group). + /// `list_schedules` result — used by the manager surface only; + /// `AgentResponse` has no equivalent variant. Schedules(Vec), /// `list_containers` result — descendant containers with running status. Containers(Vec), @@ -737,8 +738,7 @@ impl AgentServer { description = "List loose ends pending against this agent: unanswered questions \ where you are the asker (waiting on someone) or the target (someone's waiting on \ you), pending reminders you scheduled, plus — for the manager only — pending \ - approvals you submitted that the operator hasn't acted on yet (agents with the \ - `approvals` tool group also see their own pending approvals). Also lists active \ + approvals you submitted that the operator hasn't acted on yet. Also lists active \ local tasks published by external MCP daemons (e.g. running bash tasks). Cheap sweep, no args. Useful \ 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 \ @@ -1448,7 +1448,7 @@ pub async fn serve_agent_stdio(socket: PathBuf) -> Result<()> { } // ----------------------------------------------------------------------------- -// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics) +// Manager tool surface // ----------------------------------------------------------------------------- #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -1559,11 +1559,12 @@ pub struct CancelLooseEndArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetLooseEndsArgs { - /// Whose loose ends to list. Omit (or `null`) for your own: approvals - /// you submitted + questions where you are asker/target + your own - /// pending reminders. Pass `"*"` for a hive-wide view of EVERY pending - /// approval, unanswered question, and reminder across the swarm. Pass a - /// specific agent name to inspect just that agent's threads. + /// Whose loose ends to list. Omit (or `null`) for your own — the + /// manager's: approvals you submitted + questions where you are + /// asker/target + your own pending reminders. Pass `"*"` for a + /// hive-wide view of EVERY pending approval, unanswered question, + /// and reminder across the swarm. Pass a specific agent name to + /// inspect just that agent's threads. #[serde(default)] pub agent: Option, } @@ -1575,7 +1576,7 @@ pub struct AgentGetLooseEndsArgs { /// 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. + /// agent socket; use the manager socket for swarm-wide scans. #[serde(default)] pub agent: Option, } @@ -1743,6 +1744,15 @@ pub const SERVER_NAME: &str = "hyperhive"; /// should plan in /state notes instead. pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Write"]; +/// Which MCP tool surface to advertise via `--allowedTools`. The agent +/// list is the strict subset of the manager list, so we just thread the +/// flavor through. +#[derive(Debug, Clone, Copy)] +pub enum Flavor { + Agent, + Manager, +} + /// Env var written by the meta renderer with a comma-separated list of /// `hive_sh4re::ToolGroup` snake_case names (e.g. `"messaging,inbox,meta"`). /// When present, the harness expands the groups into per-tool allow entries @@ -1790,11 +1800,18 @@ fn allowed_capability_tools() -> Vec { /// token is matched (case-insensitive) against the `ToolGroup` serde names /// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`, /// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped. -/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty. -fn effective_tool_groups() -> Vec { +/// Falls back to the flavor default when the env var is absent or empty. +fn effective_tool_groups(flavor: Flavor) -> Vec { let raw = match std::env::var(TOOL_GROUPS_ENV) { Ok(v) if !v.trim().is_empty() => v, - _ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), + _ => { + // No env var — use the flavor default unchanged. + let defaults = match flavor { + Flavor::Agent => hive_sh4re::ToolGroup::AGENT_DEFAULT, + Flavor::Manager => hive_sh4re::ToolGroup::MANAGER_DEFAULT, + }; + return defaults.to_vec(); + } }; let mut groups = Vec::new(); for token in raw.split(',') { @@ -1812,9 +1829,12 @@ fn effective_tool_groups() -> Vec { if groups.is_empty() { tracing::warn!( "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ - falling back to AGENT_DEFAULT" + falling back to flavor default" ); - return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(); + return match flavor { + Flavor::Agent => hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), + Flavor::Manager => hive_sh4re::ToolGroup::MANAGER_DEFAULT.to_vec(), + }; } groups } @@ -1852,8 +1872,8 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec { /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers /// both the built-ins and the MCP surface. #[must_use] -pub fn allowed_tools_arg() -> String { - let groups = effective_tool_groups(); +pub fn allowed_tools_arg(flavor: Flavor) -> String { + let groups = effective_tool_groups(flavor); // Base built-ins always present. let mut all: Vec = ALLOWED_BUILTIN_TOOLS .iter() @@ -1883,7 +1903,15 @@ pub fn allowed_tools_arg() -> String { /// `WebFetch`/`WebSearch` when the `web_tools` group is active). #[must_use] pub fn builtin_tools_arg() -> String { - let groups = effective_tool_groups(); + builtin_tools_arg_for_flavor(Flavor::Agent) +} + +/// Flavor-aware variant used by `turn.rs` via `builtin_tools_arg`. Reads +/// the effective tool groups for `flavor` so `--tools` matches what +/// `--allowedTools` includes for the same session. +#[must_use] +pub fn builtin_tools_arg_for_flavor(flavor: Flavor) -> String { + let groups = effective_tool_groups(flavor); let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec(); for group in &groups { for t in group.builtin_tools() { diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index 8173fc7e..e4e88d30 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -1,31 +1,38 @@ //! System-prompt renderer. Single `prompts/system.md` with //! HTML-comment markers gating role-specific blocks; this module -//! assembles the final prompt (always "agent" role — there is only one -//! role). Marker grammar + placeholder substitution rules in +//! assembles the final prompt for a given flavor. Marker grammar + +//! placeholder substitution rules in //! `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -/// Assemble the system prompt for a given label + pronouns + optional hive / -/// swarm display names. Pure function — no I/O. Splits out from -/// [`write_system_prompt`] so the marker logic + substitution is unit-testable -/// in isolation. The caller supplies the template body so tests can pass an -/// inline fixture and production reads it once at harness startup via -/// [`hive_sh4re::assets::prompt_template`] +use crate::mcp::Flavor; + +/// Assemble the system prompt for a given flavor + label + pronouns + +/// optional hive / swarm display names. Pure function — no I/O. Splits +/// out from [`write_system_prompt`] so the marker logic + substitution +/// is unit-testable in isolation. The caller supplies the template body +/// so tests can pass an inline fixture and production reads it once at +/// harness startup via [`hive_sh4re::assets::prompt_template`] /// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders + /// marker grammar documented in /// `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). #[must_use] pub fn render( template: &str, + flavor: Flavor, label: &str, operator_pronouns: &str, hive_name: Option<&str>, swarm_name: Option<&str>, ) -> String { - let body = filter_role_blocks(template, "agent"); + let target = match flavor { + Flavor::Agent => "agent", + Flavor::Manager => "manager", + }; + let body = filter_role_blocks(template, target); let qualified = crate::identity::qualify(label); let hive_identity = hive_name .filter(|n| !n.is_empty()) @@ -106,7 +113,7 @@ fn parse_close_marker(line: &str) -> Option<&str> { /// # Errors /// /// Returns an error if the system prompt file cannot be written. -pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result { +pub async fn write_system_prompt(_socket: &Path, label: &str, flavor: Flavor) -> Result { let parent = crate::paths::config_dir(); tokio::fs::create_dir_all(&parent).await.ok(); let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); @@ -126,6 +133,7 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result let swarm_name = crate::identity::swarm_name(); let body = render( &template, + flavor, label, &pronouns, hive_name.as_deref(), @@ -271,6 +279,7 @@ shared closer // harness already relied on. let rendered = render( &PRODUCTION_TEMPLATE, + Flavor::Agent, "alice", "they/them", None, @@ -283,32 +292,66 @@ shared closer } #[test] - fn render_no_role_markers_in_output() { - // No raw role markers should survive into the rendered prompt. + fn render_agent_excludes_manager_only_tools() { + // Spot-check: the manager-only tool block (request_init_config, + // kill, schedule_*) MUST NOT appear in the agent's rendered + // prompt. Drift between flavor and tool surface bites every + // time it happens. let rendered = render( &PRODUCTION_TEMPLATE, + Flavor::Agent, "alice", "she/her", None, None, ); - assert!(!rendered.contains("