docs: sort the top-level directories into five sections by what the reader wants
Every docs directory moves under 1-getting-started, 2-handbook, 4-implementation or 5-project; the options reference (section 3) comes from the website and has no directory here. trust-boundary stays whole under the handbook for now. web-ui, swarm and tools split between handbook and implementation, and the command-line references move to 2-handbook/reference. Links, path references in code and nix, and the docs index follow the moves; no page content changes.
This commit is contained in:
parent
53ccbcb4b3
commit
6739bb903d
4 changed files with 736 additions and 701 deletions
259
docs/4-implementation/turn-loop/README.md
Normal file
259
docs/4-implementation/turn-loop/README.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# Turn loop + MCP
|
||||
|
||||
How the harness wakes up, what it asks the agent's runtime to do, and what
|
||||
tools the agent has access to in return.
|
||||
|
||||
## Runtimes
|
||||
|
||||
Every turn runs through `hive-runtime`, on the runtime
|
||||
`services.hyperhive.agent.runtime` selects:
|
||||
|
||||
- **`claude`** (default) — one `claude --print` process per turn.
|
||||
→ [claude-invocation.md](claude-invocation.md)
|
||||
- **`acp`** — any Agent Client Protocol agent, run as one long-lived child
|
||||
of the harness. → [`hive-runtime/README.md`](../../../hive-runtime/README.md)
|
||||
|
||||
Both report the turn in claude's `stream-json` shape, so the loop below is
|
||||
the same for either.
|
||||
|
||||
## The loop
|
||||
|
||||
Each agent harness (`hive-agent` — one serve-loop binary for all
|
||||
agents) runs:
|
||||
|
||||
0. Check the pause marker (`<harness>/paused`). While it exists the
|
||||
loop does nothing but re-stat it every 5 s — no broker poll, no
|
||||
claude process. Because step 1 is never reached, messages stay
|
||||
queued and unacked, so a resume drains the backlog instead of
|
||||
losing it; reminders and todo wakes buffer in their channels. Set
|
||||
it with `hivectl agent <name> pause` or the dashboard toggle; see
|
||||
[persistence](../../2-handbook/agent-lifecycle/persistence.md#-harnesspaused-per-agent).
|
||||
1. Long-poll `Recv` on its socket. The host-side broker
|
||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
||||
a pending message, otherwise waits up to 30 s for a broker `Sent`
|
||||
event for this recipient. After a failed `Recv` (a socket error, or
|
||||
a broker error, which comes back at once) the loop waits before the
|
||||
next one: 250 ms, doubling up to 30 s, and no wait again once a
|
||||
`Recv` succeeds. Todo wakes and local messages don't wait for it.
|
||||
2. Pop one message. Peek the remaining inbox depth with `Status`.
|
||||
3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE
|
||||
bus.
|
||||
4. Hand the wake prompt to the runtime: on `claude`, spawn
|
||||
`claude --print` and pipe it over stdin; on `acp`, send it as a
|
||||
prompt on the agent's session.
|
||||
5. Stream the turn's `stream-json` lines into the bus as
|
||||
`LiveEvent::Stream(value)`. Pump stderr as `Note`.
|
||||
6. Wait for the turn to end and classify its outcome from the
|
||||
stream + result — success, compaction, rate-limit, auth-failure, stall,
|
||||
or hard failure. The outcome drives the post-turn action (see
|
||||
[Turn outcomes](#turn-outcomes)); the session handles compaction
|
||||
internally (see
|
||||
[Compaction](claude-invocation.md#compaction)). This page describes
|
||||
rate-limit and auth-failure detection [below](#failure-detection-and-login-claude).
|
||||
7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid
|
||||
tight loops on transient failures.
|
||||
|
||||
### Failure detection and login (claude)
|
||||
|
||||
- **Rate limit** — a `429` / `rate_limit` marker on stderr, or a parsed
|
||||
`{"type":"error"}` rate-limit event on stdout (conversation-text
|
||||
mentions don't count), sets the `rate_limited` sentinel, parks for
|
||||
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries. The UI shows
|
||||
a `⊘ rate limited` badge while parked.
|
||||
- **Auth failure (401)** — `drive_turn` retries once (transient
|
||||
token-refresh races clear on retry); a second `AuthFailed` writes
|
||||
`{state_dir}/hyperhive-needs-login`, requeues the message, and parks in
|
||||
`wait_for_login` — the same path as a cold boot with no session. The
|
||||
operator re-auths via the per-agent web UI; the queued message then
|
||||
drives the next turn.
|
||||
- **Login detection** — both boot (`login::has_session`, Online vs
|
||||
NeedsLogin) and `wait_for_login`'s resume check key off the credential
|
||||
files in `login::CRED_FILE_NAMES` (the set `/logout` deletes).
|
||||
`wait_for_login` takes a `since: SystemTime` baseline (the instant of
|
||||
the 401 that parked it, or `login::NO_PRIOR_FAILURE` at cold boot) and
|
||||
resumes only once a credential file's mtime postdates it — so stale
|
||||
credentials already on disk at the 401 don't trigger an instant
|
||||
false-resume, and a login that lands *before* `wait_for_login` even
|
||||
starts polling still resumes correctly (baselining on a fixed instant
|
||||
rather than an entry-time directory snapshot is what closes that race).
|
||||
Leftover session-history files still don't read as a live session after
|
||||
a logout + container recreate (`has_session`/`is_cred_file` scope to
|
||||
`CRED_FILE_NAMES` either way).
|
||||
|
||||
## Harness binary shape
|
||||
|
||||
Two sibling binaries over one runtime library, all role-agnostic (there
|
||||
is one role: agent — the privilege boundary lives server-side at the
|
||||
broker socket (`/run/hive/mcp.sock`), which refuses privileged `Request`
|
||||
variants regardless of who sends them):
|
||||
|
||||
- `hive-agent` — long-running harness loop (the inbox poll +
|
||||
runtime turn + ack/requeue cycle described above).
|
||||
- `hive-runtime` — the library every turn runs through, one `Runtime`
|
||||
impl per runtime (`ClaudeRuntime`, `AcpRuntime`); `hive-subagent-mcp`
|
||||
drives its nested sessions through it too.
|
||||
- `hive-agent-mcp` — MCP server for the built-in `hyperhive` surface.
|
||||
Run with `--http <addr>` as a persistent streamable-HTTP daemon (the
|
||||
`hive-mcp-http` systemd unit, on `services.hyperhive.agent.mcp.httpPort`, default
|
||||
8790); claude connects to its URL via `--mcp-config`. HTTP is the sole
|
||||
transport — no per-turn stdio child (eliminates the re-registration
|
||||
race).
|
||||
|
||||
A small `Surface` trait, with one zero-sized impl, factors
|
||||
`hive-agent`'s wire types (`hive_core_agent_sock::{Request, Response}` —
|
||||
one unified enum shared by every agent socket) and its turn
|
||||
loop, so the loop itself has no per-role branches. See
|
||||
`hive-agent/src/main.rs`'s module
|
||||
doc for the trait shape.
|
||||
|
||||
### Boot wiring
|
||||
|
||||
`serve_main` reads `HIVE_PORT` (default `DEFAULT_WEB_PORT`) +
|
||||
`HIVE_LABEL` (default `"hive"` for standalone runs; the meta
|
||||
flake sets it unconditionally for any container-deployed agent;
|
||||
see `docs/5-project/process/conventions.md::Hive identity` for the env stack),
|
||||
opens turn-stats sqlite, prepares the on-boot files (see
|
||||
[claude-invocation](claude-invocation.md#on-boot-files)),
|
||||
installs claude plugins, spawns `web_ui::serve` + `vacuum::run`,
|
||||
and either drops into `serve_loop` directly (`Online`) or parks on
|
||||
the login flow first (`NeedsLogin`). `hive-forge-notify` polls forge
|
||||
notifications on its own, not this loop — see
|
||||
[`forge.md`](../../2-handbook/integrations/forge.md).
|
||||
|
||||
Boot also opens the todos store and the socket in-container producers
|
||||
dial. Matrix / bash / forge-notify daemons and the in-process
|
||||
`disk_watch` todo producer (low state-disk space) are the built-in
|
||||
producers, but the socket accepts any `subsystem` marker — a
|
||||
user-configured MCP server can push its own todos the same way. See
|
||||
[`docs/2-handbook/agent-lifecycle/persistence.md`](../../2-handbook/agent-lifecycle/persistence.md#state-dirs-per-agent) for
|
||||
what each built-in todo producer watches and how the store + `get_loose_ends`
|
||||
merge work.
|
||||
|
||||
Plugin install failures aren't fatal: the harness logs each at `warn` and skips it.
|
||||
|
||||
### Turn outcomes
|
||||
|
||||
`turn::TurnOutcome` (`Result<bool, TurnError>` — `Ok(compacted)` on success,
|
||||
else a `TurnError`) drives the post-claude branch:
|
||||
|
||||
| Outcome | Action |
|
||||
| --- | --- |
|
||||
| `Ok(_)` (`false` normal / `true` compacted) | `ack_turn` |
|
||||
| `Err(PromptTooLong)` | `drive_turn` archived the session (the lib already compacted + retried and it still overflowed); requeue inflight so the message redelivers into a fresh session that fits — no status park |
|
||||
| `Err(RateLimited)` | sleep `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), requeue inflight, status back to `online` |
|
||||
| `Err(AuthFailed)` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` |
|
||||
| `Err(SessionNotFound)` | resume + create self-heal both missed ("shouldn't happen"); requeue inflight so the next turn creates fresh — no status park, message not dropped |
|
||||
| `Err(ApiStall)` | idle watchdog sent claude SIGINT after `HIVE_TURN_IDLE_SECS` (default 600) of output silence and claude exited; sleep `HIVE_STALL_SLEEP_SECS` (default 60), requeue inflight, status back to `online` |
|
||||
| `Err(AgentStall(note))` | the same, for an ACP agent: after `HIVE_TURN_IDLE_SECS` with no `session/update` the harness sent the agent `session/cancel`, and SIGINT if it ignored that; `note` says which |
|
||||
| `Err(Failed(err))` | the harness logs it at `warn` as `turn failed`, the line the Grafana alert rule `hh-agent-turn-failed` (`nix/host-modules/swarm-grafana.nix`) fires on |
|
||||
|
||||
`ApiStall` catches an Anthropic API stall — a multi-retry connection storm where
|
||||
the stream goes silent for minutes. On `claude` the idle watchdog lives in
|
||||
`hive-claude`'s driver (`Config::idle_timeout`, enforced around `child.wait()`): the timer
|
||||
resets on every stdout line, so a large but still-streaming turn is never cut —
|
||||
only complete output silence for the window trips it. The harness sets the
|
||||
window from `HIVE_TURN_IDLE_SECS` (`0` disables) and maps the driver's
|
||||
`Error::IdleTimeout` onto `TurnError::ApiStall`. The watchdog sends claude's
|
||||
process group SIGINT, so claude stops the commands it started, and then
|
||||
waits for claude to exit. Nothing escalates to SIGKILL: a claude that
|
||||
ignores the SIGINT keeps the turn open until someone kills it or the unit
|
||||
stops.
|
||||
|
||||
`hive-runtime` applies the same window to an ACP agent's turns. That's
|
||||
also how a provider HTTP 429 ends an ACP turn when the agent retries it without
|
||||
reporting it: the turn goes silent until the watchdog stops it.
|
||||
|
||||
After the outcome handler, the stats sink records a row. `handle_turn`
|
||||
reports the result to `serve_loop` via `TurnControl { auth_failed }` —
|
||||
on auth failure the loop parks in `wait_for_login`; otherwise it loops
|
||||
straight back to the idle wait (step 1). No same-turn
|
||||
self-continue mechanism exists: every multi-step continuation rides an
|
||||
external wake instead — a new inbox message, a `remind`, or an
|
||||
in-container todo wake (bash-task completion, forge notification,
|
||||
matrix activity). Ending the turn and letting one of
|
||||
those drive the next one is strictly better than parking in-process:
|
||||
it checkpoints the session and observes wakes that only reach the
|
||||
harness between turns.
|
||||
|
||||
## Harness systemd unit shape
|
||||
|
||||
One harness serve binary (`hive-agent`, with its `hive-agent-mcp`
|
||||
sibling), one shared `nix/agent-modules/` tree, one service unit
|
||||
(`systemd.services.hive-agent`) for all agents. Privilege
|
||||
differences live in the agent's swarm permissions and the broker socket (which tools
|
||||
and manager-surface calls each agent receives).
|
||||
|
||||
`nix/templates/agent.nix` is the one entry point every agent builds from, and
|
||||
it imports the shared `nix/agent-modules/`.
|
||||
`services.hyperhive.agent.docs.enable` defaults to `false` for every agent,
|
||||
ruth included; an agent opts in from its own `agent.nix` (see
|
||||
[config.md](config.md), "Reference docs").
|
||||
|
||||
### Environment variables set on the unit
|
||||
|
||||
- `HOME = /home/<userName>` — systemd defaults `HOME` to `/` for
|
||||
services without `User=` set; with the per-agent user the harness
|
||||
needs the right home so claude finds its bind-mounted `~/.claude/`
|
||||
session dir.
|
||||
- `HIVE_STATIC_DIR = <mergedDist>` — `tower_http::ServeDir` root for
|
||||
the per-agent web UI; merged dist = agent default + every
|
||||
`services.hyperhive.agent.frontend.extraFiles` overlay.
|
||||
- `HIVE_ASSETS_DIR = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive"`
|
||||
(`nix/agent-modules/agent-service.nix:480`; `packages.assets` resolves
|
||||
to the `hyperhive-assets` derivation) — set directly on the unit,
|
||||
**not** via `environment.variables`, because the latter only
|
||||
populates `/etc/profile` which systemd services don't inherit.
|
||||
|
||||
### `PATH` setup (the wrapper-dir trick)
|
||||
|
||||
```nix
|
||||
path = [ "/run/wrappers" "/run/current-system/sw" ];
|
||||
```
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
`/run/wrappers` (not `/run/wrappers/bin`) comes first so setuid
|
||||
wrappers — notably `sudo` — resolve before bare nix-store binaries; see
|
||||
[`docs/5-project/process/gotchas.md`](../../5-project/process/gotchas.md) ("`systemd.services.*.path` appends
|
||||
`/bin` to every entry") for why the trailing `/bin` matters in
|
||||
general. It's load-bearing here because the harness runs as the
|
||||
per-agent user: without the wrapper dir on `PATH`, `sudo` resolves to
|
||||
the non-setuid nix-store binary and every
|
||||
`services.hyperhive.agent.user.passwordlessSudo` grant fails with "must be owned by
|
||||
uid 0 and have the setuid bit set."
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
### `serviceConfig` highlights
|
||||
|
||||
- `ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}"`
|
||||
(`nix/agent-modules/agent-service.nix:535`); `packages.hive-agent` is
|
||||
wired to the flake's `hyperhive.packages.<system>.hive-agent` output
|
||||
(`nix/agent-modules/packages.nix:8-22`) — same binary for every agent.
|
||||
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
|
||||
`User=`, autocleared on stop. The harness writes regenerated
|
||||
`claude-{mcp-config,settings,system-prompt}` files there
|
||||
(`paths::config_dir`). Deliberately separate from `/run/hive`, which
|
||||
the host bind-mounts in root-owned and which holds hive-c0re's
|
||||
`mcp.sock`.
|
||||
- `User = Group = userName` — drops root inside the container; sudo is
|
||||
the explicit escalation surface (`services.hyperhive.agent.user.passwordlessSudo`).
|
||||
|
||||
## Sub-pages
|
||||
|
||||
The rest lives alongside this page, in three topic files:
|
||||
|
||||
- **[claude-invocation.md](claude-invocation.md)** — how the harness
|
||||
spawns `claude --print` each turn, the two-pronged compaction (reactive +
|
||||
proactive), and the on-boot files it materialises (`--mcp-config`,
|
||||
`--system-prompt-file`).
|
||||
- **[config.md](config.md)** — the optional per-agent knobs the meta
|
||||
flake wires in (reference docs, icon, passwordless sudo, dashboard links,
|
||||
custom static files, connectivity overrides, claude plugins, cargo message
|
||||
filtering).
|
||||
- **[mcp.md](mcp.md)** — the MCP tool surface claude sees: core tools,
|
||||
permission-gated tools, self-wake, authoritative state, the tool envelope,
|
||||
and the built-in tool allowlist.
|
||||
|
||||
Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages
|
||||
describe present-state behaviour + wiring, not line-level mechanics.
|
||||
317
docs/4-implementation/turn-loop/claude-invocation.md
Normal file
317
docs/4-implementation/turn-loop/claude-invocation.md
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
# The claude invocation
|
||||
|
||||
How a turn runs on the `claude` runtime, the default
|
||||
(`services.hyperhive.agent.runtime`). An `acp` agent's turns:
|
||||
→ [`hive-runtime/README.md`](../../../hive-runtime/README.md).
|
||||
|
||||
```
|
||||
claude --print --verbose --output-format stream-json --model <name> \
|
||||
--effort <level> --resume <title> # or --name <title> on first use \
|
||||
--system-prompt-file /run/hive-config/claude-system-prompt.md \
|
||||
--mcp-config /run/hive-config/claude-mcp-config.json --strict-mcp-config \
|
||||
--tools <builtins> --allowedTools <builtins+mcp>
|
||||
# wake prompt piped over stdin
|
||||
```
|
||||
|
||||
**Crate split.** `hive-agent` runs every turn through `hive-runtime`,
|
||||
whose `ClaudeRuntime` is a pass-through to the external **`hive-claude`**
|
||||
crate (a crates.io dependency, not in this repo). `hive-claude` holds the
|
||||
generic subprocess mechanics — spawning `claude --print`, streaming +
|
||||
classifying stream-json, session lookup/archive, and the durable-session
|
||||
compaction loop (`hive_claude::{Claude, InfiniteSession, Attach,
|
||||
CompactionPolicy, PercentPolicy, Telemetry, Sink, SessionStore}`).
|
||||
`hive-agent`'s `turn` module is the
|
||||
hyperhive **policy layer** on top: it builds the per-turn config from
|
||||
the bus, bridges the output stream onto the event bus (`BusSink`), and
|
||||
owns the compaction / autoreset / retry decisions in `drive_turn`. The
|
||||
lib returns everything it parsed from a turn (usage, cost, context
|
||||
window, resolved model) as `Telemetry`, which the policy layer applies
|
||||
to the bus.
|
||||
|
||||
**Which `claude` binary.** The bare name `claude`, resolved off the
|
||||
harness unit's PATH. By default that's the `claude-code` in the agent's
|
||||
own nixpkgs (the meta flake's `nixpkgs` input) via
|
||||
`environment.systemPackages`. Since that's typically a release channel and
|
||||
this package moves fast, the operator can pin one per hive with
|
||||
`services.hyperhive.deploy.hive-controller.claudeCodePackage`. `hive-priv`
|
||||
writes its store path into the container's `/etc/hyperhive-hive-facts` as
|
||||
`HYPERHIVE_CLAUDE_CODE_PATH` before every start. `claude` on PATH is a wrapper
|
||||
that runs that build when the path exists, and the container's own
|
||||
`claude-code` when the variable is unset or the path no longer exists, saying
|
||||
so on stderr in the second case. A bump reaches an agent at its next container
|
||||
start, not at a rebuild. See docs/5-project/process/gotchas.md::`claude-code` is unfree.
|
||||
|
||||
Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
|
||||
(claude-code's canonical managed-settings path — precedence #1,
|
||||
read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix`
|
||||
from the `prompts/claude-settings.json` asset. `effortLevel` is
|
||||
deliberately not in that file — the `--effort` flag
|
||||
(`HIVE_DEFAULT_EFFORT` / the swarm's effort setting) controls effort
|
||||
live, which managed scope would otherwise lock.
|
||||
|
||||
The harness reads `<name>` from `Bus::model()` on each turn.
|
||||
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
|
||||
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
|
||||
back to an existing `/harness/hyperhive-model` file, then `"haiku"`, if
|
||||
unset). The swarm changes the model at runtime, not the web
|
||||
terminal: a model or effort the swarm sets for the agent replaces the
|
||||
`Bus` value from the next turn, and the harness doesn't persist it;
|
||||
clearing it goes back to the local value →
|
||||
[`swarm/inference.md`](../swarm/inference.md).
|
||||
|
||||
`harness_state::context_window_tokens(model)` looks up
|
||||
context-window size per-model. Resolution order (first
|
||||
match wins):
|
||||
|
||||
1. The harness's per-family defaults, matched when the family name
|
||||
is a case-insensitive substring of the active model name:
|
||||
haiku=200k, sonnet=1M, opus=1M.
|
||||
2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any
|
||||
other model (useful in dev / test).
|
||||
3. Hard fallback: `200_000`.
|
||||
|
||||
Once a turn completes, the window the model reports
|
||||
(`modelUsage.*.contextWindow`) replaces this value.
|
||||
|
||||
The effective window drives watermarks; the harness exposes it at
|
||||
runtime via `/api/state.context_window_tokens` so the UI can show a
|
||||
percentage-of-window ctx badge.
|
||||
|
||||
**Session identity — a constant title.** Every turn keys on one fixed,
|
||||
harness-owned session title (`turn::session_title()`, default
|
||||
`hive-session`, override `HIVE_SESSION_TITLE`). The durable
|
||||
`hive_claude::InfiniteSession` (built once by the serve loop via
|
||||
`turn::make_session`, then reused) `--resume <title>`s it; the _first_ use
|
||||
(bootstrap, post-archive, post-purge) misses and the session re-runs the
|
||||
same prompt once with `--name <title>` to mint it. That single self-heal
|
||||
rule is the whole identity system — there is **no** scraped session-id
|
||||
file. Because the
|
||||
title is constant, `/compact` and its post-compact retry provably target
|
||||
the same session (killing the old "compact ran on a different/empty
|
||||
session" bug), and a `choom` invocation in the same cwd can't hijack the
|
||||
context (it won't carry that title). claude stores sessions in
|
||||
`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently);
|
||||
`--name` writes the title into the file as a `custom-title` event, which
|
||||
is what `--resume <title>` resolves against. The harness never passes
|
||||
bare `--continue` (it resumes the _latest_ session in the cwd — the hijack
|
||||
vector). The managed settings at `/etc/claude-code/managed-settings.json`
|
||||
disable automemory and dynamic workflows (the `/workflows` feature):
|
||||
`disableWorkflows` keeps the `/workflows` machinery from spawning sub-runs
|
||||
that burn usage on the harness's autonomous turns. Claude's own native
|
||||
autocompact is **on**, at the widest window claude-code allows — see
|
||||
[Compaction](#compaction) below for why (it's a safety net, not the
|
||||
primary mechanism; hyperhive still owns that). `outputStyle` is fleet-wide
|
||||
**Concise** — output tokens are real spend on an always-on agent, and the
|
||||
preamble/recap Concise drops carries no information a turn-loop consumer
|
||||
needs.
|
||||
|
||||
**Session reset** is available via `POST /api/new-session` (the state
|
||||
badge's `new session` menu row). It does _not_ touch the session inline —
|
||||
that would race a mid-write claude process. Instead `Bus::request_session_reset()`
|
||||
sets a one-shot flag consumed at the next turn boundary by `drive_turn`,
|
||||
which **archives** the current session: it renames the backing
|
||||
`<uuid>.jsonl` to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl`
|
||||
resolution glob, history preserved on disk, only the file carrying _our_
|
||||
title — it leaves any `choom` session sharing the cwd alone). The next
|
||||
turn's `--resume <title>` then misses and self-heals into a fresh session.
|
||||
|
||||
## Compaction
|
||||
|
||||
hyperhive owns compaction as policy: the `hive_claude::InfiniteSession`
|
||||
keeps the session alive across the context window with two triggers baked
|
||||
into its `run`:
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
- **Reactive** — claude-code prints `Prompt is too long`. The session is
|
||||
_already_ past the window, so no turn can run on it — the session
|
||||
`/compact`s straight away and retries the same wake-up prompt once. No
|
||||
notes-checkpoint turn is possible here: the detail is gone. If the retry
|
||||
_still_ overflows, `run` surfaces `Error::PromptTooLong`; `drive_turn`
|
||||
then archives the session (session lifecycle stays hive-side) and the
|
||||
serve loop requeues the message so it redelivers into a fresh session
|
||||
(see [Turn outcomes](README.md#turn-outcomes) — the wake prompt itself is small, so
|
||||
the overflow was the accumulated context the archive clears).
|
||||
- **Proactive** — a turn finishes cleanly but the last inference's context
|
||||
size crossed the policy watermark. While the session is still healthy it
|
||||
runs one synthetic _notes-checkpoint_ turn (`CHECKPOINT_PROMPT` —
|
||||
"context is filling up, flush durable state into `/state` now") and
|
||||
_then_ `/compact`s, so the agent can persist in-flight state before the
|
||||
detail collapses into a summary.
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
**hive-agent checks both triggers above between its own turns** —
|
||||
`PercentPolicy::should_compact` reads the percent watermark from the
|
||||
last completed turn's telemetry (checked after `attempt()` returns), and
|
||||
the reactive path only fires once claude-code has already refused a whole
|
||||
turn as too long. Neither can see context growth happening _inside_ a
|
||||
single long turn's own tool-calling before it returns — a turn whose tool
|
||||
calls alone blow past the window hits the reactive path with no preceding
|
||||
checkpoint, losing whatever wasn't already persisted (a known, root-caused gap).
|
||||
|
||||
**Safety net for that gap: claude-code's own native autocompact is on**,
|
||||
via the managed settings (`"autoCompactEnabled": true`,
|
||||
`"autoCompactWindow": 1000000` — the CLI's documented max, so it fires only
|
||||
right before the real hard limit, not as an earlier watermark). This is
|
||||
deliberately _not_ the primary mechanism — it runs with no checkpoint and
|
||||
none of the `CHECKPOINT_PROMPT` notes-flush machinery above, so a session
|
||||
saved by it still loses whatever wasn't already durable — but it can act
|
||||
_mid-turn_, underneath `InfiniteSession::run`, which the two triggers above
|
||||
structurally can't. Don't confuse this with the `services.hyperhive.agent.autoCompact`
|
||||
nix option below: that one is a per-agent on/off for hyperhive's _own_
|
||||
proactive watermark and has no effect on claude's native mechanism, which
|
||||
is fleet-wide and set once in `hive-agent/prompts/claude-settings.json`.
|
||||
|
||||
The **when** for hyperhive's own policy is a `hive_claude::CompactionPolicy` injected by the harness:
|
||||
`turn::make_session` builds a `PercentPolicy` that compacts once the
|
||||
model-reported context fill reaches `HIVE_COMPACT_WATERMARK_PERCENT`
|
||||
(default **75%**), falling back to `harness_state::context_window_tokens(model)`
|
||||
for the window on turns the model didn't report one. `0` disables proactive
|
||||
compaction (the reactive path always applies). The proactive path is
|
||||
best-effort — a failed checkpoint or `/compact` never fails the turn that
|
||||
already succeeded. An ACP agent gets the same policy; how it compacts is in
|
||||
[`hive-runtime/README.md`](../../../hive-runtime/README.md#acp-backend-compaction).
|
||||
|
||||
The operator can force a compaction any time via `POST /api/compact`. It's
|
||||
**deferred**: the handler sets `Bus::request_compact()` and returns
|
||||
immediately; the harness runs the `/compact` at the next turn boundary
|
||||
(end of the in-flight turn in `drive_turn`, or — when the agent is idle —
|
||||
in `turn::run_pending_compact` on the serve loop's next empty poll). This
|
||||
lets `/api/compact` work mid-turn instead of only when idle, without racing
|
||||
a live claude process.
|
||||
|
||||
To disable proactive compaction for a specific agent, use the nix option:
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.autoCompact = false; # default true
|
||||
```
|
||||
|
||||
Setting `autoCompact = false` sets `HIVE_COMPACT_WATERMARK_TOKENS=0`, which
|
||||
the percent resolver still honours as a disable. Useful for large-context
|
||||
models (sonnet/opus) where the 75% heuristic fires before the session is
|
||||
actually full — the reactive path (compact-on-overflow at the hard limit)
|
||||
still applies.
|
||||
|
||||
- **Auto session-reset** — a third path (`turn::maybe_auto_reset`,
|
||||
pre-turn) that fires when both conditions hold: context is ≥ a watermark
|
||||
(`HIVE_AUTO_RESET_WATERMARK_TOKENS`, default **50% of
|
||||
`context_window_tokens(model)`**) AND the time since the last turn
|
||||
exceeds the assumed prompt-cache TTL (`HIVE_CACHE_TTL_SECS`, default
|
||||
`3600`). Claude's prompt cache goes cold after a while; once it's cold,
|
||||
`--resume`-ing a large session pays the full re-upload cost with no
|
||||
benefit over starting fresh. `drive_turn` **archives** the current
|
||||
session (same mechanism as the operator reset — rename `<uuid>.jsonl` →
|
||||
`.archived`) so the next turn's `--resume <title>` misses and starts
|
||||
fresh. Unlike proactive compaction, `drive_turn` drops the session
|
||||
entirely rather than compacting it — and _no_ preceding checkpoint turn runs, because any turn
|
||||
before the reset would just re-warm the cache and defeat the purpose.
|
||||
Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. Autoreset and the
|
||||
operator reset are mutually exclusive per turn (both archive → fresh
|
||||
turn), so an explicit operator reset short-circuits the heuristic.
|
||||
|
||||
The child runs with `cwd = /state` (when the bind exists; falls
|
||||
back to the parent's cwd in dev), so any relative path in a tool
|
||||
call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the
|
||||
agent's durable bind-mounted dir. AGENTS.md autoload walks
|
||||
upward from `/state` — drop a per-agent AGENTS.md there if you
|
||||
want long-term hints that survive destroy/recreate. A `CLAUDE.md`
|
||||
in `/state` or above it shadows `AGENTS.md`: claude then loads only
|
||||
the `CLAUDE.md` (see `docs/2-handbook/agent-lifecycle/persistence.md` for the
|
||||
boot-time rename).
|
||||
|
||||
The wake prompt is intentionally minimal: the popped message's
|
||||
`from`/`body`, prefixed with a `[msg #<id>]` broker-row-id marker
|
||||
(so the agent knows what id to pass to `ack_until` for bulk triage;
|
||||
transient pings show no marker because their sentinel id 0 has nothing
|
||||
to ack), plus an inline `({unread} more pending — drain via …)` hint
|
||||
when `unread > 0`. Claude drives any further `recv`/`send` itself via
|
||||
the embedded MCP server.
|
||||
|
||||
`hive-agent` self-announces once on every boot:
|
||||
`boot_announce::announce` pushes a one-line "you were just
|
||||
(re)started, check /state/ for your notes, your session is intact"
|
||||
todo the moment the in-agent todo store opens, before the serve loop
|
||||
starts. This replaced `hive-c0re`'s old `Coordinator::kick_agent`
|
||||
calls at the tail of the rebuild and start DAG nodes — those fired a
|
||||
broker message per DAG node, so one rebuild (always a real
|
||||
`StopForUpdate` → `Swap` → `RebuildBookkeeping` → `Reconcile` →
|
||||
`Start` cycle) queued two separate restart messages for a single
|
||||
actual boot. The harness itself is the one side that knows for
|
||||
certain whether a boot happened, so it dedups for free: exactly one
|
||||
todo per real process start, surfaced the normal todo-wake way
|
||||
rather than as an inbox message.
|
||||
|
||||
## On-boot files
|
||||
|
||||
`hive-agent`'s `turn` module writes two files into its own per-service
|
||||
runtime dir, `/run/hive-config/` (`RuntimeDirectory = "hive-config"`,
|
||||
deliberately separate from `/run/hive/`, the host-owned dir holding the
|
||||
per-agent socket — so the harness owns this write surface and nothing
|
||||
needs to `chown` a bind mount), once at startup:
|
||||
|
||||
- `claude-mcp-config.json` — points claude at the persistent
|
||||
`hive-mcp-http` daemon (`http://127.0.0.1:{port}/mcp`, port from
|
||||
`services.hyperhive.agent.mcp.httpPort`, default 8790) for the built-in `hyperhive`
|
||||
surface. HTTP is the sole transport for it — no per-turn stdio child,
|
||||
so the URL survives the per-turn claude re-spawn (no re-registration
|
||||
race), trading that for a hard dependency on the daemon's uptime
|
||||
(`Restart=always`, no stdio fallback). Extra servers stay stdio.
|
||||
- `claude-system-prompt.md` — rendered from
|
||||
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`:
|
||||
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
|
||||
same for `role:manager`) gate the role-specific blocks; both flavors
|
||||
share everything else. `<!-- config:repo -->` and
|
||||
`<!-- config:archetype -->` blocks gate the config-repo paragraphs: an
|
||||
agent with `HIVE_ARCHETYPE` set (the meta flake sets it on an agent built
|
||||
from an [archetype](../../2-handbook/swarm/archetypes.md)) gets the archetype block and
|
||||
every other agent the repo block, with `{archetype}` filled in with the
|
||||
archetype's name. Five placeholders are then
|
||||
substituted: `{label}` (short agent name), `{qualified_label}`
|
||||
(hive-qualified `name@domain` form),
|
||||
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
|
||||
`HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same
|
||||
shape for the swarm), and `{operators}`: a paragraph listing the
|
||||
swarm's operators with the pronouns from each one's forge profile,
|
||||
for example `Operators: mara (she/her), sol.`. The harness reads
|
||||
them with one direct get on the one key of the `operator-profiles`
|
||||
KV bucket, which `swarm-controller` writes. The harness waits up to five seconds for
|
||||
them. When the bucket is empty or doesn't answer in time,
|
||||
`{operators}` is empty and the prompt names no operator. Either way
|
||||
the template tells agents to say "the operator" in third person. A
|
||||
pronoun change reaches an agent at its next harness start, since
|
||||
the prompt renders once per start.
|
||||
When you enable `services.hyperhive.agent.docs.enable`, `HIVE_DOCS_DIR` is present
|
||||
in the environment and `render()` appends a one-sentence pointer
|
||||
telling the agent where the docs live.
|
||||
Passed via `--system-prompt-file`.
|
||||
|
||||
**Marker grammar.** `<!-- KIND:X -->` opens a block; any
|
||||
`<!-- /KIND:X -->` closes the current block of that kind. The renderer
|
||||
filters the two kinds one after the other. The renderer always uses
|
||||
role `agent`, so it elides blocks with other role tags, and config `repo`
|
||||
or `archetype` as above. Nesting **isn't**
|
||||
supported — a stray opener with no closer runs until end of file.
|
||||
The renderer tolerates whitespace inside markers (`<!--role:foo-->` parses the
|
||||
same as `<!-- role:foo -->`). Content outside any marker is always
|
||||
included. Today's `system.md` carries no role markers (single agent
|
||||
role), and the role grammar stays wired for a future manager / multi-role
|
||||
prompt; it carries one pair of config blocks.
|
||||
|
||||
**`hive_identity` / `swarm_identity` shape.** Each carries a
|
||||
leading space + backticked name (` on hive \`pr1ma\``,
|
||||
` in swarm \`constellat1on\``) when the corresponding env var
|
||||
is set, otherwise empty string. The independence lets the
|
||||
template drop one or both into the opener prose without
|
||||
breaking single-hive deployments that never set the option;
|
||||
the renderer also treats `Some("")`from a caller as`None` so
|
||||
empty-string env vars and missing env vars round-trip the
|
||||
same way.
|
||||
|
||||
The per-turn plumbing described on this page — on-boot files, session
|
||||
identity, the reset/autoreset/retry state machine, and the
|
||||
telemetry-to-bus bridge — lives in `hive-agent`'s `turn` module; see its
|
||||
`//!` doc comment (`hive-agent/src/turn.rs`) for the exact call shape.
|
||||
The actual claude spawn, stream classification, and the
|
||||
reactive/proactive compaction loop are in the `hive-claude` crate, reached
|
||||
through `hive-runtime`'s `ClaudeRuntime`.
|
||||
Login-wait lives in `hive-agent`'s `login` module.
|
||||
348
docs/4-implementation/turn-loop/config.md
Normal file
348
docs/4-implementation/turn-loop/config.md
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
# Agent config knobs
|
||||
|
||||
Optional per-agent knobs, set under `services.hyperhive.agent.*` in the
|
||||
agent's own `agent.nix` and read at boot or per turn by the harness.
|
||||
Absent means the default. (The claude spawn + compaction themselves live in
|
||||
[claude-invocation](claude-invocation.md).)
|
||||
|
||||
## Reference docs (`services.hyperhive.agent.docs.enable`)
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.docs.enable = true; # default: false
|
||||
```
|
||||
|
||||
Makes the hyperhive `docs/` tree available inside the container at a nix
|
||||
store path read from `$HIVE_DOCS_DIR`, and injects a single pointer
|
||||
sentence into the agent's system prompt so it knows the docs exist and
|
||||
where to find them. `claude --add-dir` serves the tree so the full
|
||||
markdown is readable during every turn.
|
||||
|
||||
Off by default for every agent, ruth included. An agent opts in by adding the
|
||||
line above to its `agent.nix`.
|
||||
|
||||
The docs come from the pinned `hyperhive` input (the `reference-docs` package
|
||||
built from its `docs/` tree), so they match the hyperhive rev the host runs.
|
||||
|
||||
## Agent icon
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.icon = ./icon.svg; # default: null (falls back to shared hyperhive logo)
|
||||
```
|
||||
|
||||
Path to an SVG file used as this agent's visual identity — shown in
|
||||
the per-agent page header, as the page favicon, and uploaded to the
|
||||
agent's Forgejo profile avatar (via the `forge-avatar-sync` boot
|
||||
unit) and Matrix profile avatar (set by `hive-matrix-daemon` over its
|
||||
live Client). Commit the SVG next to `agent.nix` in the config repo
|
||||
and reference it as a relative path.
|
||||
|
||||
When `null` (the default), the agent falls back to the shared
|
||||
hyperhive branding mark. The harness serves whichever icon is active
|
||||
at `GET /icon` on the per-agent web port.
|
||||
|
||||
## `user.passwordlessSudo`
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.user.passwordlessSudo = true; # default
|
||||
```
|
||||
|
||||
Grants the per-agent unix user passwordless `sudo` (`NOPASSWD: ALL`).
|
||||
Enabled by default so claude's shell tools work for operations that
|
||||
need root inside the container (`systemctl`, package managers in dev
|
||||
shells, etc.) — the agent user gets root explicitly via `sudo` rather
|
||||
than running as root itself.
|
||||
|
||||
Set to `false` for agents that should be strictly unprivileged.
|
||||
Any tool invocation that needs root then fails loudly with the standard
|
||||
sudo rejection rather than silently succeeding — easier to audit.
|
||||
|
||||
`services.hyperhive.agent.user.name` is the unix user the harness and
|
||||
its co-process daemons run as inside the container (default `"agent"`
|
||||
for a standalone evaluation; the meta flake rebinds it to the agent's
|
||||
own name). `services.hyperhive.agent.user.uid` / `.gid` are optional
|
||||
fixed UID/GID (`null` default lets NixOS assign one automatically from
|
||||
the normal-user range) — set them only when something outside the
|
||||
container needs a stable numeric id across a full destroy + recreate.
|
||||
|
||||
## Dashboard links
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.dashboardLinks = [
|
||||
{ label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; }
|
||||
{ label = "Scratchpad"; url = "http://localhost:8080"; }
|
||||
];
|
||||
```
|
||||
|
||||
Declares extra navigation links that appear in the per-agent page
|
||||
header alongside the built-in forge / config / container links. Each
|
||||
entry has:
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------- | -------- | ----------------------------------------------------------------------------------------- |
|
||||
| `label` | yes | Display text shown in the icon strip tooltip and meta-nav. |
|
||||
| `url` | yes | Absolute URL — may include a different port (the dashboard renders it as a plain anchor). |
|
||||
| `icon` | no | Emoji or short glyph prefix. Defaults to empty string. |
|
||||
|
||||
A one-shot systemd unit writes the list to
|
||||
`<state>/hyperhive-dashboard-links.json` at container boot. The harness's own web UI
|
||||
(`agent_links` in `hive-agent/src/web_ui/state.rs`) reads the file on
|
||||
each `/api/state` snapshot and appends the entries to the per-agent
|
||||
nav as `kind = External` links — no `hive-c0re` / operator-dashboard
|
||||
involvement, and no code change needed to pick up a new entry.
|
||||
Omitting the option (default empty) produces no extra links.
|
||||
|
||||
## Custom static files
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.frontend.extraFiles = {
|
||||
"games/bitburner" = {
|
||||
source = ./bitburner-dist; # path relative to agent.nix
|
||||
# target defaults to attribute name: "games/bitburner"
|
||||
};
|
||||
"my-page" = {
|
||||
source = ./my-page.html;
|
||||
target = "my-page.html"; # explicit override
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Layers additional files over the default per-agent web UI dist. Each
|
||||
attribute defines one overlay entry:
|
||||
|
||||
- **`source`** — a Nix path (file or directory) copied into the merged
|
||||
static tree. Evaluated at nix build time; the resulting derivation is
|
||||
pointed at by `HIVE_STATIC_DIR`.
|
||||
- **`target`** — destination path within the merged tree, used as both
|
||||
the served URL prefix (`/<target>/…`) and the on-disk layout.
|
||||
Defaults to the attribute name. Forward slashes create nested layouts
|
||||
(`"games/bitburner"` serves at `/games/bitburner/…`).
|
||||
|
||||
Constraints: `target` must start with an alphanumeric or `_` and
|
||||
contain only alphanumerics, `_`, `.`, `/`, `-`. A config assertion
|
||||
rejects `..` segments. The merge step refuses to overwrite
|
||||
files already present in the default dist — pick a target name that
|
||||
doesn't collide with existing paths (`static/`, `index.html`, etc.).
|
||||
|
||||
The default dist ships at `services.hyperhive.agent.frontend.dist` (the
|
||||
`hyperhive-frontend` package output, read-only). To replace the
|
||||
entire UI rather than layer on top, override `frontend.dist` directly.
|
||||
|
||||
## Connectivity overrides
|
||||
|
||||
Two `services.hyperhive.agent.forge.*` / `services.hyperhive.agent.matrix.*` options override where
|
||||
the per-agent daemons connect. Both rarely need changing on a standard
|
||||
single-host deploy, but are useful for multi-hive or custom-network
|
||||
setups.
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: null
|
||||
services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null
|
||||
```
|
||||
|
||||
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes
|
||||
the git credential helper to this forge and is where `forge-avatar-sync`
|
||||
uploads the agent's icon. Override when the agent should connect to a
|
||||
Forgejo on a different host or port (for example a swarm peer's forge).
|
||||
Validated: must be an `http://` or `https://` URL, or `null`.
|
||||
|
||||
**Defaults to `null`, meaning "no forge" — not a guessed address.** A
|
||||
loopback default would only ever be correct when the forge shares the
|
||||
agent's network namespace, and inside a container `localhost` is the
|
||||
agent itself, so the default was a value that built fine and then talked
|
||||
to the wrong machine. With `null` the credential helper and `forge-avatar-sync`
|
||||
units aren't generated at all: an absent integration rather than a
|
||||
misdirected one. You don't normally set this — hive-c0re renders the
|
||||
host's real forge URL into every agent, and refuses to write a meta
|
||||
flake without one, so `null` only survives where nix evaluates the
|
||||
agent modules outside a hive.
|
||||
|
||||
**`services.hyperhive.agent.matrix.url`** — homeserver URL used by
|
||||
`hive-matrix-daemon` when connecting via the matrix-sdk. hive-c0re
|
||||
writes it into every agent at deploy time as the gateway-routed
|
||||
`chat.<swarm-domain>` URL, so isolated agents can reach the homeserver.
|
||||
Override per-agent when an agent should talk to a different homeserver
|
||||
— for example a remote hive's tuwunel reached over a VPN, or an
|
||||
external Matrix server for a federation-only agent.
|
||||
|
||||
**Defaults to `null`, meaning "no matrix" — same reasoning as
|
||||
`forge.url` above** (a loopback default would resolve inside the
|
||||
agent's own netns to the agent itself, not the homeserver). With
|
||||
`null` the daemon has no homeserver and no-ops exactly as it does
|
||||
without a token. The hive only forwards `HIVE_MATRIX_URL` when it
|
||||
actually has a matrix vhost to name, so `null` survives where a hive
|
||||
runs no homeserver, or where nix evaluates the agent modules outside a
|
||||
hive.
|
||||
|
||||
## Claude Code plugins
|
||||
|
||||
The harness installs Claude Code plugins before the serve loop opens.
|
||||
Three per-agent `agent.nix` options control this:
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.claudeMarketplaces = [ # default
|
||||
"anthropics/claude-plugins-official"
|
||||
"${services.hyperhive.agent.packages.claude-plugins}" # hive's own local marketplace, registered as `hyperhive`
|
||||
];
|
||||
services.hyperhive.agent.claudePlugins = [ # default
|
||||
"skill-creator@claude-plugins-official"
|
||||
"base@hyperhive"
|
||||
];
|
||||
services.hyperhive.agent.claudePluginsAutoUpdate = false; # default
|
||||
```
|
||||
|
||||
- **`claudeMarketplaces`** — list of marketplace sources passed to
|
||||
`claude plugin marketplace add <source>`. Defaults to Anthropic's
|
||||
official marketplace plus hyperhive's own `claude-plugins` nix
|
||||
package (see `nix/packages/claude-plugins.nix`) — a local-path
|
||||
marketplace registered under the name `hyperhive`, so a hive-authored
|
||||
skill needs no forge repo or git remote to ship. Override or extend
|
||||
to add custom marketplaces. Idempotent — re-adding an existing
|
||||
source is a no-op.
|
||||
- **`claudePlugins`** — list of plugin specs passed to
|
||||
`claude plugin install <spec>`. The harness installs each spec on
|
||||
every boot, assuming `install` is idempotent; failures log a warning
|
||||
but don't abort boot. Boot waits for the marketplace adds, the optional
|
||||
update and the installs (serial `claude plugin` calls). The harness kills
|
||||
and logs any call that hasn't exited within 2 minutes, and caps the whole
|
||||
sequence at 4 minutes: after that the remaining calls fail at once with
|
||||
the same warning and the web UI starts. Defaults to Anthropic's
|
||||
`skill-creator` (so every
|
||||
agent can author, refine, and evaluate its own skills) plus
|
||||
hyperhive's own `base` plugin — skills that apply to every agent
|
||||
regardless of role (currently just `state-hygiene`) — all without
|
||||
any per-agent wiring.
|
||||
|
||||
> Both plugin lists follow ordinary NixOS list-option semantics: a
|
||||
> per-agent definition **replaces** the default, it doesn't extend it.
|
||||
> An agent that sets `claudePlugins` and still wants the defaults has
|
||||
> to list `skill-creator@claude-plugins-official` and `base@hyperhive`
|
||||
> explicitly alongside its own entries — likewise for the two default
|
||||
> entries in `claudeMarketplaces`.
|
||||
|
||||
- **`claudePluginsAutoUpdate`** — when `true`, runs
|
||||
`claude plugin marketplace update` before installing plugins to pull
|
||||
the latest index. Off by default to keep boot times short and
|
||||
plugin versions pinned.
|
||||
|
||||
## `cargo.shortMessages`
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.cargo.shortMessages = true; # default
|
||||
```
|
||||
|
||||
When enabled (the default), the harness injects a `cargo` shell
|
||||
function into `/etc/hyperhive/bash-env.sh` that transparently appends
|
||||
`--message-format short` to compile subcommands (`build`, `check`,
|
||||
`clippy`, `test`, `run`, `doc`, `bench`, `install`, `rustc`, `fix`).
|
||||
This suppresses the per-crate progress lines that flood the response
|
||||
window, leaving only warnings and errors.
|
||||
|
||||
The function handles `+toolchain` selectors (`cargo +nightly build`)
|
||||
and passes through cleanly when `--message-format` is already present.
|
||||
It leaves non-compile subcommands (`new`, `add`, third-party
|
||||
`cargo-*`) untouched.
|
||||
|
||||
Set to `false` for agents that parse cargo's JSON output
|
||||
programmatically and don't pass `--message-format json` themselves.
|
||||
|
||||
## API-key provider (`useApiKey` / `backendEnvironmentFile`)
|
||||
|
||||
```nix
|
||||
services.hyperhive.agent.useApiKey = true; # default: false
|
||||
services.hyperhive.agent.backendEnvironmentFile =
|
||||
"/agents/myagent/harness/openrouter.env"; # default: null
|
||||
services.hyperhive.agent.model = "anthropic/claude-3.5-sonnet"; # provider-specific model string
|
||||
```
|
||||
|
||||
Runs this agent's `claude` against an API-key provider (for example OpenRouter)
|
||||
instead of a Claude subscription via OAuth. Two options, paired — each
|
||||
is a no-op without the other:
|
||||
|
||||
- **`useApiKey`** tells the harness itself not to wait for a Claude OAuth
|
||||
session: at boot, `LoginState::from_dir` reports `Online` without
|
||||
checking `~/.claude/` (`hive_agent::login::using_api_key`, reads
|
||||
`HIVE_USE_API_KEY`), and the harness stamps the fact into the
|
||||
consolidated harness state file so the operator dashboard also stops reading this
|
||||
agent's empty `~/.claude/` as "needs login." An api-key agent that hits
|
||||
a real 401 (the key itself is bad) still surfaces `needs_login` — the
|
||||
harness skips only the boot-time check for an earlier login, not
|
||||
the auth-failure path.
|
||||
- **`backendEnvironmentFile`** points at an operator-managed file
|
||||
(outside the nix store, one `KEY=value` per line, systemd
|
||||
`EnvironmentFile` syntax) supplying the credentials `claude` itself
|
||||
reads from the environment — typically `ANTHROPIC_API_KEY` and
|
||||
`ANTHROPIC_BASE_URL`. Loaded as an _optional_ `EnvironmentFile`
|
||||
(leading `-`), so setting the option before the file exists doesn't
|
||||
strand the harness at boot. With `useApiKey` set, the subagent daemon
|
||||
(`hive-subagent-daemon`) loads the same file, so the agent's claude
|
||||
subagents run on the same key.
|
||||
|
||||
On an agent with a secret store (`services.hyperhive.agent.bao.addr`) that uses
|
||||
`useApiKey` or the `acp` runtime, `hive-agent-backend-env.service` also keeps the
|
||||
file's text in the store at `swarm/agents/<agent>/backend-env`, field `env`, and
|
||||
writes it to `/run/hive-agent-backend-env/env` every 2 minutes. When the store
|
||||
holds nothing there, the unit creates the leaf from `backendEnvironmentFile`,
|
||||
byte for byte and once; it never rotates a value and never rewrites or deletes
|
||||
the file. The harness and the subagent daemon load `backendEnvironmentFile`
|
||||
first and the `/run` file after it, so a variable the store holds wins, and with
|
||||
the store down at a start the file alone carries the agent. Both read it at
|
||||
their own start, so a changed value reaches a running agent at its next
|
||||
restart.
|
||||
|
||||
To set or replace a key, use **set the API-key environment** on the agent in the swarm UI:
|
||||
it sends `PUT /api/hives/{hive}/agents/{agent}/backend-env` with the file text,
|
||||
which must be blank lines, `#` or `;` comments, and `NAME=VALUE` lines of at
|
||||
most 16 KiB. A save overwrites the stored text, including text the agent wrote
|
||||
into an empty leaf, and the store's value wins over the file. The box never
|
||||
shows the stored text. **Remove** (`DELETE` on the same path) deletes the stored
|
||||
text. An agent whose file has content creates the leaf from it again within 2
|
||||
minutes; one without a file keeps its copy in `/run` until the container
|
||||
restarts.
|
||||
|
||||
A save also keeps the host of `ANTHROPIC_BASE_URL` (and of every `*_BASE_URL`
|
||||
variable) as an automatic allowed-domain entry for the agent, so a restricted
|
||||
agent reaches its provider through the hive's proxy; **Remove** drops it. The
|
||||
controller derives only from an operator's save, never from text it finds in the
|
||||
store, so an environment saved before this existed needs one more save. An
|
||||
agent that has just the file gets no entry, and an operator adds its
|
||||
provider's host in the swarm UI (see
|
||||
[allowed domains](../../2-handbook/swarm/ui.md#allowed-domains)).
|
||||
|
||||
Provision the file once, out of band (never through nix — an API key in
|
||||
the store is world-readable and travels with the flake closure):
|
||||
|
||||
```sh
|
||||
# on the host, once per agent that should use an api-key backend
|
||||
sudo install -m 0600 -o root /dev/stdin \
|
||||
/var/lib/hyperhive/agents/<name>/harness/openrouter.env <<KEYS
|
||||
ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
|
||||
ANTHROPIC_API_KEY=sk-or-...
|
||||
KEYS
|
||||
```
|
||||
|
||||
The file lives in the agent's bind-mounted **harness** dir, not `state/`
|
||||
— `harness/` survives container rebuilds exactly like `state/` does, but
|
||||
(unlike `state/`) is never bind-mounted into a parent agent's container
|
||||
(see `docs/2-handbook/agent-lifecycle/persistence.md`'s "Parent access to child state"), so an API
|
||||
key placed here is reachable by nothing but this agent and the host. The
|
||||
file doesn't stay root-owned, though: the container's activation chowns the
|
||||
whole harness dir to the agent, so after the next boot the file is agent-owned
|
||||
whatever owner you installed it with, and the agent can rewrite it. That holds
|
||||
until the swarm secret store replaces the file.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
⚠️ Verified end-to-end against OpenRouter hasn't happened as of this
|
||||
writing — `ANTHROPIC_BASE_URL` support in the shipped Claude CLI is
|
||||
documented behavior, not something this hive has run a live turn
|
||||
against yet. Tool use, streaming, and MCP all need to keep working
|
||||
through a non-Anthropic base URL; treat the first real agent on this
|
||||
path as the actual verification, not this doc.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
Switching an already-provisioned OAuth agent to `useApiKey` leaves
|
||||
`~/.claude/credentials.json` in place but unused — harmless, not
|
||||
cleaned up automatically. Cost shape also changes: subscription pricing
|
||||
→ per-request billing with no built-in monthly cap, worth knowing before
|
||||
pointing a busy agent at a metered provider.
|
||||
240
docs/4-implementation/turn-loop/mcp.md
Normal file
240
docs/4-implementation/turn-loop/mcp.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# MCP surface
|
||||
|
||||
The harness ships an embedded MCP server (rmcp 2). A persistent
|
||||
`hive-mcp-http` daemon serves the built-in `hyperhive` surface over
|
||||
streamable HTTP (loopback, `127.0.0.1:<services.hyperhive.agent.mcp.httpPort>`,
|
||||
per-container private netns). Claude connects to its stable URL via
|
||||
`--mcp-config` rather than respawning a stdio child each turn, so the
|
||||
URL survives the per-turn claude re-spawn (and a host-side hive-c0re
|
||||
restart) — there is no per-turn MCP re-registration race for the
|
||||
built-in surface. HTTP is the sole transport for it (no stdio fallback).
|
||||
Extra servers (`services.hyperhive.agent.extraMcpServers`) pick their own transport per
|
||||
entry (`type = "stdio" | "http"`, default `"stdio"`): `matrix` and
|
||||
`subagent` each run their own persistent streamable-http listener
|
||||
(`hive-matrix-daemon`, `hive-subagent-daemon`), same reasoning as the
|
||||
built-in surface and `bash`'s `hive-bash-daemon`. The GUI bridge
|
||||
(`screen`, gated on `services.hyperhive.agent.gui.enable`) is the one
|
||||
entry on the schema's stdio default — it spawns
|
||||
`hive-screen-mcp` fresh each turn. `rea` (gated on
|
||||
`services.hyperhive.agent.rea.enable`) is a second stdio entry, spawning `rea mcp`. The server name is `hyperhive`, so
|
||||
the tools land in claude as
|
||||
`mcp__hyperhive__<tool>`. Each entry also has an `availableToSubagents`
|
||||
toggle (default `false`) — see
|
||||
[`docs/2-handbook/tools/subagent.md`](../../2-handbook/tools/subagent.md#mcp-servers-available-to-a-subagent)
|
||||
for what reaches a subagent's own `--mcp-config` and what doesn't.
|
||||
An `"http"` entry that needs request headers (a bearer-authenticated remote
|
||||
server) sets `headersFile` to an absolute path: a file holding a JSON object
|
||||
of header name to value. It renders as claude's `headersHelper` (`cat` of the
|
||||
file), so the secret never enters the nix store, and claude re-reads a rotated token
|
||||
on its next reconnect. ACP agents don't read it.
|
||||
|
||||
The agent's permissions gate tool access
|
||||
([`docs/5-project/process/conventions.md#permissions`](../../5-project/process/conventions.md)).
|
||||
The default (`Permission::DEFAULT`) is `messaging`, `meta`, `inbox` and
|
||||
`execution`; `swarmctl agent permissions set` changes it, and the next turn
|
||||
runs on the change.
|
||||
|
||||
## Core tools (always available)
|
||||
|
||||
**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`,
|
||||
`recv(max?)`, `ack_until(up_to)`.
|
||||
|
||||
- `send` — hive-local broker message to a peer on this hive (logical name). With
|
||||
the matrix MCP tools, talk to peers over matrix instead (a DM to the `user_id` in
|
||||
`get_agent_meta`'s `matrix_accounts`); `send` is for peers without a matrix
|
||||
account and agents without matrix tools. Optional
|
||||
`in_reply_to: i64` links the message to a prior id for thread
|
||||
rendering. Per-agent `services.hyperhive.agent.allowedRecipients`
|
||||
(default: empty = unrestricted) limits which names `send` accepts —
|
||||
useful for sandboxing. The broker refuses `to: "operator"`. The error
|
||||
points an agent that has the matrix MCP tools at matrix
|
||||
([matrix](../../2-handbook/tools/matrix.md)), and tells one without them that the
|
||||
operator reads its `set_status` line, its turn output on the dashboard,
|
||||
and its logs.
|
||||
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
|
||||
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
|
||||
with `[msg #<id>]` (broker row id; note the highest id seen, then pass
|
||||
it to `ack_until` to bulk-triage the batch). **Graceful shutdown**: when the harness
|
||||
receives a stop signal, the inbox becomes fenced and `recv` returns an
|
||||
explicit `from: "graceful-stop"` message instead of an empty inbox.
|
||||
This unmissably directs the agent to flush durable state (`/state`
|
||||
files) and end the turn — the container exits when the turn completes.
|
||||
The graceful-stop turn takes the same post-turn compaction path as any
|
||||
other turn: if the context crossed the watermark the harness runs a
|
||||
notes-checkpoint turn and then `/compact`. Compacting before shutdown
|
||||
keeps a later cold start cheap instead of re-uploading a large transcript.
|
||||
- `ack_until(up_to)` — bulk-mark inbox rows handled: it stamps every row
|
||||
with broker id `<= up_to` as acked in a single UPDATE.
|
||||
Recipient-scoped (agents can only ack their own rows). Use when a
|
||||
restart redelivers a large backlog of already-handled messages: read
|
||||
the highest `[msg #N]` from the set you've actually processed, then
|
||||
`ack_until(N)` to prevent re-pop. Acked rows never redeliver.
|
||||
Transient pings (sentinel id 0) have nothing to ack and show no marker.
|
||||
|
||||
**System messages** (from sender `system`): the broker delivers them as regular inbox
|
||||
messages (same `recv` path). A container crash never reaches an
|
||||
agent — it only produces a log line + swarm-level grafana
|
||||
alert, no agent turn at all (`docs/4-implementation/scheduler/observability.md`). Lifecycle
|
||||
transitions the job-queue scheduler or crash watcher drive directly —
|
||||
stop/kill, destroy, a flake-rev login or logout state change — reach
|
||||
no individual agent: they publish onto a swarm-wide NATS
|
||||
JetStream stream (`swarm_notices::notify`,
|
||||
`hive-c0re/src/swarm_notices.rs`); every hive is swarm-controlled.
|
||||
|
||||
**Inbox** (`inbox` group): `get_loose_ends()`,
|
||||
`cancel_loose_end(kind, id)`, `remind(message, delay_seconds? |
|
||||
at_unix_timestamp?)`.
|
||||
|
||||
- `get_loose_ends()` — list this agent's own open threads: pending
|
||||
reminders, open todos pushed by an in-container subsystem (matrix,
|
||||
bash, forge, or any user-configured MCP server), an undelivered-inbox
|
||||
count, and unread matrix notifications. Always the caller's own rows. Full per-variant wire shape in
|
||||
[`docs/5-project/process/conventions.md` § Loose-ends wire
|
||||
shape](../../5-project/process/conventions.md#loose-ends-wire-shape).
|
||||
- `cancel_loose_end` — hard-delete a `reminder` or clear a `todo` row
|
||||
(loose-ends-v2). Agents may only cancel rows they own.
|
||||
- `remind` — schedule a reminder in this agent's own inbox. Large
|
||||
payloads spill to `/agents/<self>/state/reminders/`. Pending count
|
||||
capped at 50 per agent (`HIVE_REMIND_MAX_PENDING_PER_AGENT`).
|
||||
|
||||
No same-turn self-continue tool exists — see
|
||||
[Turn outcomes](README.md#turn-outcomes) for why. Multi-step work rides
|
||||
`remind` for a durable self-wake, or an in-container todo wake
|
||||
(bash-task completion, forge notification, matrix activity) for work
|
||||
already in flight.
|
||||
|
||||
**Meta** (`meta` group): `get_agent_meta(name?)`.
|
||||
|
||||
- `get_agent_meta` — fetch identity + status metadata for an agent:
|
||||
`{ name, hyperhive_rev, running, status_text, status_set_at,
|
||||
hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
|
||||
list of matrix identities the agent can act as (`name`, `user_id?`,
|
||||
`homeserver`); omitted for agents with no matrix provisioning. Omit
|
||||
`name` to query self.
|
||||
|
||||
**Always-on, no permission needed**: `set_status(text)`, `compact()`,
|
||||
`mark_todos_done(ids)`.
|
||||
|
||||
- `set_status` — set a free-text status string visible on the
|
||||
dashboard. Single line, ≤ 200 chars. Persisted to
|
||||
`{state_dir}/hyperhive-status`. Pass `""` to clear. Not gated by the
|
||||
`meta` group — every agent needs to keep its dashboard status chip
|
||||
current regardless of which optional groups it holds.
|
||||
- `compact` — agent self-service equivalent of the operator dashboard's
|
||||
`/compact` button. No args. Gated server-side on the last completed
|
||||
turn's context usage: refused (with an explanation, no side effect)
|
||||
unless usage is above 66% of the effective context window. On a
|
||||
pass, queues the same deferred `compact_pending` flag the dashboard
|
||||
button sets — consumed at the end of the current turn, so it never
|
||||
races a live claude process, and the usual pre-compaction
|
||||
notes-checkpoint turn still fires first. Dispatched through the
|
||||
in-agent socket (`hive-agent-sock::Request::Compact`), not the
|
||||
broker — see `hive-agent/src/todo_server.rs`.
|
||||
- `mark_todos_done(ids)` — bulk-clear specific loose-ends-v2 todo rows
|
||||
by id in one call, instead of `cancel_loose_end`ing each one.
|
||||
List-based: pass the ids `get_loose_ends` actually showed you reviewed;
|
||||
unknown or already-done ids are silently skipped. Dials the same
|
||||
in-agent todo socket as `cancel_loose_end`'s `todo` kind.
|
||||
|
||||
## Privileged tools (by permission)
|
||||
|
||||
- **Bash execution** (`execution`) — background shell tasks. See
|
||||
[`docs/2-handbook/tools/bash.md`](../../2-handbook/tools/bash.md).
|
||||
- **Subagent spawning** — nested sessions on the agent's own runtime
|
||||
(claude or ACP, via `hive-runtime`) as background tasks, shipped
|
||||
default-on like bash execution; no permission gates it. See
|
||||
[`docs/2-handbook/tools/subagent.md`](../../2-handbook/tools/subagent.md).
|
||||
- **Config changes** — no permission: they go through a forge PR on
|
||||
`agent-configs/<name>` — see
|
||||
[`docs/2-handbook/agent-lifecycle/config-changes.md`](../../2-handbook/agent-lifecycle/config-changes.md).
|
||||
- **Web egress** (`web_tools`) — enables Claude's built-in `WebFetch`
|
||||
and `WebSearch` tools (not MCP tools; added directly to the
|
||||
`--allowedTools` list). Off by default.
|
||||
- **Recovery mounts** (`manage_root_agent`) — no tool: every agent's state
|
||||
and config mounted into this agent's container. See
|
||||
[`docs/5-project/process/conventions.md#permissions`](../../5-project/process/conventions.md).
|
||||
- **Matrix MCP + extra servers** — `mcp__matrix__*` tools and
|
||||
per-agent extra MCP config. See
|
||||
[`docs/2-handbook/tools/matrix.md`](../../2-handbook/tools/matrix.md).
|
||||
|
||||
## Waking the agent from inside the container
|
||||
|
||||
The built-in producers (matrix, bash, forge) upsert a todo on the
|
||||
harness's in-agent socket (`HIVE_AGENT_SOCKET`, `UpsertTodo` — see
|
||||
[`docs/2-handbook/tools/bash.md` § Completion as a todo
|
||||
(loose-ends v2)](../../2-handbook/tools/bash.md#completion-as-a-todo-loose-ends-v2)
|
||||
for the full upsert/signal/clear mechanism): a new or changed summary
|
||||
makes the harness signal its own turn loop, with no hive-c0re
|
||||
round-trip. An external MCP server (or any other in-container process)
|
||||
can push its own todo the same way — `subsystem` is any string.
|
||||
|
||||
The host-served per-agent socket at `/run/hive/mcp.sock` carries a
|
||||
lower-level `Wake` request (`hive_core_agent_sock::Request::Wake {
|
||||
from, body }` — JSON-line, `{"cmd":"wake","from":"matrix","body":"new dm
|
||||
from @alice"}\n`) that drops the body straight into the broker inbox as
|
||||
`{from:<label>, to:<agent>, body}`, bypassing the todo layer entirely.
|
||||
`hive-c0re` handles it; matrix, bash and forge don't call it. Identity = socket: anything that can connect to
|
||||
`/run/hive/mcp.sock` is implicitly trusted to inject one — the
|
||||
bind-mount is the agent's own container only.
|
||||
|
||||
## Authoritative state
|
||||
|
||||
`hive-agent`'s `events::Bus` carries the current turn-loop state in
|
||||
addition to the broadcast channel and the events history. Variants:
|
||||
|
||||
- `Idle` — sitting on `Recv` waiting for mail.
|
||||
- `Thinking` — `claude --print` is running for a turn.
|
||||
- `Compacting` — operator-triggered `/compact` is in flight.
|
||||
|
||||
The harness flips state at the relevant transitions
|
||||
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
|
||||
after; `set_state(Compacting)` around an idle operator compact in
|
||||
`turn::run_pending_compact`). Exposed via `/api/state.turn_state` +
|
||||
`turn_state_since` (unix seconds); the agent page renders this rather
|
||||
than deriving from SSE events.
|
||||
|
||||
## Tool envelope
|
||||
|
||||
`mcp::run_tool_envelope`: every MCP tool handler logs the request,
|
||||
runs the body, logs the result. Pre-/post-log only — the inbox
|
||||
status hint lives in the wake prompt + UI header, not here.
|
||||
|
||||
## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`)
|
||||
|
||||
The built-in list and the `--tools` value it resolves to live in
|
||||
`hive-sh4re` (the `Permission` enum itself is in `hive-types`), not in the
|
||||
harness, because the
|
||||
subagent daemon spawns its own `claude` and must resolve the same set —
|
||||
see [`docs/2-handbook/tools/subagent.md`](../../2-handbook/tools/subagent.md).
|
||||
|
||||
- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`.
|
||||
`Skill` is what makes an installed plugin's `SKILL.md` invokable —
|
||||
without it, a skill's `description` frontmatter never gets seen by
|
||||
the model no matter how well it matches the task.
|
||||
- Permission-gated built-ins: `WebFetch`, `WebSearch` (added once the
|
||||
agent holds `web_tools`).
|
||||
- Denied by omission (absent from the harness `--tools` /
|
||||
`--allowedTools`, so they "literally don't exist" in a harness turn):
|
||||
`Bash`, `Task`, `NotebookEdit`, `TodoWrite`.
|
||||
- Additionally in the managed-settings deny list
|
||||
(`/etc/claude-code/managed-settings.json`, un-overridable): `Task`,
|
||||
`TodoWrite`. `Bash` **isn't** in the managed deny — see below.
|
||||
- Allowed MCP tools: as listed above (by permission).
|
||||
|
||||
The autonomous harness disallows `Bash` — shell execution goes
|
||||
through `mcp__bash__run` (background tasks with structured output +
|
||||
task-id tracking) instead of an interactive shell. The harness gate is
|
||||
`--tools` / `--allowedTools` (Bash absent from `ALLOWED_BUILTIN_TOOLS`),
|
||||
so Bash never exists in a harness turn regardless of managed settings.
|
||||
`Bash` is deliberately **not** in the managed-settings deny so that the
|
||||
operator-driven `hivectl agent <name> choom` session — which passes neither `--tools`
|
||||
nor `--allowedTools` — gets claude's built-in synchronous `Bash` tool
|
||||
(inline, human-approved). That sidesteps the async `mcp__bash__run`
|
||||
completion wake landing in the wrong session (the harness inbox) for a
|
||||
choom-started task; `choom` is an operator action (root or `hive-admin`),
|
||||
so built-in shell there stays within the existing trust boundary. The bash MCP server
|
||||
(`run` / `status` / `kill`) uses `allowedTools = ["*"]` so all
|
||||
`mcp__bash__*` tools are available whenever the agent holds `execution`.
|
||||
|
||||
`WebFetch` / `WebSearch` are off by default; grant `web_tools`
|
||||
(`swarmctl agent permissions set`) to enable them from the next turn.
|
||||
Loading…
Reference in a new issue