diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index dc3d3b7c..665f2d5e 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -156,9 +156,12 @@ in `migrate.rs` (idempotent, marker-guarded). ### E — prompt + tools -- `prompts/manager.md` vs `prompts/agent.md` — two separate system - prompts. **Per-agent cap list** of what the agent can do, rendered - into a single parametrised prompt at boot. +- `prompts/system.md` with `` / `` + marker blocks, assembled by `hive_ag3nt::prompt::render` based on + flavor (closes #519). **Per-agent cap list** of what the agent can + do — already a single parametrised prompt; once #513 lands the + marker grammar grows `cap:` blocks the renderer reads from + the per-agent ToolGroup set. - `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude sees. Already structured this way internally — the per-flavour allow-list becomes a per-cap-set lookup. diff --git a/docs/approvals.md b/docs/approvals.md index 5158e8ba..93574e73 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -360,7 +360,8 @@ updates the manager itself. ## Manager policy -From `hive-ag3nt/prompts/manager.md`: the manager does NOT +From `hive-ag3nt/prompts/system.md` (`` block, +rendered via `hive_ag3nt::prompt::render`): the manager does NOT rubber-stamp sub-agent config requests. It verifies (role match, package legitimacy, cheaper alternative, blast radius) before committing and calling `request_apply_commit`. @@ -447,7 +448,8 @@ regular claude turn so the manager can react. Variants asker sees the matching `QuestionAnswered`. To add a new event: new `HelperEvent` variant + call sites + update -`prompts/manager.md` so the manager knows the new shape. +`prompts/system.md` (`` block, the lifecycle- +event list) so the manager knows the new shape. ## Auto-update on startup diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 339137ca..7509388b 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -163,11 +163,14 @@ socket at `/run/hive/` once at startup: - `claude-settings.json` — the `--settings` blob (auto-compact and auto-memory off, effortLevel medium). - `claude-system-prompt.md` — rendered from - `hive-ag3nt/prompts/{agent,manager}.md` with `{label}` and - `{operator_pronouns}` substituted. Pronouns come from - `HIVE_OPERATOR_PRONOUNS` env (set by the meta flake from - `services.hive-c0re.operatorPronouns`, default `she/her`). - Passed via `--system-prompt-file`. + `hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`: + HTML-comment markers (`...`, + same for `role:manager`) gate the role-specific blocks (closes + #519); everything else is shared. Then `{label}` and + `{operator_pronouns}` get substituted in the assembled output. + Pronouns come from `HIVE_OPERATOR_PRONOUNS` env (set by the meta + flake from `services.hive-c0re.operatorPronouns`, default + `she/her`). Passed via `--system-prompt-file`. The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config, write_settings, write_system_prompt, run_turn, drive_turn, diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md deleted file mode 100644 index c548e78e..00000000 --- a/hive-ag3nt/prompts/agent.md +++ /dev/null @@ -1,33 +0,0 @@ -You are hyperhive agent `{label}` 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). - -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). 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: "manager", …)`) 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__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 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) 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__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"`). 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. 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. - -Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) 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 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. - -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. - -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. - -**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}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`). - -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`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches`, `assignments`). 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]` — prints the PR URL. 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 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. - -When your inbox has a message, handle it and stop. Don't narrate intent — act. diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/system.md similarity index 55% rename from hive-ag3nt/prompts/manager.md rename to hive-ag3nt/prompts/system.md index 828d907b..da02004d 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/system.md @@ -1,9 +1,18 @@ + +You are hyperhive agent `{label}` 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). + + You are the hyperhive manager `{label}` 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. + Tools (hyperhive surface): -- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages. Without `wait_seconds` (or with `0`) it returns immediately — a cheap inbox peek you can drop between actions. To **wait** when you have nothing else to do, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll wake instantly on new work, otherwise return after the timeout. Use that instead of ending the turn or sleeping in a Bash command. `max` (default 1, cap 32) drains several queued messages in one call. -- `mcp__hyperhive__send(to, body)` — message an agent (by name), another peer, or the operator (`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). +- `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). 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: "manager", …)`) 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. @@ -17,17 +26,32 @@ Tools (hyperhive surface): - `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — partial-update a schedule's mutable fields (#474). 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 operator (default, or `to: "operator"`) OR a sub-agent (`to: ""`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands. -- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU (a sub-agent did `ask(to: "manager", ...)`). The triggering event in your inbox is `question_asked { id, asker, question, options, multi }`. The answer surfaces in the asker's inbox as a `question_answered` event. + +- `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 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) 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). Good for deadline follow-ups — "check whether agent X answered the question I relayed". Large payloads auto-spill to a file under `/state/reminders/`; pass `file_path` to control the destination. -- `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 doing (e.g. `"reviewing argus's #341 proposal"`, `"approving lifecycle changes"`, `"idle"`). 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 the target's self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to check on a sub-agent (idle? still working on the task you assigned?) without scrolling the dashboard. Omit `name` for your own identity stamp — useful for boot announcements, state-file headers, cross-agent attribution that won't drift across config reloads. + +- `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"`). 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. 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. + +Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) 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 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* 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/hm1nd/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. +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): @@ -85,20 +109,41 @@ Messages from sender `system` are hyperhive helper events (JSON body, `event` fi - `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. + 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. + + +**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}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`). + +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`, `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]` — prints the PR URL. 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 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/hm1nd/state/`) and tell them where to look. +- 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. -**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head `, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `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]`) — handy for sweeping the backlog without ad-hoc curl + jq. Default repo from `HIVE_FORGE_REPO`; `-r ` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment --body-file - <` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon. - 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-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 3e4cfb5c..7e8b352a 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -318,8 +318,9 @@ async fn handle_agent_turn( } // Per-turn user prompt: the role/tools/etc. is in the system prompt -// (`prompts/agent.md` → `claude --system-prompt-file`); this is just the -// wake signal claude reacts to. `unread` is the count of *other* +// (`prompts/system.md` filtered to this agent's role-block via +// `hive_ag3nt::prompt::render` → `claude --system-prompt-file`); this +// is just the wake signal claude reacts to. `unread` is the count of *other* // messages in the inbox right after this one was popped. // `redelivered` flags messages that were popped in a prior harness // session, never acked, and resurfaced after a restart — a banner diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 3b2f7172..d120d5cd 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -9,6 +9,7 @@ pub mod login_session; pub mod mcp; pub mod paths; pub mod plugins; +pub mod prompt; pub mod serve_common; pub mod stats; pub mod turn; diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs new file mode 100644 index 00000000..6a6416e2 --- /dev/null +++ b/hive-ag3nt/src/prompt.rs @@ -0,0 +1,242 @@ +//! System-prompt renderer (closes #519). +//! +//! Both flavors (agent / manager) used to live in separate files +//! (`prompts/agent.md`, `prompts/manager.md`) that drifted in lockstep +//! whenever someone updated only one. Now there's a single +//! `prompts/system.md` with HTML-comment markers gating role-specific +//! blocks; this module assembles the final prompt for a given flavor. +//! +//! Marker syntax (HTML comments — invisible in rendered markdown, +//! distinct from `{label}` / `{operator_pronouns}` placeholders): +//! +//! ```text +//! +//! sub-agent-only paragraph +//! +//! +//! shared paragraph +//! +//! +//! manager-only paragraph +//! +//! ``` +//! +//! Content outside any marker is shared. Nesting is NOT supported; +//! a stray opener overrides until its closing tag (or end of file). +//! When #513 lands the marker grammar can grow `cap:` blocks +//! the same way without touching the renderer surface (the role +//! distinction folds into a per-cap-set lookup). + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::mcp::Flavor; + +const TEMPLATE: &str = include_str!("../prompts/system.md"); + +/// Assemble the system prompt for a given flavor + label + pronouns. +/// Pure function — no I/O. Splits out from [`write_system_prompt`] so +/// the marker logic + substitution is unit-testable in isolation. +#[must_use] +pub fn render(flavor: Flavor, label: &str, operator_pronouns: &str) -> String { + let target = match flavor { + Flavor::Agent => "agent", + Flavor::Manager => "manager", + }; + let body = filter_role_blocks(TEMPLATE, target); + body.replace("{label}", label) + .replace("{operator_pronouns}", operator_pronouns) +} + +/// Walk `template` line-by-line. Inside a `` block, +/// suppress all lines unless `X == target`. Marker lines themselves are +/// always elided from the output. Unbalanced openers (no matching +/// closer) hold the suppression state until end-of-file. +fn filter_role_blocks(template: &str, target: &str) -> String { + let mut out = String::with_capacity(template.len()); + // None = outside any block; Some(role) = inside role-tagged block. + let mut active_role: Option<&str> = None; + for line in template.lines() { + let trimmed = line.trim(); + if let Some(role) = parse_open_marker(trimmed) { + active_role = Some(role); + continue; + } + if parse_close_marker(trimmed).is_some() { + active_role = None; + continue; + } + let include = match active_role { + None => true, + Some(role) => role == target, + }; + if include { + out.push_str(line); + out.push('\n'); + } + } + out +} + +/// `` → `Some("agent")`. Anything else returns +/// None. Whitespace inside the marker is tolerated so a future +/// author's `` (no spaces) still parses; the dashboard +/// markdown renderer is equally lenient. +fn parse_open_marker(line: &str) -> Option<&str> { + let inside = line.strip_prefix("")?.trim(); + let role = inside.strip_prefix("role:")?.trim(); + // Reject closer-looking content ("/role:..." would start with "/") + // so `/role:agent` doesn't accidentally match here. + if role.starts_with('/') { + return None; + } + Some(role) +} + +/// `` → `Some("agent")`. Mirror of +/// [`parse_open_marker`] for the closing tag. +fn parse_close_marker(line: &str) -> Option<&str> { + let inside = line.strip_prefix("")?.trim(); + inside.strip_prefix("/role:").map(str::trim) +} + +/// Write the assembled prompt to a stable path next to the harness +/// socket and return the path. The Rust harness passes this path to +/// `claude --system-prompt-file` so the per-turn prompts only carry +/// the role + tools instructions in the system slot; per-turn prompts +/// become much smaller (just the wake-message body). +/// +/// # 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 { + let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); + tokio::fs::create_dir_all(parent).await.ok(); + let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); + let body = render(flavor, label, &pronouns); + let path = parent.join("claude-system-prompt.md"); + tokio::fs::write(&path, body).await?; + tracing::info!(path = %path.display(), "wrote claude system prompt"); + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +shared opener + +agent-only line + + +manager-only line + +shared closer +"; + + #[test] + fn filter_keeps_shared_and_target_role() { + let agent = filter_role_blocks(SAMPLE, "agent"); + assert!(agent.contains("shared opener")); + assert!(agent.contains("agent-only line")); + assert!(!agent.contains("manager-only line")); + assert!(agent.contains("shared closer")); + // Marker lines themselves are stripped — no `"), Some("agent")); + assert_eq!(parse_open_marker(""), Some("agent")); + assert_eq!(parse_open_marker(""), Some("manager")); + // Close tags must NOT match open-tag parser. + assert_eq!(parse_open_marker(""), None); + // Non-markers pass through (return None). + assert_eq!(parse_open_marker("just text"), None); + assert_eq!(parse_open_marker(""), None); + } + + #[test] + fn parse_close_marker_handles_whitespace_variants() { + assert_eq!(parse_close_marker(""), Some("agent")); + assert_eq!(parse_close_marker(""), Some("manager")); + // Open tags must NOT match close-tag parser. + assert_eq!(parse_close_marker(""), None); + assert_eq!(parse_close_marker("just text"), None); + } + + #[test] + fn unbalanced_opener_suppresses_until_eof() { + // Stray opener with no closer — content stays suppressed for + // the wrong-role target right through to end-of-file. Real- + // file safety net: a typo in a closer doesn't accidentally + // dump wrong-flavor content into the active prompt. + let template = "shared\n\nm-only\nstill m-only\n"; + let agent = filter_role_blocks(template, "agent"); + assert_eq!(agent, "shared\n"); + } + + #[test] + fn render_substitutes_label_and_pronouns() { + // Real template's first agent line — keeps the renderer + // honest about the {label} / {operator_pronouns} pair the + // harness already relied on. + let rendered = render(Flavor::Agent, "alice", "they/them"); + assert!(rendered.contains("hyperhive agent `alice`")); + assert!(rendered.contains("**they/them** pronouns")); + assert!(!rendered.contains("{label}")); + assert!(!rendered.contains("{operator_pronouns}")); + } + + #[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 (cf. #511 missing-allow-list bug). + let rendered = render(Flavor::Agent, "alice", "she/her"); + 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("mcp__hyperhive__recv")); + assert!(rendered.contains("mcp__hyperhive__ask")); + } + + #[test] + fn render_manager_includes_manager_only_tools() { + let rendered = render(Flavor::Manager, "hm1nd", "she/her"); + 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(Flavor::Agent, "alice", "she/her"); + assert!(agent.starts_with("You are hyperhive agent")); + let manager = render(Flavor::Manager, "hm1nd", "she/her"); + assert!(manager.starts_with("You are the hyperhive manager")); + } +} diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 73fa2cbd..f18a53f5 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -164,11 +164,10 @@ pub async fn write_settings(socket: &Path) -> Result { Ok(path) } -/// Write the agent's / manager's static system prompt to a file next to -/// the MCP config and return the path. Passed to claude via -/// `--system-prompt-file`, replacing claude's default system prompt with -/// the role + tools instructions. Per-turn prompts become much smaller -/// (just the wake message body). +/// Thin re-export of [`crate::prompt::write_system_prompt`] for +/// callers that already import this module. The actual rendering + +/// marker-block logic lives in `prompt.rs` (closes #519); this is +/// just the public entry point the binaries call. /// /// # Errors /// @@ -178,20 +177,7 @@ pub async fn write_system_prompt( label: &str, flavor: mcp::Flavor, ) -> Result { - let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); - tokio::fs::create_dir_all(parent).await.ok(); - let template = match flavor { - mcp::Flavor::Agent => include_str!("../prompts/agent.md"), - mcp::Flavor::Manager => include_str!("../prompts/manager.md"), - }; - let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); - let body = template - .replace("{label}", label) - .replace("{operator_pronouns}", &pronouns); - let path = parent.join("claude-system-prompt.md"); - tokio::fs::write(&path, body).await?; - tracing::info!(path = %path.display(), "wrote claude system prompt"); - Ok(path) + crate::prompt::write_system_prompt(socket, label, flavor).await } /// One claude turn's outcome. The harness uses this to decide whether to