docs(#2628): update bash tool descriptions, docs, and system prompt for the todo model (trim impl details for agent)

This commit is contained in:
damocles 2026-07-22 17:34:12 +02:00
commit 3188e50ab8
5 changed files with 67 additions and 70 deletions

View file

@ -233,13 +233,12 @@ Under `/var/lib/hyperhive/agents/<name>/`:
(full captured output). `hive-c0re::bash_tasks_vacuum` runs (full captured output). `hive-c0re::bash_tasks_vacuum` runs
hourly and deletes terminal task trios older than 48 hours; hourly and deletes terminal task trios older than 48 hours;
non-terminal (still-running) tasks are never deleted by vacuum. non-terminal (still-running) tasks are never deleted by vacuum.
- `mcp-loose-ends/` — JSON files published by external MCP daemons - `hyperhive-todos.sqlite` — loose-ends-v2 todo store. In-container
(e.g. `hive-bash-mcp`, `hive-matrix-mcp`) listing their active MCP daemons (`hive-bash-mcp`, `hive-matrix-mcp`) and `forge_notify`
loose-end summary strings. Each file is `<daemon>.json` containing upsert keyed todos here over the harness's in-agent socket
a JSON array of plain-text lines. Read by `get_loose_ends` to (`HIVE_AGENT_SOCKET`); the harness merges them into `get_loose_ends`
surface active background work without hardcoding per-MCP output and clears a row on `mark_todo_done`. Replaced the old
knowledge in the harness. Files are created/removed by the file-based `mcp-loose-ends/` scanner.
external daemons themselves.
- `hyperhive-events.sqlite` — turn-loop event log. - `hyperhive-events.sqlite` — turn-loop event log.
- `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-turn-stats.sqlite` — per-turn timing stats.
- `hyperhive-model` — single-line model name override file. - `hyperhive-model` — single-line model name override file.

View file

@ -13,24 +13,24 @@ invocation regardless of tool groups.
Submit a shell command for background execution (runs via `bash`). Submit a shell command for background execution (runs via `bash`).
Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`. Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`.
When the task completes (or times out, or the process errors), the When the task completes (or times out, or the process errors), it
harness fires a wake with `from: "bash-task-<id>"`; the body contains surfaces as a todo in the agent's loose-ends (via `get_loose_ends`),
the exit code and last stdout lines. Handle the completion on a future carrying the exit code and a `Read(<path>)` pointer to the captured
turn — unless `wait_seconds` already delivered the terminal result output. Handle it on a future turn — unless `wait_seconds` already
inline, in which case the wake is suppressed (see `status` below). 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 * `timeout_secs` — kill the task after N seconds and mark it
`timed_out`. Omit for no timeout (runs until natural exit). `timed_out`. Omit for no timeout (runs until natural exit).
* `wait_seconds` — inline poll before returning (capped at 30). * `wait_seconds` — inline poll before returning (capped at 30).
When the task finishes within the window the full status is 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=<id>` the task keeps running and the normal `task started: id=<id>`
response is returned. **Defaults to 3** — pass `wait_seconds: 0` response is returned. **Defaults to 3** — pass `wait_seconds: 0`
to disable inline waiting and always get the immediate response. to disable inline waiting and always get the immediate response.
* `name` — optional caller-chosen task id. When set it replaces the * `name` — optional caller-chosen task id. When set it replaces the
auto-generated hex id, so it surfaces in the wake `from` auto-generated hex id, so it surfaces in `status(<name>)` lookups and
(`bash-task-<name>`), in `status(<name>)` lookups, and in the the loose-ends list — a memorable label instead of an opaque id. A name
loose-ends list — a memorable label instead of an opaque id. A name
is **reusable once its previous task has finished**; submitting a is **reusable once its previous task has finished**; submitting a
name whose task is still `pending`/`running` is rejected. Allowed name whose task is still `pending`/`running` is rejected. Allowed
characters: ASCII letters, digits, `.`, `_`, `-` (max 64). Omit for 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 Useful to avoid a separate round-trip when the task is expected to
finish soon. finish soon.
Any `status` call (waited or not) that observes a terminal task Any `status` call (waited or not) that observes a terminal task clears
suppresses that task's completion wake — you already have the result that task's completion todo — you already have the result in this
in this response, so no redundant `bash-task-<id>` inbox message response, so no redundant loose-end follows. Narrow best-effort race: a
follows (#2270). Narrow best-effort race: a `status`/`run` inline wait `status`/`run` inline wait that resolves in the same instant the task
that resolves in the same instant the task actually finishes can still actually finishes can still occasionally get both.
occasionally get both.
Tasks marked `interrupted` had their process killed by a harness 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. silently blocked.
Exposed as `mcp__bash__status`. Exposed as `mcp__bash__status`.
@ -68,8 +67,8 @@ Exposed as `mcp__bash__status`.
### `kill(id, force?)` ### `kill(id, force?)`
Stop a running or pending task by its ID (from `run`). Fire-and-forget: Stop a running or pending task by its ID (from `run`). Fire-and-forget:
sends the signal and returns without waiting — handle the completion sends the signal and returns without waiting — the completion surfaces
wake (`from: "bash-task-<id>"`) on a future turn. in the agent's loose-ends; handle it on a future turn.
- `force: false` (default) — SIGINT to the task's **process group** - `force: false` (default) — SIGINT to the task's **process group**
(graceful; lets the process clean up). The whole process group is (graceful; lets the process clean up). The whole process group is
@ -79,7 +78,7 @@ wake (`from: "bash-task-<id>"`) on a future turn.
If a SIGINT'd task doesn't exit, call `kill` again with `force: true`. 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 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`. Exposed as `mcp__bash__kill`.
@ -97,7 +96,7 @@ matrix MCP:
- **`hive-bash-daemon`** — long-running process (one per agent container, - **`hive-bash-daemon`** — long-running process (one per agent container,
systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management, 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. Listens on `/run/hive-bash/socket` inside the container.
- **`hive-bash-mcp`** — stdio bridge spawned by claude per turn (declared - **`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 This split keeps claude's turn-local MCP bridge lightweight while the
daemon tracks long-running tasks that outlive a single turn. 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 When a bash task changes state, `hive-bash-daemon` upserts a single keyed
the agent's per-agent socket as a **transient wake** (`AgentRequest::Wake` todo (`key = task id`) on the harness's in-agent socket (`HIVE_AGENT_SOCKET`)
with `transient: true`). This bypasses broker sqlite for lower latency — — "running" at start, then the completion summary when it finishes. The
the same mechanism used by matrix events. The message is delivered summary change signals the harness turn loop directly (in-process, no broker
directly to the harness without touching the persistent message store. 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 ## Relationship to the `execution` tool group

View file

@ -90,17 +90,16 @@ returned by `get_loose_ends` so unread rooms surface in the
loose-ends list between turns. loose-ends list between turns.
**Invite wakes**: when the daemon's sync loop receives an **Invite wakes**: when the daemon's sync loop receives an
`m.room.member` invite event, it writes the invite to `m.room.member` invite event, it upserts a todo (keyed `invite:<room>`)
`mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon on the harness's in-agent socket, which drives a turn. The daemon does
does **not** auto-join — the agent calls `list_invites` to see pending **not** auto-join — the agent calls `list_invites` to see pending
invites and `resolve_invite` to accept or reject them. invites and `resolve_invite` to accept or reject them.
**Pending invites as loose ends**: pending invites are written to **Pending invites as loose ends**: pending invites are upserted as
`mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as keyed todos and appear in `get_loose_ends` output as
`[matrix] pending invite: <room> — use list_invites to see, `[matrix] pending invite: <room> — use list_invites to see,
resolve_invite to accept or reject`. The file is updated atomically resolve_invite to accept or reject`. The keyed todo is cleared when a
after each invite event and after each `resolve_invite` (or `resolve_invite` (or `join_room`) call resolves the invite.
`join_room`) call clears the entry.
See [`docs/matrix.md`](../matrix.md) for the homeserver setup, See [`docs/matrix.md`](../matrix.md) for the homeserver setup,
provisioning flow, and federation config. provisioning flow, and federation config.

View file

@ -8,7 +8,7 @@ Tools (hyperhive surface):
- (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. - (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.
- `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__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 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__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(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: "<name>"` 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: "<name>"` 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 <you>]` 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__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 — 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__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__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.

View file

@ -125,7 +125,7 @@ fn render_bash_run(id: &str, resp: Result<DaemonResponse>) -> String {
/// immediately re-waiting on the same task. /// 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 \ 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 \ 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 /// Turn a `DaemonResponse` from a `BashStatus` call into the string
/// claude sees as the tool result. When `waited` is set (the call /// claude sees as the tool result. When `waited` is set (the call
@ -154,7 +154,7 @@ fn render_bash_kill(resp: Result<DaemonResponse>) -> String {
let sig = payload["signal"].as_str().unwrap_or("SIGINT"); let sig = payload["signal"].as_str().unwrap_or("SIGINT");
format!( format!(
"task `{id}`: {sig} sent to its process group; it transitions to `killed` \ "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 { } else {
format!("task `{id}` was pending — cancelled before it started.") format!("task `{id}` was pending — cancelled before it started.")
@ -180,17 +180,16 @@ struct BashRunArgs {
timeout_secs: Option<u64>, timeout_secs: Option<u64>,
/// Optional inline wait: `run` polls for up to `wait_seconds` /// Optional inline wait: `run` polls for up to `wait_seconds`
/// (capped at 30) before returning. When the task finishes within the /// (capped at 30) before returning. When the task finishes within the
/// window the full status is returned immediately and no wake is fired; /// window the full status is returned immediately (nothing to handle
/// when the timeout expires the task keeps running and the normal /// later); when the timeout expires the task keeps running and the
/// `task started: id=<id>` response is returned. Defaults to 3s. Pass /// normal `task started: id=<id>` response is returned. Defaults to 3s.
/// `0` to disable inline waiting and always get the immediate response. /// Pass `0` to disable inline waiting and always get the immediate response.
#[serde(default = "default_wait")] #[serde(default = "default_wait")]
wait_seconds: Option<u64>, wait_seconds: Option<u64>,
/// Optional task name. When set it becomes the task id, so it appears /// Optional task name. When set it becomes the task id, so it appears
/// in the completion wake (`from: "bash-task-<name>"`), in `status` /// in `status` lookups and your loose-ends list — handy for recognising
/// lookups, and in the loose-ends list — handy for recognising a task /// a task later instead of an opaque hex id. A name can be reused once
/// later instead of an opaque hex id. A name can be reused once its /// its previous task has finished; reusing a name whose task is still
/// previous task has finished; reusing a name whose task is still
/// running is rejected. Allowed chars: ASCII letters, digits, `.`, /// running is rejected. Allowed chars: ASCII letters, digits, `.`,
/// `_`, `-` (max 64). Omit to get the auto-generated id. /// `_`, `-` (max 64). Omit to get the auto-generated id.
#[serde(default)] #[serde(default)]
@ -237,21 +236,18 @@ struct BashMcp;
impl BashMcp { impl BashMcp {
#[tool( #[tool(
description = "Run a shell command in the background. Returns a task ID immediately — \ 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 \ do NOT wait inline. When the command finishes it surfaces as a todo in your \
`from: \"bash-task-<id>\"` carrying the exit status plus a `Read(<path>)` \ loose-ends (the exit status plus a `Read(<path>)` pointer to the captured \
pointer to the captured `.out`/`.err` files (not the output text itself \ `.out`/`.err` files read only what you need); handle it on a future turn. Use \
read only what you need); handle it on a future turn. Use `status` to poll the task status within \ `status` to poll within the same turn if needed. `timeout_secs` defaults to \
the same turn if needed. `timeout_secs` defaults to `None` (no timeout) \ `None` (no timeout) task runs until natural exit; pass a value to kill after \
task runs until natural exit; pass an explicit value to kill after N seconds. \ N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \
Pass `wait_seconds` (capped at 30) to wait inline for fast commands: when the \ if the task finishes within the window you get the full status immediately \
task finishes within the window the full status is returned immediately and no \ (nothing to handle later); otherwise it keeps running and you get \
wake is fired; when the timeout expires the task keeps running and the normal \ `task started: id=<id>`. Defaults to 3; pass `0` to disable inline waiting. \
`task started: id=<id>` response is returned. `wait_seconds` defaults to 3; \ Pass `name` to label the task with a memorable id (shown in `status` lookups \
pass `wait_seconds: 0` to disable inline waiting and always get the immediate \ and your loose-ends) instead of an opaque hex id; reusable once the prior task \
response. Pass `name` to label the task with a memorable id (used in the wake \ has finished, rejected while one is still running."
`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."
)] )]
async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String { async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
let req = DaemonRequest::BashRun { 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. \ 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 \ 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 \ 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 \ is cancelled before it starts. The task ends as `killed` and surfaces in your \
completion wake." loose-ends like any completion."
)] )]
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String { async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
let req = DaemonRequest::BashKill { let req = DaemonRequest::BashKill {