remove Role::Manager + ManagerSurface + Flavor::Manager — there is only one role: agent
This commit is contained in:
parent
186ee430b5
commit
f56b272a23
8 changed files with 100 additions and 489 deletions
|
|
@ -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 + ManagerServer
|
||||
src/mcp.rs embedded MCP server (rmcp): unified HiveServer {socket, flavor} (AgentServer + ManagerServer are type aliases)
|
||||
src/forge_notify.rs Forgejo webhook subscriber: formats new-issue /
|
||||
new-PR / comment / review notifications as
|
||||
broker messages; embeds a body excerpt
|
||||
|
|
|
|||
|
|
@ -81,26 +81,17 @@ Three subcommands:
|
|||
|
||||
### `Surface` trait + zero-sized type tags
|
||||
|
||||
`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:
|
||||
`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:
|
||||
|
||||
- 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()`'s dispatch picks `serve_main::<AgentSurface>` vs
|
||||
`serve_main::<ManagerSurface>` and the turn logic stays in
|
||||
lockstep by construction — there's no separate per-role copy of
|
||||
`serve_loop` / `handle_turn` / `wake`.
|
||||
`main()` calls `serve_main::<AgentSurface>` for all roles. The turn
|
||||
loop (`serve_loop` / `handle_turn` / `wake`) has no per-role branches.
|
||||
|
||||
### Boot wiring
|
||||
|
||||
|
|
@ -272,14 +263,13 @@ socket at `/run/hive/` once at startup:
|
|||
Passed via `--system-prompt-file`.
|
||||
|
||||
**Marker grammar.** `<!-- role:X -->` opens a block; matching
|
||||
`<!-- /role:X -->` closes it. Nesting is NOT supported — a stray
|
||||
opener overrides until its closing tag (or end of file). A
|
||||
mismatched closer (`<!-- /role:manager -->` 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
|
||||
`<!-- /role:X -->` 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
|
||||
(`<!--role:foo-->` parses the same as `<!-- role:foo -->`).
|
||||
Content outside any marker is always shared.
|
||||
Content outside any marker is always included.
|
||||
|
||||
**`hive_identity` / `swarm_identity` shape.** Each carries a
|
||||
leading space + backticked name (` on hive \`pr1ma\``,
|
||||
|
|
|
|||
|
|
@ -1,157 +1,62 @@
|
|||
<!-- role:agent -->
|
||||
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.
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
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.
|
||||
<!-- /role:manager -->
|
||||
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.
|
||||
|
||||
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: "<parent>"` 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: "<children>"` 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: <message-id>` 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.
|
||||
<!-- role:agent -->
|
||||
- `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: "<parent>"` 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: "<children>"` 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: <message-id>` 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.
|
||||
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — 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.
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
- `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/<name>/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/<id>` — 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).
|
||||
<!-- /role:manager -->
|
||||
- `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: "<agent-name>"`). 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 manager calls `ask(to: "<your-name>", ...)`. 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.
|
||||
<!-- role:agent -->
|
||||
- `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: "<your-name>", ...)`. 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 <you>]` 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.)
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
- `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: "<name>"` 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).
|
||||
<!-- /role:manager -->
|
||||
- `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 <you>]` 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__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`, `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.
|
||||
<!-- role:agent -->
|
||||
- `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__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/<name>/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 — 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
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.
|
||||
|
||||
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/<name>/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/<name>/state/matrix.toml" ]; # replace <name> 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/<id>` 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/<name>/config fetch applied` — refresh the local copy of every deployed/failed/denied tag.
|
||||
- `git -C /agents/<name>/config log applied/main --oneline` — every successful deploy of this agent.
|
||||
- `git -C /agents/<name>/config show applied/refs/tags/deployed/<id>` — the tree that was deployed for approval `<id>`.
|
||||
- `git -C /agents/<name>/config show applied/refs/tags/failed/<id>` — annotated tag body is the build error from a rejected rebuild.
|
||||
- `git -C /agents/<name>/config show applied/refs/tags/denied/<id>` — annotated tag body is the operator's reason for denial.
|
||||
- `git -C /agents/<name>/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: "<agent>"`) when you need a structured answer from a peer rather than free-form chat.
|
||||
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.
|
||||
|
||||
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/<agent>/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.
|
||||
- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents/<agent>/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.
|
||||
- `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 the operator's answer to questions you asked.
|
||||
<!-- /role:manager -->
|
||||
- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up answers to questions you asked.
|
||||
|
||||
Durable knowledge:
|
||||
<!-- role:agent -->
|
||||
|
||||
- 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.
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
|
||||
- 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/<name>/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.
|
||||
<!-- /role:manager -->
|
||||
|
||||
Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.
|
||||
<!-- role:agent -->
|
||||
|
||||
**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.
|
||||
<!-- /role:agent -->
|
||||
|
||||
**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/<org>/<repo>.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 <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (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 <branch> [--base main] [--body "..." | --body-file <path>] [--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 <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
<!-- role:agent -->
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` 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.
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
|
||||
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/<descriptive-name>` and tell them "see /agents/X/state/<descriptive-name>".
|
||||
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/{label}/state/`) and tell them where to look.
|
||||
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. 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.
|
||||
<!-- /role:manager -->
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` 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.
|
||||
|
||||
When your inbox has a message, handle it and stop. Don't narrate intent — act.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
//! 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
|
||||
//! 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
|
||||
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -13,7 +10,7 @@ use std::time::Duration;
|
|||
|
||||
use hive_ag3nt::web_ui::TurnLock;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
|
|
@ -21,15 +18,10 @@ 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, ManagerRequest, ManagerResponse, SYSTEM_SENDER,
|
||||
};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "hive",
|
||||
about = "hyperhive harness — role from $HIVE_ROLE (agent|manager)"
|
||||
)]
|
||||
#[command(name = "hive", about = "hyperhive harness")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
|
||||
|
|
@ -48,16 +40,14 @@ enum Cmd {
|
|||
#[arg(long, default_value_t = 1000)]
|
||||
poll_ms: u64,
|
||||
},
|
||||
/// Run this role's MCP server on stdio. Spawned by `claude` via
|
||||
/// Run the 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. Available on both
|
||||
/// agent and manager roles; mirrors the `AgentRequest::Wake` /
|
||||
/// `ManagerRequest::Wake` pair already on the wire.
|
||||
/// need to nudge claude on external events.
|
||||
Wake {
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
|
|
@ -67,20 +57,6 @@ enum Cmd {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum Role {
|
||||
Agent,
|
||||
Manager,
|
||||
}
|
||||
|
||||
fn resolve_role() -> Result<Role> {
|
||||
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()
|
||||
|
|
@ -91,26 +67,11 @@ async fn main() -> Result<()> {
|
|||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let role = resolve_role()?;
|
||||
|
||||
// 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::<AgentSurface>(&cli.socket, poll_ms).await
|
||||
}
|
||||
(Role::Manager, Cmd::Serve { poll_ms }) => {
|
||||
serve_main::<ManagerSurface>(&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::<AgentSurface>(&cli.socket, from, body).await
|
||||
}
|
||||
(Role::Manager, Cmd::Wake { from, body }) => {
|
||||
wake::<ManagerSurface>(&cli.socket, from, body).await
|
||||
}
|
||||
match cli.cmd {
|
||||
Cmd::Serve { poll_ms } => serve_main::<AgentSurface>(&cli.socket, poll_ms).await,
|
||||
Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await,
|
||||
Cmd::Wake { from, body } => wake::<AgentSurface>(&cli.socket, from, body).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,16 +147,11 @@ enum RecvOutcome {
|
|||
TransportError,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
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<Output = ()>;
|
||||
|
|
@ -237,12 +193,11 @@ trait Surface {
|
|||
|
||||
// ---------- AgentSurface ----------
|
||||
|
||||
/// Zero-sized type tag for the sub-agent wire surface.
|
||||
/// Zero-sized type tag for the 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 {
|
||||
|
|
@ -382,159 +337,11 @@ 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<u64>, Option<u64>) {
|
||||
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<ManagerResponse> = 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 ----------
|
||||
|
||||
/// 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
|
||||
/// 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<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||
let port = std::env::var("HIVE_PORT")
|
||||
|
|
@ -559,13 +366,12 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
bus.seed_usage(ctx, cost);
|
||||
}
|
||||
}
|
||||
let files = turn::TurnFiles::prepare(socket, &label, S::FLAVOR).await?;
|
||||
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
||||
let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(()));
|
||||
// Plugin install runs role-agnostic: failures come back as a
|
||||
// Vec<String> and we route each through `<parent>` via the same
|
||||
// `send_to_parent` failure-notify path the turn loop uses. The
|
||||
// broker resolves `<parent>` per `topology::parent_of`; root
|
||||
// agents and the manager fall through to operator.
|
||||
// Plugin install failures come back as a Vec<String> — route each
|
||||
// through `<parent>` via the `send_to_parent` failure-notify path.
|
||||
// The broker resolves `<parent>` per `topology::parent_of`;
|
||||
// root agents fall through to operator.
|
||||
for failure in plugins::install_configured(socket).await {
|
||||
S::send_to_parent(socket, failure).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1744,13 +1744,11 @@ 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.
|
||||
/// Kept for API stability — was two-variant (Agent/Manager) but the manager
|
||||
/// role no longer exists. Only `Agent` remains; everything is perms + caps.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Flavor {
|
||||
Agent,
|
||||
Manager,
|
||||
}
|
||||
|
||||
/// Env var written by the meta renderer with a comma-separated list of
|
||||
|
|
@ -1800,18 +1798,11 @@ fn allowed_capability_tools() -> Vec<String> {
|
|||
/// 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 the flavor default when the env var is absent or empty.
|
||||
fn effective_tool_groups(flavor: Flavor) -> Vec<hive_sh4re::ToolGroup> {
|
||||
/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty.
|
||||
fn effective_tool_groups() -> Vec<hive_sh4re::ToolGroup> {
|
||||
let raw = match std::env::var(TOOL_GROUPS_ENV) {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => {
|
||||
// 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();
|
||||
}
|
||||
_ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(),
|
||||
};
|
||||
let mut groups = Vec::new();
|
||||
for token in raw.split(',') {
|
||||
|
|
@ -1829,12 +1820,9 @@ fn effective_tool_groups(flavor: Flavor) -> Vec<hive_sh4re::ToolGroup> {
|
|||
if groups.is_empty() {
|
||||
tracing::warn!(
|
||||
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
|
||||
falling back to flavor default"
|
||||
falling back to AGENT_DEFAULT"
|
||||
);
|
||||
return match flavor {
|
||||
Flavor::Agent => hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(),
|
||||
Flavor::Manager => hive_sh4re::ToolGroup::MANAGER_DEFAULT.to_vec(),
|
||||
};
|
||||
return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec();
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
|
@ -1872,8 +1860,8 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> {
|
|||
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
|
||||
/// both the built-ins and the MCP surface.
|
||||
#[must_use]
|
||||
pub fn allowed_tools_arg(flavor: Flavor) -> String {
|
||||
let groups = effective_tool_groups(flavor);
|
||||
pub fn allowed_tools_arg() -> String {
|
||||
let groups = effective_tool_groups();
|
||||
// Base built-ins always present.
|
||||
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
|
||||
.iter()
|
||||
|
|
@ -1903,15 +1891,7 @@ pub fn allowed_tools_arg(flavor: Flavor) -> String {
|
|||
/// `WebFetch`/`WebSearch` when the `web_tools` group is active).
|
||||
#[must_use]
|
||||
pub fn builtin_tools_arg() -> String {
|
||||
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 groups = effective_tool_groups();
|
||||
let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
||||
for group in &groups {
|
||||
for t in group.builtin_tools() {
|
||||
|
|
|
|||
|
|
@ -1,38 +1,31 @@
|
|||
//! System-prompt renderer. Single `prompts/system.md` with
|
||||
//! HTML-comment markers gating role-specific blocks; this module
|
||||
//! assembles the final prompt for a given flavor. Marker grammar +
|
||||
//! placeholder substitution rules in
|
||||
//! assembles the final prompt (always "agent" role — there is only one
|
||||
//! role). 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};
|
||||
|
||||
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`]
|
||||
/// 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`]
|
||||
/// (`$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 target = match flavor {
|
||||
Flavor::Agent => "agent",
|
||||
Flavor::Manager => "manager",
|
||||
};
|
||||
let body = filter_role_blocks(template, target);
|
||||
let body = filter_role_blocks(template, "agent");
|
||||
let qualified = crate::identity::qualify(label);
|
||||
let hive_identity = hive_name
|
||||
.filter(|n| !n.is_empty())
|
||||
|
|
@ -113,7 +106,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, flavor: Flavor) -> Result<PathBuf> {
|
||||
pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf> {
|
||||
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());
|
||||
|
|
@ -133,7 +126,6 @@ pub async fn write_system_prompt(_socket: &Path, label: &str, flavor: Flavor) ->
|
|||
let swarm_name = crate::identity::swarm_name();
|
||||
let body = render(
|
||||
&template,
|
||||
flavor,
|
||||
label,
|
||||
&pronouns,
|
||||
hive_name.as_deref(),
|
||||
|
|
@ -279,7 +271,6 @@ shared closer
|
|||
// harness already relied on.
|
||||
let rendered = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
Flavor::Agent,
|
||||
"alice",
|
||||
"they/them",
|
||||
None,
|
||||
|
|
@ -292,66 +283,32 @@ shared closer
|
|||
}
|
||||
|
||||
#[test]
|
||||
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.
|
||||
fn render_no_role_markers_in_output() {
|
||||
// No raw role markers should survive into the rendered prompt.
|
||||
let rendered = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
Flavor::Agent,
|
||||
"alice",
|
||||
"she/her",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(!rendered.contains("request_init_config"));
|
||||
assert!(!rendered.contains("request_apply_commit"));
|
||||
assert!(!rendered.contains("get_logs"));
|
||||
// Sanity: shared tools DO appear.
|
||||
assert!(!rendered.contains("<!-- role:"));
|
||||
assert!(!rendered.contains("<!-- /role:"));
|
||||
// Shared tools appear.
|
||||
assert!(rendered.contains("mcp__hyperhive__recv"));
|
||||
assert!(rendered.contains("mcp__hyperhive__ask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_manager_includes_manager_only_tools() {
|
||||
fn render_uses_agent_opener() {
|
||||
let rendered = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
Flavor::Manager,
|
||||
"ruth",
|
||||
"she/her",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(rendered.contains("request_init_config"));
|
||||
assert!(rendered.contains("request_apply_commit"));
|
||||
assert!(rendered.contains("get_logs"));
|
||||
assert!(rendered.contains("request_schedule_prompt"));
|
||||
assert!(rendered.contains("cancel_schedule"));
|
||||
// Sub-agent-only sections must NOT appear in manager prompt.
|
||||
assert!(!rendered.contains("request_next_turn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_uses_correct_role_opener() {
|
||||
let agent = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
Flavor::Agent,
|
||||
"alice",
|
||||
"she/her",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(agent.starts_with("You are hyperhive agent"));
|
||||
let manager = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
Flavor::Manager,
|
||||
"ruth",
|
||||
"she/her",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(manager.starts_with("You are the hyperhive manager"));
|
||||
assert!(rendered.starts_with("You are hyperhive agent"));
|
||||
}
|
||||
|
||||
// Inline fixture for the {hive_identity} / {swarm_identity}
|
||||
|
|
@ -371,7 +328,6 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
fn render_substitutes_hive_identity_when_set() {
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
Flavor::Agent,
|
||||
"alice",
|
||||
"she/her",
|
||||
Some("pr1ma"),
|
||||
|
|
@ -389,7 +345,6 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
fn render_substitutes_swarm_identity_when_set() {
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
Flavor::Manager,
|
||||
"ruth",
|
||||
"she/her",
|
||||
None,
|
||||
|
|
@ -403,7 +358,6 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
fn render_substitutes_both_when_both_set() {
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
Flavor::Agent,
|
||||
"iris",
|
||||
"she/her",
|
||||
Some("pr1ma"),
|
||||
|
|
@ -418,14 +372,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
fn render_omits_identity_when_unset() {
|
||||
// None / None must round-trip the non-identity opener verbatim
|
||||
// — single-hive deployments see zero diff.
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
Flavor::Agent,
|
||||
"alice",
|
||||
"she/her",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", None, None);
|
||||
assert!(!rendered.contains("on hive"));
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
assert!(!rendered.contains("{hive_identity}"));
|
||||
|
|
@ -438,14 +385,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
// through `identity::hive_name()` as None (the accessor
|
||||
// filters empty), but `render` should still no-op on a
|
||||
// direct `Some("")` from a test fixture or a future caller.
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
Flavor::Agent,
|
||||
"alice",
|
||||
"she/her",
|
||||
Some(""),
|
||||
Some(""),
|
||||
);
|
||||
let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", Some(""), Some(""));
|
||||
assert!(!rendered.contains("on hive"));
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Per-turn claude invocation shared by `hive-ag3nt` and `hive-m1nd`. The
|
||||
//! two binaries differ only in their MCP `Flavor` (agent surface vs.
|
||||
//! manager surface) and their wake-prompt wording; the spawn shape,
|
||||
//! arg-vector, stdin plumbing, and stream-json pumping are identical.
|
||||
//! Per-turn claude invocation. The spawn shape, arg-vector, stdin plumbing,
|
||||
//! and stream-json pumping are shared across all roles (there is only one
|
||||
//! role: agent).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -107,7 +106,6 @@ pub struct TurnFiles {
|
|||
pub mcp_config: PathBuf,
|
||||
pub settings: PathBuf,
|
||||
pub system_prompt: PathBuf,
|
||||
pub flavor: mcp::Flavor,
|
||||
}
|
||||
|
||||
impl TurnFiles {
|
||||
|
|
@ -117,12 +115,11 @@ impl TurnFiles {
|
|||
/// # Errors
|
||||
///
|
||||
/// Returns an error if any of the config files cannot be written to disk.
|
||||
pub async fn prepare(socket: &Path, label: &str, flavor: mcp::Flavor) -> Result<Self> {
|
||||
pub async fn prepare(socket: &Path, label: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
mcp_config: write_mcp_config(socket).await?,
|
||||
settings: write_settings(socket).await?,
|
||||
system_prompt: write_system_prompt(socket, label, flavor).await?,
|
||||
flavor,
|
||||
system_prompt: write_system_prompt(socket, label).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -184,12 +181,8 @@ pub async fn write_settings(_socket: &Path) -> Result<PathBuf> {
|
|||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the system prompt file cannot be written.
|
||||
pub async fn write_system_prompt(
|
||||
socket: &Path,
|
||||
label: &str,
|
||||
flavor: mcp::Flavor,
|
||||
) -> Result<PathBuf> {
|
||||
crate::prompt::write_system_prompt(socket, label, flavor).await
|
||||
pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf> {
|
||||
crate::prompt::write_system_prompt(socket, label).await
|
||||
}
|
||||
|
||||
/// One claude turn's outcome. The harness uses this to decide whether to
|
||||
|
|
@ -682,9 +675,9 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
|||
.arg(&files.mcp_config)
|
||||
.arg("--strict-mcp-config")
|
||||
.arg("--tools")
|
||||
.arg(mcp::builtin_tools_arg_for_flavor(files.flavor))
|
||||
.arg(mcp::builtin_tools_arg())
|
||||
.arg("--allowedTools")
|
||||
.arg(mcp::allowed_tools_arg(files.flavor));
|
||||
.arg(mcp::allowed_tools_arg());
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ use crate::client;
|
|||
use crate::events::Bus;
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::{LoginSession, drop_if_finished};
|
||||
use crate::mcp;
|
||||
use crate::turn::TurnFiles;
|
||||
|
||||
/// Live login state for the web UI. The harness updates this in place as it
|
||||
|
|
@ -63,8 +62,6 @@ struct AppState {
|
|||
gui_vnc_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Re-export so callers in `turn.rs` can name the type via `web_ui::Flavor`.
|
||||
pub type Flavor = mcp::Flavor;
|
||||
|
||||
/// Bind the per-container web listener and serve the SPA.
|
||||
///
|
||||
|
|
|
|||
Loading…
Reference in a new issue