From 3188e50ab8242268b380ce89d341d1063c71d1b1 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 22 Jul 2026 17:34:12 +0200 Subject: [PATCH] docs(#2628): update bash tool descriptions, docs, and system prompt for the todo model (trim impl details for agent) --- docs/persistence.md | 13 ++++----- docs/tools/bash.md | 55 +++++++++++++++++++----------------- docs/tools/matrix.md | 15 +++++----- hive-agent/prompts/system.md | 2 +- hive-bash-mcp/src/bin/mcp.rs | 50 +++++++++++++++----------------- 5 files changed, 66 insertions(+), 69 deletions(-) diff --git a/docs/persistence.md b/docs/persistence.md index 006db829..10495ef4 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -233,13 +233,12 @@ Under `/var/lib/hyperhive/agents//`: (full captured output). `hive-c0re::bash_tasks_vacuum` runs hourly and deletes terminal task trios older than 48 hours; non-terminal (still-running) tasks are never deleted by vacuum. - - `mcp-loose-ends/` — JSON files published by external MCP daemons - (e.g. `hive-bash-mcp`, `hive-matrix-mcp`) listing their active - loose-end summary strings. Each file is `.json` containing - a JSON array of plain-text lines. Read by `get_loose_ends` to - surface active background work without hardcoding per-MCP - knowledge in the harness. Files are created/removed by the - external daemons themselves. + - `hyperhive-todos.sqlite` — loose-ends-v2 todo store. In-container + MCP daemons (`hive-bash-mcp`, `hive-matrix-mcp`) and `forge_notify` + upsert keyed todos here over the harness's in-agent socket + (`HIVE_AGENT_SOCKET`); the harness merges them into `get_loose_ends` + output and clears a row on `mark_todo_done`. Replaced the old + file-based `mcp-loose-ends/` scanner. - `hyperhive-events.sqlite` — turn-loop event log. - `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-model` — single-line model name override file. diff --git a/docs/tools/bash.md b/docs/tools/bash.md index cac557af..1e802ac1 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -13,24 +13,24 @@ invocation regardless of tool groups. Submit a shell command for background execution (runs via `bash`). Stdout and stderr stream to `harness/bash-tasks/.{out,err}`. -When the task completes (or times out, or the process errors), the -harness fires a wake with `from: "bash-task-"`; the body contains -the exit code and last stdout lines. Handle the completion on a future -turn — unless `wait_seconds` already delivered the terminal result -inline, in which case the wake is suppressed (see `status` below). +When the task completes (or times out, or the process errors), it +surfaces as a todo in the agent's loose-ends (via `get_loose_ends`), +carrying the exit code and a `Read()` pointer to the captured +output. Handle it on a future turn — unless `wait_seconds` already +delivered the terminal result inline, in which case no todo is created +(see `status` below). * `timeout_secs` — kill the task after N seconds and mark it `timed_out`. Omit for no timeout (runs until natural exit). * `wait_seconds` — inline poll before returning (capped at 30). When the task finishes within the window the full status is - returned immediately and no wake is fired; when the window expires + returned immediately and no todo is created; when the window expires the task keeps running and the normal `task started: id=` response is returned. **Defaults to 3** — pass `wait_seconds: 0` to disable inline waiting and always get the immediate response. * `name` — optional caller-chosen task id. When set it replaces the - auto-generated hex id, so it surfaces in the wake `from` - (`bash-task-`), in `status()` lookups, and in the - loose-ends list — a memorable label instead of an opaque id. A name + auto-generated hex id, so it surfaces in `status()` lookups and + the loose-ends list — a memorable label instead of an opaque id. A name is **reusable once its previous task has finished**; submitting a name whose task is still `pending`/`running` is rejected. Allowed characters: ASCII letters, digits, `.`, `_`, `-` (max 64). Omit for @@ -52,15 +52,14 @@ finishes within the window the full status is returned immediately. Useful to avoid a separate round-trip when the task is expected to finish soon. -Any `status` call (waited or not) that observes a terminal task -suppresses that task's completion wake — you already have the result -in this response, so no redundant `bash-task-` inbox message -follows (#2270). Narrow best-effort race: a `status`/`run` inline wait -that resolves in the same instant the task actually finishes can still -occasionally get both. +Any `status` call (waited or not) that observes a terminal task clears +that task's completion todo — you already have the result in this +response, so no redundant loose-end follows. Narrow best-effort race: a +`status`/`run` inline wait that resolves in the same instant the task +actually finishes can still occasionally get both. Tasks marked `interrupted` had their process killed by a harness -restart; a best-effort wake was still sent so the agent is not +restart; a best-effort todo is still surfaced so the agent is not silently blocked. Exposed as `mcp__bash__status`. @@ -68,8 +67,8 @@ Exposed as `mcp__bash__status`. ### `kill(id, force?)` Stop a running or pending task by its ID (from `run`). Fire-and-forget: -sends the signal and returns without waiting — handle the completion -wake (`from: "bash-task-"`) on a future turn. +sends the signal and returns without waiting — the completion surfaces +in the agent's loose-ends; handle it on a future turn. - `force: false` (default) — SIGINT to the task's **process group** (graceful; lets the process clean up). The whole process group is @@ -79,7 +78,7 @@ wake (`from: "bash-task-"`) on a future turn. If a SIGINT'd task doesn't exit, call `kill` again with `force: true`. A still-pending task is cancelled before it starts. The task ends as -`killed` and fires the usual completion wake. +`killed` and surfaces in the loose-ends like any completion. Exposed as `mcp__bash__kill`. @@ -97,7 +96,7 @@ matrix MCP: - **`hive-bash-daemon`** — long-running process (one per agent container, systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management, - output file writing, `mcp-loose-ends/` state, and wake signal delivery. + output file writing, and todo delivery on the harness's in-agent socket. Listens on `/run/hive-bash/socket` inside the container. - **`hive-bash-mcp`** — stdio bridge spawned by claude per turn (declared @@ -108,13 +107,17 @@ matrix MCP: This split keeps claude's turn-local MCP bridge lightweight while the daemon tracks long-running tasks that outlive a single turn. -### Transient wake (bypass broker sqlite) +### Completion as a todo (loose-ends v2) -When a bash task finishes, `hive-bash-daemon` sends the wake signal via -the agent's per-agent socket as a **transient wake** (`AgentRequest::Wake` -with `transient: true`). This bypasses broker sqlite for lower latency — -the same mechanism used by matrix events. The message is delivered -directly to the harness without touching the persistent message store. +When a bash task changes state, `hive-bash-daemon` upserts a single keyed +todo (`key = task id`) on the harness's in-agent socket (`HIVE_AGENT_SOCKET`) +— "running" at start, then the completion summary when it finishes. The +summary change signals the harness turn loop directly (in-process, no broker +round-trip), so the agent is driven a turn to handle it via `get_loose_ends`, +then clears the todo with `mark_todo_done`. Same mechanism the matrix daemon +uses for unread rooms. An inline `wait_seconds` / `status` observation that +already delivered the result instead clears the keyed todo, so no redundant +loose-end follows. ## Relationship to the `execution` tool group diff --git a/docs/tools/matrix.md b/docs/tools/matrix.md index 1c43bc02..c81bed76 100644 --- a/docs/tools/matrix.md +++ b/docs/tools/matrix.md @@ -90,17 +90,16 @@ returned by `get_loose_ends` so unread rooms surface in the loose-ends list between turns. **Invite wakes**: when the daemon's sync loop receives an -`m.room.member` invite event, it writes the invite to -`mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon -does **not** auto-join — the agent calls `list_invites` to see pending +`m.room.member` invite event, it upserts a todo (keyed `invite:`) +on the harness's in-agent socket, which drives a turn. The daemon does +**not** auto-join — the agent calls `list_invites` to see pending invites and `resolve_invite` to accept or reject them. -**Pending invites as loose ends**: pending invites are written to -`mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as +**Pending invites as loose ends**: pending invites are upserted as +keyed todos and appear in `get_loose_ends` output as `[matrix] pending invite: — use list_invites to see, -resolve_invite to accept or reject`. The file is updated atomically -after each invite event and after each `resolve_invite` (or -`join_room`) call clears the entry. +resolve_invite to accept or reject`. The keyed todo is cleared when a +`resolve_invite` (or `join_room`) call resolves the invite. See [`docs/matrix.md`](../matrix.md) for the homeserver setup, provisioning flow, and federation config. diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index fc27896d..2ff4bca3 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -8,7 +8,7 @@ Tools (hyperhive surface): - (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 operator calls `ask(to: "", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target. -- `mcp__hyperhive__get_loose_ends(agent?)` — 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 to list your own threads — cheap server-side sweep useful at turn start. Pass `agent: ""` to inspect a peer agent's threads. Direct child agents are always accessible. For non-children, the `query_agent_state` capability is required — without it the request is rejected with an error. +- `mcp__hyperhive__get_loose_ends(agent?)` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), reminders you've scheduled that haven't fired, plus in-container todos surfaced by your MCP daemons (finished/running background bash tasks, unread matrix rooms, forge threads). No args to list your own threads — cheap server-side sweep useful at turn start. Pass `agent: ""` to inspect a peer agent's threads. Direct child agents are always accessible. For non-children, the `query_agent_state` capability is required — without it the request is rejected with an error. - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by ]` answer so the waiter unblocks), `"reminder"` (hard-deleted before it fires), or `"approval"` (withdraws a pending approval you submitted that got superseded — root agent only; the server rejects this kind for all other callers). `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. diff --git a/hive-bash-mcp/src/bin/mcp.rs b/hive-bash-mcp/src/bin/mcp.rs index 69435cd9..9c365a0a 100644 --- a/hive-bash-mcp/src/bin/mcp.rs +++ b/hive-bash-mcp/src/bin/mcp.rs @@ -125,7 +125,7 @@ fn render_bash_run(id: &str, resp: Result) -> String { /// immediately re-waiting on the same task. const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait timed out before it \ finished. If you have other useful work, do that and check back later (the task keeps running, and \ -a wake fires when it completes) rather than immediately re-waiting."; +it surfaces in your loose-ends when it completes) rather than immediately re-waiting."; /// Turn a `DaemonResponse` from a `BashStatus` call into the string /// claude sees as the tool result. When `waited` is set (the call @@ -154,7 +154,7 @@ fn render_bash_kill(resp: Result) -> String { let sig = payload["signal"].as_str().unwrap_or("SIGINT"); format!( "task `{id}`: {sig} sent to its process group; it transitions to `killed` \ - once the process exits and the usual completion wake fires." + once the process exits and then surfaces in your loose-ends." ) } else { format!("task `{id}` was pending — cancelled before it started.") @@ -180,17 +180,16 @@ struct BashRunArgs { timeout_secs: Option, /// Optional inline wait: `run` polls for up to `wait_seconds` /// (capped at 30) before returning. When the task finishes within the - /// window the full status is returned immediately and no wake is fired; - /// when the timeout expires the task keeps running and the normal - /// `task started: id=` response is returned. Defaults to 3s. Pass - /// `0` to disable inline waiting and always get the immediate response. + /// window the full status is returned immediately (nothing to handle + /// later); when the timeout expires the task keeps running and the + /// normal `task started: id=` response is returned. Defaults to 3s. + /// Pass `0` to disable inline waiting and always get the immediate response. #[serde(default = "default_wait")] wait_seconds: Option, /// Optional task name. When set it becomes the task id, so it appears - /// in the completion wake (`from: "bash-task-"`), in `status` - /// lookups, and in the loose-ends list — handy for recognising a task - /// later instead of an opaque hex id. A name can be reused once its - /// previous task has finished; reusing a name whose task is still + /// in `status` lookups and your loose-ends list — handy for recognising + /// a task later instead of an opaque hex id. A name can be reused once + /// its previous task has finished; reusing a name whose task is still /// running is rejected. Allowed chars: ASCII letters, digits, `.`, /// `_`, `-` (max 64). Omit to get the auto-generated id. #[serde(default)] @@ -237,21 +236,18 @@ struct BashMcp; impl BashMcp { #[tool( description = "Run a shell command in the background. Returns a task ID immediately — \ - do NOT wait inline. When the command finishes, the harness fires a wake with \ - `from: \"bash-task-\"` carrying the exit status plus a `Read()` \ - pointer to the captured `.out`/`.err` files (not the output text itself — \ - read only what you need); handle it on a future turn. Use `status` to poll the task status within \ - the same turn if needed. `timeout_secs` defaults to `None` (no timeout) — \ - task runs until natural exit; pass an explicit value to kill after N seconds. \ - Pass `wait_seconds` (capped at 30) to wait inline for fast commands: when the \ - task finishes within the window the full status is returned immediately and no \ - wake is fired; when the timeout expires the task keeps running and the normal \ - `task started: id=` response is returned. `wait_seconds` defaults to 3; \ - pass `wait_seconds: 0` to disable inline waiting and always get the immediate \ - response. Pass `name` to label the task with a memorable id (used in the wake \ - `from`, `status` lookups, and the loose-ends list) instead of an opaque hex id; \ - a name is reusable once its prior task has finished, and rejected while one is \ - still running." + do NOT wait inline. When the command finishes it surfaces as a todo in your \ + loose-ends (the exit status plus a `Read()` pointer to the captured \ + `.out`/`.err` files — read only what you need); handle it on a future turn. Use \ + `status` to poll within the same turn if needed. `timeout_secs` defaults to \ + `None` (no timeout) — task runs until natural exit; pass a value to kill after \ + N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \ + if the task finishes within the window you get the full status immediately \ + (nothing to handle later); otherwise it keeps running and you get \ + `task started: id=`. Defaults to 3; pass `0` to disable inline waiting. \ + Pass `name` to label the task with a memorable id (shown in `status` lookups \ + and your loose-ends) instead of an opaque hex id; reusable once the prior task \ + has finished, rejected while one is still running." )] async fn run(&self, Parameters(args): Parameters) -> String { let req = DaemonRequest::BashRun { @@ -297,8 +293,8 @@ impl BashMcp { signalled, so a runaway child (cargo/nix/etc.) is stopped too, not just the shell. \ Fire-and-forget: sends the signal and returns without waiting. If a SIGINT'd task \ doesn't exit, call kill again with `force: true` to SIGKILL. A still-pending task \ - is cancelled before it starts. The task ends as `killed` and fires the usual \ - completion wake." + is cancelled before it starts. The task ends as `killed` and surfaces in your \ + loose-ends like any completion." )] async fn kill(&self, Parameters(args): Parameters) -> String { let req = DaemonRequest::BashKill {