Watch
0
0
Fork
You've already forked hyperhive
0

agents: remove the hive-level /model control; the swarm editor sets the model

The per-agent web UI no longer sets the model: the `/model` slash
command, the model picker, `POST /api/model`, `Bus::set_model` and the
`hyperhive-model` writer are gone. The model chip is display-only and
shows where the model comes from (`model_source` in `/api/state`). The
model is set on the swarm (swarm UI agents page, `swarmctl agent
inference set`).

The read side is unchanged: with no swarm value an agent runs its
agent.nix model, else an existing `hyperhive-model` file, else haiku.
Existing files are not touched.

With the picker gone, its model list goes too: `available_models` in
`/api/state`, the ACP model-list filter, and the harness's global
`HIVE_AVAILABLE_MODELS`. The subagent daemon keeps its own
`HIVE_AVAILABLE_MODELS` from mcp.nix, and the `availableModels`
assertion stays.

`/effort` (command, picker, route, file) is unchanged.

Refs #5034
This commit is contained in:
atlas 2026-10-10 23:06:15 +02:00
commit 594693f4c7
26 changed files with 244 additions and 425 deletions

1
Cargo.lock generated
View file

@ -1725,6 +1725,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower",
"tower-http 0.7.0", "tower-http 0.7.0",
"tracing", "tracing",
] ]

View file

@ -211,12 +211,12 @@ log row never blocks a rebuild.
### `/harness/hyperhive-model` (per agent) ### `/harness/hyperhive-model` (per agent)
Single-line text file holding the claude model name currently Single-line text file holding a claude model name. The harness only
selected for this agent (default `haiku` when absent). Written by reads it, at boot in `Bus::new` and again when the swarm clears the
`Bus::set_model` whenever the operator flips it via `/model model it set. It holds the model the agent runs when neither the swarm nor
<name>` in the web terminal. Read once at harness boot in `HIVE_DEFAULT_MODEL` (agent.nix) names one. Nothing in the harness
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`. writes it. Path overridable via `HYPERHIVE_MODEL_FILE`. Survives
Survives destroy/recreate, gone on `--purge`. destroy/recreate, gone on `--purge`.
### `/harness/paused` (per agent) ### `/harness/paused` (per agent)
@ -313,7 +313,7 @@ Under `/var/lib/hyperhive/agents/<name>/`:
tables above. tables above.
- `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 file, read-only fallback.
- `work/` — the agent's working space for git worktrees, build dirs - `work/` — the agent's working space for git worktrees, build dirs
and other large working data. Bind-mounted RW to and other large working data. Bind-mounted RW to
`/agents/<name>/work` inside the container and owned by the agent `/agents/<name>/work` inside the container and owned by the agent

View file

@ -20,14 +20,15 @@ swarmctl agent inference clear iris
agent's `availableModels` as the models its subagent `start` and agent's `availableModels` as the models its subagent `start` and
`continue` calls may name, read on each call `continue` calls may name, read on each call
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)). ([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
The web UI's model picker doesn't use it.
- `get` shows each field, naming the unset ones (or says the swarm sets - `get` shows each field, naming the unset ones (or says the swarm sets
nothing for the agent), and then the model and effort the agent reports nothing for the agent), and then the model and effort the agent reports
running on, each with where it comes from. running on, each with where it comes from.
- `clear` removes every field; the agent goes back to its own values. - `clear` removes every field; the agent goes back to its own values.
The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The
swarm UI's agents page edits the same settings; see [UI](ui.md). swarm UI's agents page edits the same settings; see [UI](ui.md). The
swarm is the only place to change an agent's model at runtime: its web UI
shows the model but has no picker and no `/model` command.
## What an agent runs on ## What an agent runs on
@ -47,11 +48,14 @@ Each running value names where it comes from:
| ------------ | -------------------------------------------------------------------------- | | ------------ | -------------------------------------------------------------------------- |
| `swarm` | the value set here | | `swarm` | the value set here |
| `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix | | `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix |
| `local_file` | a `/model` or `/effort` pick in the agent's web UI | | `local_file` | an `/effort` pick in the agent's web UI, or a `hyperhive-model` file |
| `default` | the harness's built-in default (`haiku`, `medium`) | | `default` | the harness's built-in default (`haiku`, `medium`) |
When the agent starts without a swarm value, its agent.nix model wins over When the agent starts without a swarm value, its agent.nix model wins over
its `/model` pick, and its `/effort` pick wins over its agent.nix effort. a `hyperhive-model` file, and its `/effort` pick wins over its agent.nix
effort. A `hyperhive-model` file comes from a runtime model pick the
agent's web UI no longer offers; the agent still reads it but never writes
it.
The agent reports these whenever they change, and again once a minute. The agent reports these whenever they change, and again once a minute.
swarm-controller keeps only what it has received since it started, so for up swarm-controller keeps only what it has received since it started, so for up
@ -63,13 +67,13 @@ Each agent reads its settings every 10 seconds and switches on the next
read: its next turn runs on the new model or effort, and its web UI badge read: its next turn runs on the new model or effort, and its web UI badge
shows it. A turn already running keeps what it started with. shows it. A turn already running keeps what it started with.
- **Its own value is the one it runs without the swarm**, from a `/model` - **Its own value is the one it runs without the swarm**: its agent.nix, an
or `/effort` pick in its web UI or from its agent.nix. Setting a swarm `/effort` pick in its web UI, or a `hyperhive-model` file. Setting a swarm
value doesn't overwrite it. value doesn't overwrite it.
- **Clearing a field** (or the whole key) puts the agent back on its own - **Clearing a field** (or the whole key) puts the agent back on its own
value on the next read. value on the next read.
- **A `/model` or `/effort` pick while the swarm sets that field** changes - **An `/effort` pick while the swarm sets effort** changes the agent's own
the agent's own value, but the swarm value comes back on the next read. value, but the swarm value comes back on the next read.
- **After a restart**, the agent runs its own values until its first read - **After a restart**, the agent runs its own values until its first read
of its settings succeeds. of its settings succeeds.

View file

@ -146,7 +146,7 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
Show or change the model, effort and subagent models the swarm sets for an agent. Show or change the model, effort and subagent models the swarm sets for an agent.
A field the swarm sets wins over the agent's own value (from its `/model` or `/effort` pick or its agent.nix) from its next turn on; the agent reads its settings every ten seconds. A field the swarm doesn't set, or clears, leaves the agent on its own value. A field the swarm sets wins over the agent's own value (its agent.nix, its `/effort` pick, or a `hyperhive-model` file) from its next turn on; the agent reads its settings every ten seconds. A field the swarm doesn't set, or clears, leaves the agent on its own value.
**Usage:** `swarmctl agent inference <COMMAND>` **Usage:** `swarmctl agent inference <COMMAND>`

View file

@ -50,13 +50,12 @@ live, which managed scope would otherwise lock.
The harness reads `<name>` from `Bus::model()` on each turn. The harness reads `<name>` from `Bus::model()` on each turn.
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial `services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
back to `"haiku"` if unset). The operator can flip it at runtime back to an existing `/harness/hyperhive-model` file, then `"haiku"`, if
with `/model <name>` in the web terminal — the next turn picks it unset). The swarm changes the model at runtime, not the web
up. The harness persists the choice to `/harness/hyperhive-model` so it terminal: a model or effort the swarm sets for the agent replaces the
survives restart; override path: `HYPERHIVE_MODEL_FILE` env var `Bus` value from the next turn, and the harness doesn't persist it;
for tests. A model or effort the swarm sets for the agent replaces the clearing it goes back to the local value →
`Bus` value, and the harness doesn't persist it; clearing it goes back to [`swarm/inference.md`](../swarm/inference.md).
the persisted or nix value → [`swarm/inference.md`](../swarm/inference.md).
`harness_state::context_window_tokens(model)` looks up `harness_state::context_window_tokens(model)` looks up
context-window size per-model. Resolution order (first context-window size per-model. Resolution order (first

View file

@ -47,15 +47,18 @@ at a given viewport width. Two columns:
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`. `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
- **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` / - **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix. `○ offline` / `… booting` + age suffix.
- **Model badge** (`model · <name> ▾`): a real picker — selecting it opens - **Model badge** (`model · <name>`): display only. Its tooltip says where
a `Dropdown` of `state.available_models`, selecting one POSTs the model comes from (`state.model_source`: set on the swarm, agent.nix,
`/api/model` immediately (same endpoint the `/model <name>` slash an existing `hyperhive-model` file, or the built-in default) and what the
command uses). A header badge: the control and its display sit last turn resolved it to. The model is set on the swarm: the swarm UI's
together. agents page, or `swarmctl agent inference set`
- **Effort badge** (`effort · <level> ▾`): same shape, `/api/effort`, ([`inference.md`](../swarm/inference.md)).
shown when `state.available_efforts` is non-empty. - **Effort badge** (`effort · <level> ▾`): a real picker — selecting it
- On an ACP agent both pickers list what its session offers (its opens a `Dropdown` of `state.available_efforts` and POSTs `/api/effort`
`model` and `thought_level` config options), and are empty until the immediately. Shown when `state.available_efforts` is non-empty.
- On an ACP agent the model badge shows the session's own model when it
doesn't offer the requested one, and the effort picker lists the levels
its session offers (its `thought_level` config option), empty until the
first turn has attached a session. The effort picker is hidden when the first turn has attached a session. The effort picker is hidden when the
model offers no effort levels, and while a newly picked model waits for model offers no effort levels, and while a newly picked model waits for
the next turn, since the levels come with the model. the next turn, since the levels come with the model.
@ -82,8 +85,8 @@ at a given viewport width. Two columns:
(`GET /api/state`'s `links` field) also feeds (`GET /api/state`'s `links` field) also feeds
`DashboardState.links` for the dashboard card's icon strip — `DashboardState.links` for the dashboard card's icon strip —
`agent_links()` in hive-agent is the single source of truth for `agent_links()` in hive-agent is the single source of truth for
both. The dashboard link lives in this 🔗 popover; the model/effort both. The dashboard link lives in this 🔗 popover; the effort picker
pickers are real badges above; and `/new-session` / `/logout` are is a real badge above; and `/new-session` / `/logout` are
typed slash commands with their own type-twice confirm (see typed slash commands with their own type-twice confirm (see
below). below).
- `/cancel` (slash command, below) is the only cancel-turn path; the - `/cancel` (slash command, below) is the only cancel-turn path; the
@ -234,10 +237,6 @@ Slash commands today:
runs the `/compact` at the next turn boundary (end of the in-flight runs the `/compact` at the next turn boundary (end of the in-flight
turn, or `turn::run_pending_compact` when idle); output streams into turn, or `turn::run_pending_compact` when idle); output streams into
the live panel. Works mid-turn, not only when idle. the live panel. Works mid-turn, not only when idle.
- `/model <name>` — `POST /api/model` flipping `Bus::set_model`.
Takes effect on the next turn; persisted to
`/state/hyperhive-model` so the override survives harness
restart / rebuild.
- `/effort <level>` — `POST /api/effort` setting the claude effort - `/effort <level>` — `POST /api/effort` setting the claude effort
level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next
turn. `StatusChips`' effort badge calls the same endpoint; both stay turn. `StatusChips`' effort badge calls the same endpoint; both stay
@ -276,17 +275,15 @@ shaped).
turn — only the stdin payload differs). Flips state to turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits `Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`. `TurnStateChanged`.
- `POST /api/model` (`model=<name>`) — switch the model for
future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/effort` (`effort=<level>`) — switch the claude - `POST /api/effort` (`effort=<level>`) — switch the claude
effort level for future sessions. Validated server-side against effort level for future sessions. Validated server-side against
`EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are `EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are
rejected rather than forwarded to `claude --effort`. Persists via rejected rather than forwarded to `claude --effort`. Persists via
`Bus::set_effort`, which emits `EffortChanged`. Applies on the next `Bus::set_effort`, which emits `EffortChanged`. Applies on the next
session start (no mid-session swap). session start (no mid-session swap).
- On an ACP agent, `/api/model` and `/api/effort` accept only values the - On an ACP agent, `/api/effort` accepts only values the session offers,
session offers, and the runtime sets them with `session/set_config_option` and the runtime sets the level with `session/set_config_option` before
before the next prompt. the next prompt.
- `POST /api/new-session` — arm a one-shot for the next turn to - `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`. Emits a `LiveEvent::Note`. drop `--continue`. Emits a `LiveEvent::Note`.
- `POST /api/logout` — three-step teardown that re-uses the - `POST /api/logout` — three-step teardown that re-uses the
@ -320,7 +317,7 @@ shaped).
the frontend (`useAgentState.ts`) on page load and, on a poll, while the frontend (`useAgentState.ts`) on page load and, on a poll, while
`status === 'needs_login_in_progress'`. Includes `turn_state`, `status === 'needs_login_in_progress'`. Includes `turn_state`,
`context_window_tokens`, `qualified_label`, `context_window_tokens`, `qualified_label`,
`available_models`, `effort`, `available_efforts`, `links`, and `model`, `model_source`, `effort`, `available_efforts`, `links`, and
other fields described inline throughout this document. All other fields described inline throughout this document. All
subsequent state updates arrive via SSE. subsequent state updates arrive via SSE.
- `GET /api/dashboard-state` — lean snapshot of agent-owned fields - `GET /api/dashboard-state` — lean snapshot of agent-owned fields
@ -392,7 +389,8 @@ Bus events (new vocabulary on `/events/stream`):
`needs_login_idle` / `needs_login_in_progress`. Drives the `needs_login_idle` / `needs_login_in_progress`. Drives the
alive-badge. The harness sets `rate_limited` when it detects a alive-badge. The harness sets `rate_limited` when it detects a
429 response and clears it when the retry sleep expires. 429 response and clears it when the retry sleep expires.
- `model_changed { model }` — drives the model chip. - `model_changed { model }` — drives the model chip. The harness emits it
when it applies or clears a swarm value.
- `effort_changed { effort }` — drives the effort picker chip. - `effort_changed { effort }` — drives the effort picker chip.
Emitted from `Bus::set_effort` on `POST /api/effort`; applies on Emitted from `Bus::set_effort` on `POST /api/effort`; applies on
the next session start. the next session start.

View file

@ -26,7 +26,7 @@ import { useTodos } from "./hooks/useTodos.js";
import { fmtAge, fmtTokens } from "./lib/format.js"; import { fmtAge, fmtTokens } from "./lib/format.js";
import { resolveDashboardBase } from "./lib/dashboardBase.js"; import { resolveDashboardBase } from "./lib/dashboardBase.js";
import { submitPauseResume } from "./lib/pauseAction.js"; import { submitPauseResume } from "./lib/pauseAction.js";
import { postModel, postEffort } from "./lib/modelEffort.js"; import { postEffort } from "./lib/modelEffort.js";
import { import {
postCancelTurn, postCancelTurn,
postLogout, postLogout,
@ -220,8 +220,6 @@ export function Root() {
statusLabel="… connecting" statusLabel="… connecting"
statusTone="neutral" statusTone="neutral"
model="" model=""
availableModels={[]}
onSelectModel={() => {}}
effort="" effort=""
availableEfforts={[]} availableEfforts={[]}
onSelectEffort={() => {}} onSelectEffort={() => {}}
@ -308,10 +306,7 @@ export function Root() {
statusTooltip={primaryStatus.tooltip} statusTooltip={primaryStatus.tooltip}
model={state.model} model={state.model}
resolvedModel={state.resolved_model ?? undefined} resolvedModel={state.resolved_model ?? undefined}
availableModels={state.available_models} modelSource={state.model_source}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
effort={state.effort} effort={state.effort}
availableEfforts={state.available_efforts} availableEfforts={state.available_efforts}
onSelectEffort={(level) => { onSelectEffort={(level) => {

View file

@ -13,8 +13,8 @@
/* mara, live, on a screenshot of this row: "badges and interactive /* mara, live, on a screenshot of this row: "badges and interactive
elements on same row have inconsistent size." Real cause: Badge.css's elements on same row have inconsistent size." Real cause: Badge.css's
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor `.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
(model/effort pickers, pause) while a plain display `<span>` badge (effort picker, pause) while a plain display `<span>` badge
(alive, state, ctx, cost — no `onClick`) stays compact, so the row (model, alive, state, ctx, cost — no `onClick`) stays compact, so the row
reads as two different pill sizes even though every badge here is reads as two different pill sizes even though every badge here is
the same `<Badge>` component. Scoped to this row rather than the same `<Badge>` component. Scoped to this row rather than
Badge.css itself — display-only badges elsewhere (e.g. the dashboard) Badge.css itself — display-only badges elsewhere (e.g. the dashboard)

View file

@ -1,6 +1,7 @@
// <StatusChips> — the agent page's status row: the consolidated status // <StatusChips> — the agent page's status row: the consolidated status
// badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in // badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in
// its dropdown), the model + effort pickers, and context/cost usage. // its dropdown), the model (display-only: the swarm sets it), the effort
// picker, and context/cost usage.
// This is the concrete fix for the design guide's own named anti-example // This is the concrete fix for the design guide's own named anti-example
// (the old page's model/effort *pickers* lived in the `⋯` overflow menu // (the old page's model/effort *pickers* lived in the `⋯` overflow menu
// while the current model/effort only showed as a disconnected chip) — // while the current model/effort only showed as a disconnected chip) —
@ -13,6 +14,7 @@
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import type { ModelSource } from "../types.js";
import "./StatusChips.css"; import "./StatusChips.css";
export interface StatusChipsProps { export interface StatusChipsProps {
@ -25,8 +27,8 @@ export interface StatusChipsProps {
* operator can tell what an alias resolved to. `undefined` before any * operator can tell what an alias resolved to. `undefined` before any
* turn has completed. */ * turn has completed. */
resolvedModel?: string; resolvedModel?: string;
availableModels: string[]; /** Where `model` comes from (`/api/state` `model_source`). */
onSelectModel: (name: string) => void; modelSource?: ModelSource;
effort: string; effort: string;
availableEfforts: string[]; availableEfforts: string[];
onSelectEffort: (level: string) => void; onSelectEffort: (level: string) => void;
@ -50,10 +52,11 @@ export interface StatusChipsProps {
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
} }
const MODEL_DESCRIPTIONS: Record<string, string> = { const SOURCE_LABEL: Record<ModelSource, string> = {
haiku: "fast", swarm: "set on the swarm",
sonnet: "balanced", agent_nix: "from agent.nix",
opus: "powerful", local_file: "from its own earlier pick",
default: "the built-in default",
}; };
const EFFORT_DESCRIPTIONS: Record<string, string> = { const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: "", low: "",
@ -235,8 +238,7 @@ export function StatusChips({
statusTooltip, statusTooltip,
model, model,
resolvedModel, resolvedModel,
availableModels, modelSource,
onSelectModel,
effort, effort,
availableEfforts, availableEfforts,
onSelectEffort, onSelectEffort,
@ -263,13 +265,17 @@ export function StatusChips({
onNewSession={onNewSession} onNewSession={onNewSession}
onLogout={onLogout} onLogout={onLogout}
/> />
<Picker <Badge
label="model" label="model"
value={model} value={model}
options={availableModels} title={
descriptions={MODEL_DESCRIPTIONS} [
onSelect={onSelectModel} modelSource ? SOURCE_LABEL[modelSource] : null,
title={resolvedModel ? `resolved to ${resolvedModel}` : undefined} resolvedModel ? `resolved to ${resolvedModel}` : null,
]
.filter(Boolean)
.join(" · ") || undefined
}
/> />
{availableEfforts.length ? ( {availableEfforts.length ? (
<Picker <Picker

View file

@ -11,7 +11,7 @@
// (a local note explains what confirming does), typing it again within // (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm. // the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { postModel, postEffort } from "../lib/modelEffort.js"; import { postEffort } from "../lib/modelEffort.js";
import { import {
postCancelTurn, postCancelTurn,
postCompact, postCompact,
@ -32,10 +32,6 @@ const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: "/clear", desc: "wipe the terminal panel (local-only)" }, { name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" }, { name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" }, { name: "/compact", desc: "compact the persistent claude session" },
{
name: "/model",
desc: "/model <name> — switch claude model for future turns",
},
{ {
name: "/effort", name: "/effort",
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns", desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
@ -131,16 +127,6 @@ export function TermInput({
); );
} }
return true; return true;
case "/model":
if (!arg)
onLocalNote(
"✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)",
);
else
postModel(arg).then(
(r) => !r.ok && reportFailure("/model", r.detail),
);
return true;
case "/effort": case "/effort":
if (!arg) if (!arg)
onLocalNote( onLocalNote(

View file

@ -1,4 +1,4 @@
// POST `/api/model` / `/api/effort` — same-origin (this agent's own // POST `/api/effort` — same-origin (this agent's own
// backend), so a plain `fetch` is safe unlike pause/resume (see // backend), so a plain `fetch` is safe unlike pause/resume (see
// pauseAction.ts). `redirect: 'manual'` + the broad "ok" check below is // pauseAction.ts). `redirect: 'manual'` + the broad "ok" check below is
// ported from app.js unchanged — the endpoints don't actually redirect // ported from app.js unchanged — the endpoints don't actually redirect
@ -34,6 +34,5 @@ async function post(
} }
} }
export const postModel = (name: string) => post("api/model", "model", name);
export const postEffort = (level: string) => export const postEffort = (level: string) =>
post("api/effort", "effort", level); post("api/effort", "effort", level);

View file

@ -6,7 +6,7 @@
// hive-c0re doesn't need to grow CORS headers for it — the tradeoff is // hive-c0re doesn't need to grow CORS headers for it — the tradeoff is
// an opaque, unreadable response, which is fine here since the caller // an opaque, unreadable response, which is fine here since the caller
// doesn't need to read it, only trigger a state refresh afterward (same // doesn't need to read it, only trigger a state refresh afterward (same
// shape as `postModel`/`postEffort` in modelEffort.ts). `credentials: // shape as `postEffort` in modelEffort.ts). `credentials:
// 'include'` matches the cookie-sending behavior of the plain <form> // 'include'` matches the cookie-sending behavior of the plain <form>
// submit this replaces. // submit this replaces.
// //

View file

@ -4,6 +4,9 @@
// today, and the frontend only ever reads a handful of these fields. // today, and the frontend only ever reads a handful of these fields.
// Extend as more of the page gets rebuilt against real data. // Extend as more of the page gets rebuilt against real data.
// Mirrors `swarm_queue_client::agent_inference::Source`.
export type ModelSource = "swarm" | "agent_nix" | "local_file" | "default";
export interface AgentState { export interface AgentState {
seq: number; seq: number;
label: string; label: string;
@ -24,7 +27,7 @@ export interface AgentState {
cost_usage: TokenUsage | null; cost_usage: TokenUsage | null;
hive_name: string | null; hive_name: string | null;
swarm_name: string | null; swarm_name: string | null;
available_models: string[]; model_source: ModelSource;
effort: string; effort: string;
available_efforts: string[]; available_efforts: string[];
paused: boolean; paused: boolean;

View file

@ -57,6 +57,7 @@ tracing.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
tokio = { workspace = true, features = ["test-util"] } tokio = { workspace = true, features = ["test-util"] }
tower = { version = "0.5", features = ["util"] }
# Single harness serve-loop binary: `hive-agent` (from `src/main.rs`). # Single harness serve-loop binary: `hive-agent` (from `src/main.rs`).
# The sibling MCP server is its own bin crate now (`hive-agent-mcp`). # The sibling MCP server is its own bin crate now (`hive-agent-mcp`).

View file

@ -21,7 +21,7 @@ use tokio::sync::broadcast;
use crate::harness_state::{ use crate::harness_state::{
configured_effort, configured_model, context_window_tokens, local_effort, local_model, configured_effort, configured_model, context_window_tokens, local_effort, local_model,
persist_effort, persist_model, read_harness_state, write_harness_state, persist_effort, read_harness_state, write_harness_state,
}; };
const CHANNEL_CAPACITY: usize = 256; const CHANNEL_CAPACITY: usize = 256;
@ -115,8 +115,8 @@ pub enum LiveEvent {
/// during the short-lived in-progress window — the client /// during the short-lived in-progress window — the client
/// re-fetches only while that flow is active. /// re-fetches only while that flow is active.
StatusChanged { status: String }, StatusChanged { status: String },
/// `/api/model` switched the active claude model. The web UI /// The active claude model changed (a swarm value applied or cleared).
/// updates the chip + the per-turn stats sink will key off this /// The web UI updates the chip + the per-turn stats sink will key off this
/// to mark the boundary in its log. /// to mark the boundary in its log.
ModelChanged { model: String }, ModelChanged { model: String },
/// `/api/effort` switched the active claude effort level. Applies on /// `/api/effort` switched the active claude effort level. Applies on
@ -282,8 +282,8 @@ pub struct Bus {
store: Option<Arc<EventStore>>, store: Option<Arc<EventStore>>,
/// Current turn-loop state + since-when (unix seconds). /// Current turn-loop state + since-when (unix seconds).
state: Arc<Mutex<(TurnState, i64)>>, state: Arc<Mutex<(TurnState, i64)>>,
/// Model name passed to `claude --model`. Default `haiku`; the /// Model name passed to `claude --model`. Default `haiku`; a value the
/// operator can override at runtime via `POST /api/model`. /// swarm sets replaces it at runtime (`crate::swarm_inference`).
model: Arc<Mutex<String>>, model: Arc<Mutex<String>>,
effort: Arc<Mutex<String>>, effort: Arc<Mutex<String>>,
/// Where `model` and `effort` come from, as the swarm is told in this /// Where `model` and `effort` come from, as the swarm is told in this
@ -341,8 +341,7 @@ pub struct Bus {
turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>, turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>,
/// The model and effort the session offers, for a runtime that reads them /// The model and effort the session offers, for a runtime that reads them
/// from its session (ACP). Set once by the serve loop when it builds the /// from its session (ACP). Set once by the serve loop when it builds the
/// session; unset on claude, whose pickers list the configured models and /// session; unset on claude, whose effort picker lists `EFFORT_LEVELS`.
/// `EFFORT_LEVELS`.
session_choices: Arc<OnceLock<hive_runtime::Choices>>, session_choices: Arc<OnceLock<hive_runtime::Choices>>,
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the /// Current fresh-claude-session id (FK to `sessions.id`). Set by the
/// bin loop after minting a session row on a fresh start; stamped onto /// bin loop after minting a session row on a fresh start; stamped onto
@ -407,7 +406,7 @@ impl Bus {
// Write the resolved active model to hyperhive-harness.json on // Write the resolved active model to hyperhive-harness.json on
// startup so hive-c0re can surface the model badge without reading // startup so hive-c0re can surface the model badge without reading
// hyperhive-model directly. Written once here; updated on every // hyperhive-model directly. Written once here; updated on every
// set_model() call (runtime override) and emit_status() call. // apply_model() call and emit_status() call.
write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model)); write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model));
Self { Self {
tx: Arc::new(tx), tx: Arc::new(tx),
@ -562,7 +561,7 @@ impl Bus {
} }
/// Currently-selected claude model name. Read on every turn so a /// Currently-selected claude model name. Read on every turn so a
/// `/model <name>` flip takes effect on the next turn. /// swarm change takes effect on the next turn.
/// ///
/// # Panics /// # Panics
/// ///
@ -572,27 +571,10 @@ impl Bus {
self.model.lock().unwrap().clone() self.model.lock().unwrap().clone()
} }
/// Switch the model for future turns. The current turn (if any) /// Switch the model for future turns. Never writes `hyperhive-model`, so
/// keeps the model it was already running. Persisted to the agent's /// clearing the swarm value finds the agent's local value again. For a
/// state dir (`hyperhive-model`) so the override survives harness /// value the swarm sets (`crate::swarm_inference`). Emits `ModelChanged`
/// restart and container rebuild (gone on `--purge`, matching /// even when only `source` changed.
/// every other piece of agent state).
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_model(&self, name: impl Into<String>) {
let value: String = name.into();
if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed");
}
self.apply_model(value, Source::LocalFile);
}
/// Switch the model for future turns without persisting it, so
/// `hyperhive-model` keeps the agent's local value. For a value the
/// swarm sets (`crate::swarm_inference`). Emits `ModelChanged` even when
/// only `source` changed.
/// ///
/// # Panics /// # Panics
/// ///

View file

@ -8,8 +8,9 @@ use std::path::PathBuf;
use swarm_queue_client::agent_inference::Source; use swarm_queue_client::agent_inference::Source;
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE` /// Path to the `hyperhive-model` file. The harness only reads it, as the
/// for dev / tests; otherwise derived from the agent's harness dir. /// fallback in [`local_model`]. Overridable via `HYPERHIVE_MODEL_FILE` for
/// dev / tests; otherwise derived from the agent's harness dir.
fn model_file_path() -> PathBuf { fn model_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else( std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-model"), || crate::paths::harness_dir().join("hyperhive-model"),
@ -27,14 +28,6 @@ pub(crate) fn load_model() -> Option<String> {
} }
} }
pub(crate) fn persist_model(name: &str) -> std::io::Result<()> {
let path = model_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{name}\n"))
}
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`, /// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise /// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
/// derived from the agent's harness dir. /// derived from the agent's harness dir.
@ -57,8 +50,8 @@ pub(crate) fn load_effort() -> Option<String> {
/// The model this agent runs when the swarm sets none, and where it comes /// The model this agent runs when the swarm sets none, and where it comes
/// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from /// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from
/// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime /// `services.hyperhive.agent.model` in agent.nix) > an existing
/// override > [`DEFAULT_MODEL`]. /// `hyperhive-model` file > [`DEFAULT_MODEL`].
pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) { pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) {
model_from(configured, load_model()) model_from(configured, load_model())
} }
@ -220,13 +213,13 @@ pub(crate) fn write_api_key_mode(enabled: bool) {
} }
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a /// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
/// persisted runtime override is present. /// `hyperhive-model` file is present.
pub const DEFAULT_MODEL: &str = "haiku"; pub const DEFAULT_MODEL: &str = "haiku";
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from /// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
/// `services.hyperhive.agent.model` in `agent.nix`), or `None` if the env var is absent / /// `services.hyperhive.agent.model` in `agent.nix`), or `None` if the env var is absent /
/// empty. When `Some`, this takes precedence over any persisted runtime /// empty. When `Some`, this takes precedence over a `hyperhive-model` file
/// override so that nix config changes always take effect on rebuild. /// so that nix config changes always take effect on rebuild.
#[must_use] #[must_use]
pub fn configured_model() -> Option<&'static str> { pub fn configured_model() -> Option<&'static str> {
// Leak once at startup — acceptable for a single config value. // Leak once at startup — acceptable for a single config value.

View file

@ -131,3 +131,8 @@ pub fn claude_dir() -> PathBuf {
} }
PathBuf::from("/root/.claude") PathBuf::from("/root/.claude")
} }
/// Held by every test that points `HYPERHIVE_HARNESS_DIR` at a temp dir:
/// cargo runs a crate's tests on parallel threads of one process.
#[cfg(test)]
pub(crate) static HARNESS_DIR_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());

View file

@ -321,11 +321,14 @@ mod tests {
#[test] #[test]
fn the_swarm_value_is_not_persisted_over_the_local_one() { fn the_swarm_value_is_not_persisted_over_the_local_one() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let _env = crate::paths::HARNESS_DIR_ENV
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR"); let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
// SAFETY: no other test in this crate reads or sets this variable. // SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) }; unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
let bus = Bus::new(); let bus = Bus::new();
bus.set_model("sonnet"); std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap();
bus.set_effort("low"); bus.set_effort("low");
let target = Harness { let target = Harness {
bus: bus.clone(), bus: bus.clone(),

View file

@ -126,41 +126,6 @@ pub(super) async fn post_new_session(State(state): State<AppState>) -> Response
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
#[derive(Deserialize)]
pub(super) struct ModelForm {
model: String,
}
/// Switch the model for future turns. The current turn (if any)
/// keeps its model; `/model <name>` applies starting with the next
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
/// claude-side validation — we just hand the string through to
/// `claude --model <name>`; an unknown model surfaces as a turn
/// failure in the live panel and the operator can revert. On ACP the name
/// must be one the session offers, since the runtime sets no other.
pub(super) async fn post_set_model(
State(state): State<AppState>,
Form(form): Form<ModelForm>,
) -> Response {
let name = form.model.trim();
if name.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "model: name required");
}
let text = if state.bus.session_choices().is_some() {
let offered = super::state::pickers(&state.bus).available_models;
if let Some(response) = accept_or_refuse("model", name, &offered) {
return response;
}
format!("operator: /model — model set to '{name}' from the next turn")
} else {
format!("operator: /model — claude model set to '{name}' for future turns")
};
state.bus.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note { text });
tracing::info!(%name, "operator set model");
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct EffortForm { pub(super) struct EffortForm {
effort: String, effort: String,
@ -201,7 +166,7 @@ pub(super) async fn post_set_effort(
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
/// The ACP branch of `post_set_model`/`post_set_effort`: `None` when `name` /// The ACP branch of `post_set_effort`: `None` when `name`
/// is one `offered` (the session's current pickers list) names, else the /// is one `offered` (the session's current pickers list) names, else the
/// [`not_offered`] refusal to return. Before a session has attached, /// [`not_offered`] refusal to return. Before a session has attached,
/// `offered` is empty and every name is refused. /// `offered` is empty and every name is refused.

View file

@ -67,6 +67,29 @@ struct AppState {
interrupted: Arc<AtomicBool>, interrupted: Arc<AtomicBool>,
} }
/// The API routes, without the extra proxies and the static fallback.
fn routes() -> Router<AppState> {
Router::new()
.route("/api/state", get(state::api_state))
.route("/api/dashboard-state", get(state::api_dashboard_state))
.route("/events/stream", get(stream::events_stream))
.route("/events/history", get(stream::events_history))
.route("/send", post(actions::post_send))
.route("/login/start", post(auth::post_login_start))
.route("/login/code", post(auth::post_login_code))
.route("/login/cancel", post(auth::post_login_cancel))
.route("/api/cancel", post(actions::post_cancel_turn))
.route("/api/compact", post(actions::post_compact))
.route("/api/effort", post(actions::post_set_effort))
.route("/api/new-session", post(actions::post_new_session))
.route("/api/logout", post(auth::post_logout))
.route("/api/todos", get(stats::api_todos))
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
.route("/api/stats", get(stats::api_stats))
.route("/screen/ws", get(screen::screen_ws))
.route("/icon", get(screen::serve_icon))
}
/// Bind the per-container web listener and serve the SPA. /// Bind the per-container web listener and serve the SPA.
/// ///
/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the /// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the
@ -110,26 +133,7 @@ pub async fn serve(
gui_vnc_port, gui_vnc_port,
interrupted, interrupted,
}; };
let app: Router<AppState> = Router::new() let app = routes();
.route("/api/state", get(state::api_state))
.route("/api/dashboard-state", get(state::api_dashboard_state))
.route("/events/stream", get(stream::events_stream))
.route("/events/history", get(stream::events_history))
.route("/send", post(actions::post_send))
.route("/login/start", post(auth::post_login_start))
.route("/login/code", post(auth::post_login_code))
.route("/login/cancel", post(auth::post_login_cancel))
.route("/api/cancel", post(actions::post_cancel_turn))
.route("/api/compact", post(actions::post_compact))
.route("/api/model", post(actions::post_set_model))
.route("/api/effort", post(actions::post_set_effort))
.route("/api/new-session", post(actions::post_new_session))
.route("/api/logout", post(auth::post_logout))
.route("/api/todos", get(stats::api_todos))
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
.route("/api/stats", get(stats::api_stats))
.route("/screen/ws", get(screen::screen_ws))
.route("/icon", get(screen::serve_icon));
// Mount any `services.hyperhive.agent.extraWebProxies` under `/extra/<name>/` before the // Mount any `services.hyperhive.agent.extraWebProxies` under `/extra/<name>/` before the
// static fallback so declared proxies win over `ServeDir`. // static fallback so declared proxies win over `ServeDir`.
let app = proxy::mount_extra_proxies(app) let app = proxy::mount_extra_proxies(app)
@ -419,3 +423,64 @@ fn broker_error_response(err: &BrokerError, action: &str) -> Response {
), ),
} }
} }
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, PoisonError};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::ServiceExt;
use super::{AppState, routes};
use crate::events::Bus;
use crate::login::LoginState;
fn form(path: &str, body: &'static str) -> Request<Body> {
Request::post(path)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
/// The swarm sets the model, so the agent serves no route for it; effort
/// is still set here.
#[test]
fn the_model_has_no_route_and_effort_still_does() {
let dir = tempfile::tempdir().unwrap();
let _env = crate::paths::HARNESS_DIR_ENV
.lock()
.unwrap_or_else(PoisonError::into_inner);
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
let state = AppState {
label: "test".to_owned(),
login: Arc::new(Mutex::new(LoginState::Online)),
session: Arc::new(Mutex::new(None)),
bus: Bus::new(),
socket: dir.path().join("mcp.sock"),
gui_vnc_port: None,
interrupted: Arc::new(AtomicBool::new(false)),
};
let app = routes().with_state(state);
let (model, effort) = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let model = app.clone().oneshot(form("/api/model", "model=opus"));
// An unknown level reaches the handler, which refuses it
// before anything is applied.
let effort = app.oneshot(form("/api/effort", "effort=bogus"));
(model.await.unwrap(), effort.await.unwrap())
});
match prev {
// SAFETY: as above.
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
}
assert_eq!(model.status(), StatusCode::NOT_FOUND);
assert_eq!(effort.status(), StatusCode::BAD_REQUEST);
}
}

View file

@ -1,9 +1,8 @@
//! `/api/state` + `/api/dashboard-state` snapshot builders. //! `/api/state` + `/api/dashboard-state` snapshot builders.
use std::sync::atomic::{AtomicBool, Ordering};
use axum::extract::State; use axum::extract::State;
use serde::Serialize; use serde::Serialize;
use swarm_queue_client::agent_inference::Source;
use crate::login::LoginState; use crate::login::LoginState;
use crate::login_session::drop_if_finished; use crate::login_session::drop_if_finished;
@ -60,6 +59,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
turn_state, turn_state,
turn_state_since, turn_state_since,
model: pickers.model, model: pickers.model,
model_source: state.bus.model_source(),
resolved_model, resolved_model,
context_window_tokens, context_window_tokens,
ctx_usage, ctx_usage,
@ -70,7 +70,6 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),
hive_name: crate::identity::hive_name(), hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(), swarm_name: crate::identity::swarm_name(),
available_models: pickers.available_models,
effort: pickers.effort, effort: pickers.effort,
available_efforts: pickers.available_efforts, available_efforts: pickers.available_efforts,
paused: crate::paths::paused_marker().exists(), paused: crate::paths::paused_marker().exists(),
@ -138,10 +137,11 @@ pub(super) struct StateSnapshot {
/// client-side off this rather than tracking it from SSE events. /// client-side off this rather than tracking it from SSE events.
turn_state: crate::events::TurnState, turn_state: crate::events::TurnState,
turn_state_since: i64, turn_state_since: i64,
/// Currently-active claude model name. Reflected on the page so /// Currently-active claude model name, shown on the page. The swarm
/// the operator can see what they just switched to (and what's /// sets it (`crate::swarm_inference`); the page cannot.
/// in flight). Mutable at runtime via `POST /api/model`.
model: String, model: String,
/// Where `model` comes from.
model_source: Source,
/// The concrete model id the most recent completed turn actually /// The concrete model id the most recent completed turn actually
/// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that /// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that
/// turn's assistant events — `model` above is the alias the /// turn's assistant events — `model` above is the alias the
@ -182,24 +182,14 @@ pub(super) struct StateSnapshot {
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from /// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
/// `HYPERHIVE_SWARM_NAME`; `None` when unset. /// `HYPERHIVE_SWARM_NAME`; `None` when unset.
swarm_name: Option<String>, swarm_name: Option<String>,
/// Ordered list of model short-names the operator has declared as
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
/// (comma-separated, set by `services.hyperhive.availableModels`).
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
/// absent or empty. The frontend model quick-picker renders one button
/// per entry in this list, so operators can add new models or drop
/// ones they don't want without touching the frontend code.
/// On ACP, the models the session offers, narrowed to this same list
/// when at least one entry matches one it offers (see [`pickers`]).
available_models: Vec<String>,
/// Currently-active claude effort level. Reflected on the page so the /// Currently-active claude effort level. Reflected on the page so the
/// operator's effort picker shows the live selection. Mutable at /// operator's effort picker shows the live selection. Mutable at
/// runtime via `POST /api/effort`; applies on the next session. /// runtime via `POST /api/effort`; applies on the next session.
effort: String, effort: String,
/// Selectable effort levels for the picker, ascending. Fixed set /// Selectable effort levels for the picker, ascending. Fixed set
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from /// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
/// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like /// [`crate::harness_state::EFFORT_LEVELS`]. The frontend renders one
/// `available_models`. The frontend renders one button per entry. /// button per entry.
/// On ACP, the levels the session offers instead, empty when it offers /// On ACP, the levels the session offers instead, empty when it offers
/// none, which hides the picker (see [`pickers`]). /// none, which hides the picker (see [`pickers`]).
available_efforts: Vec<String>, available_efforts: Vec<String>,
@ -426,107 +416,24 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
} }
} }
/// Parse `HIVE_AVAILABLE_MODELS` (comma-separated, injected by /// The model the page shows, and the effort picker: the value it shows and
/// `services.hyperhive.agent.availableModels`) into its entries. Empty /// the values it offers.
/// (absent env, or nothing left after trimming) means different things to
/// the two callers below, so neither the empty check nor a fallback lives
/// here.
fn parse_available_models_env() -> Vec<String> {
std::env::var("HIVE_AVAILABLE_MODELS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// The claude picker's model list. Falls back to `["haiku", "sonnet",
/// "opus"]` when the env var is absent or resolves to an empty list after
/// trimming.
fn available_models() -> Vec<String> {
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
let models = parse_available_models_env();
if models.is_empty() {
DEFAULT.iter().map(ToString::to_string).collect()
} else {
models
}
}
/// The operator's configured model list, for filtering the ACP picker.
/// `None` when the env is absent or empty, so [`filter_offered_models`] can
/// tell "nothing configured" apart from a real (if unmatched) list — unlike
/// [`available_models`], which folds both into its claude-only fallback.
fn configured_models() -> Option<Vec<String>> {
let models = parse_available_models_env();
(!models.is_empty()).then_some(models)
}
/// Set the first time [`filter_offered_models`] logs its "no match" warning.
/// `pickers()` runs on every `/api/state` poll (every 4s per open dashboard
/// tab) and on every `post_set_model`/`post_set_effort` call, and all of them
/// funnel through the one [`filter_offered_models`] call site in
/// [`offered_pickers`], so one flag guards every caller.
static MODEL_MISMATCH_WARNED: AtomicBool = AtomicBool::new(false);
/// Keep only the entries of `offered` (an ACP session's own model list, in
/// its own order) that `configured` names. `configured: None` (the env is
/// unset) or a `configured` list matching none of `offered` both leave
/// `offered` unfiltered — the latter is what an ACP agent that has never
/// set `services.hyperhive.agent.availableModels` hits, since the option's
/// default is the claude names, so it must show every model the session
/// offers rather than an empty picker. `warned` gates the mismatch warning
/// to once per process; tests pass their own instead of [`MODEL_MISMATCH_WARNED`]
/// so they don't observe each other's state.
fn filter_offered_models(
offered: Vec<String>,
configured: Option<&[String]>,
warned: &AtomicBool,
) -> Vec<String> {
let Some(configured) = configured else {
return offered;
};
let matching: Vec<String> = offered
.iter()
.filter(|model| configured.iter().any(|c| c == *model))
.cloned()
.collect();
if matching.is_empty() {
if !warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
configured = configured.join(", "),
"HIVE_AVAILABLE_MODELS matches none of the models this ACP session offers; \
showing every model it offers instead"
);
}
offered
} else {
matching
}
}
/// The model and effort pickers: the value each shows and the values it
/// offers.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub(super) struct Pickers { pub(super) struct Pickers {
pub(super) model: String, pub(super) model: String,
pub(super) available_models: Vec<String>,
pub(super) effort: String, pub(super) effort: String,
pub(super) available_efforts: Vec<String>, pub(super) available_efforts: Vec<String>,
} }
/// On claude, the requested model and effort, the configured models and /// On claude, the requested model and effort and
/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers /// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers
/// (see [`offered_pickers`]). /// (see [`offered_pickers`]).
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers { pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
let (model, effort) = (bus.model(), bus.effort()); let (model, effort) = (bus.model(), bus.effort());
match bus.session_choices() { match bus.session_choices() {
Some(choices) => { Some(choices) => offered_pickers(model, effort, choices.get()),
offered_pickers(model, effort, choices.get(), configured_models().as_deref())
}
None => Pickers { None => Pickers {
model, model,
available_models: available_models(),
effort, effort,
available_efforts: crate::harness_state::EFFORT_LEVELS available_efforts: crate::harness_state::EFFORT_LEVELS
.iter() .iter()
@ -537,17 +444,15 @@ pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
} }
/// Pickers for what an ACP session offers. The runtime sets a requested value /// Pickers for what an ACP session offers. The runtime sets a requested value
/// on the next turn, and only if the session offers it, so each picker shows /// on the next turn, and only if the session offers it, so the model and the
/// the requested value when offered and not refused by the agent, and the /// effort each show the requested value when offered and not refused by the
/// session's own otherwise. The effort levels on offer come with the model, /// agent, and the session's own otherwise. The effort levels on offer come
/// so while a new model waits for the next turn the effort picker is empty. `configured` narrows the /// with the model, so while a new model waits for the next turn the effort
/// model list per [`filter_offered_models`]; it never narrows which value is /// picker is empty.
/// shown as current, only which values the picker offers alongside it.
fn offered_pickers( fn offered_pickers(
model: String, model: String,
effort: String, effort: String,
offered: hive_runtime::SessionChoices, offered: hive_runtime::SessionChoices,
configured: Option<&[String]>,
) -> Pickers { ) -> Pickers {
let settable = |wanted: &String, c: &hive_runtime::Choice| { let settable = |wanted: &String, c: &hive_runtime::Choice| {
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted) c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
@ -564,21 +469,15 @@ fn offered_pickers(
Pickers { Pickers {
model: shown(model, offered.model.as_ref()), model: shown(model, offered.model.as_ref()),
effort: shown(effort, effort_choice.as_ref()), effort: shown(effort, effort_choice.as_ref()),
available_models: offered
.model
.map(|c| filter_offered_models(c.values, configured, &MODEL_MISMATCH_WARNED))
.unwrap_or_default(),
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(), available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use hive_runtime::{Choice, SessionChoices}; use hive_runtime::{Choice, SessionChoices};
use super::{Pickers, filter_offered_models, offered_pickers}; use super::{Pickers, offered_pickers};
fn choice(current: &str, values: &[&str]) -> Choice { fn choice(current: &str, values: &[&str]) -> Choice {
Choice { Choice {
@ -600,15 +499,14 @@ mod tests {
}; };
// `haiku` and `max` are not on offer: the session's own values show. // `haiku` and `max` are not on offer: the session's own values show.
assert_eq!( assert_eq!(
offered_pickers("haiku".into(), "max".into(), offered.clone(), None), offered_pickers("haiku".into(), "max".into(), offered.clone()),
Pickers { Pickers {
model: "m/think".into(), model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
effort: "low".into(), effort: "low".into(),
available_efforts: strings(&["low", "high"]), available_efforts: strings(&["low", "high"]),
} }
); );
let picked = offered_pickers("m/think".into(), "high".into(), offered, None); let picked = offered_pickers("m/think".into(), "high".into(), offered);
assert_eq!(picked.effort, "high"); assert_eq!(picked.effort, "high");
assert_eq!(picked.available_efforts, strings(&["low", "high"])); assert_eq!(picked.available_efforts, strings(&["low", "high"]));
} }
@ -619,7 +517,7 @@ mod tests {
model: Some(choice("m/plain", &["m/think", "m/plain"])), model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None, effort: None,
}; };
let shown = offered_pickers("m/plain".into(), "high".into(), plain, None); let shown = offered_pickers("m/plain".into(), "high".into(), plain);
assert_eq!(shown.model, "m/plain"); assert_eq!(shown.model, "m/plain");
assert!(shown.available_efforts.is_empty()); assert!(shown.available_efforts.is_empty());
@ -627,7 +525,7 @@ mod tests {
model: Some(choice("m/think", &["m/think", "m/plain"])), model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])), effort: Some(choice("low", &["low", "high"])),
}; };
let pending = offered_pickers("m/plain".into(), "high".into(), think, None); let pending = offered_pickers("m/plain".into(), "high".into(), think);
assert_eq!(pending.model, "m/plain"); assert_eq!(pending.model, "m/plain");
assert!(pending.available_efforts.is_empty()); assert!(pending.available_efforts.is_empty());
} }
@ -642,10 +540,9 @@ mod tests {
effort: Some(choice("low", &["low", "high"])), effort: Some(choice("low", &["low", "high"])),
}; };
assert_eq!( assert_eq!(
offered_pickers("m/plain".into(), "high".into(), refused, None), offered_pickers("m/plain".into(), "high".into(), refused),
Pickers { Pickers {
model: "m/think".into(), model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
effort: "high".into(), effort: "high".into(),
available_efforts: strings(&["low", "high"]), available_efforts: strings(&["low", "high"]),
} }
@ -654,73 +551,7 @@ mod tests {
#[test] #[test]
fn acp_pickers_are_empty_before_a_session_offers_anything() { fn acp_pickers_are_empty_before_a_session_offers_anything() {
let none = offered_pickers( let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default());
"haiku".into(), assert!(none.available_efforts.is_empty());
"high".into(),
SessionChoices::default(),
None,
);
assert!(none.available_models.is_empty() && none.available_efforts.is_empty());
}
#[test]
fn unconfigured_model_list_leaves_the_session_offer_untouched() {
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), None, &warned),
strings(&["m/think", "m/plain"])
);
}
#[test]
fn configured_model_list_keeps_only_the_matches_in_the_session_s_order() {
let configured = strings(&["m/plain", "m/other"]);
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned),
strings(&["m/plain"])
);
}
#[test]
fn configured_model_list_matching_nothing_falls_back_to_every_offered_model() {
// The claude names on an ACP agent that never set `availableModels`.
let configured = strings(&["haiku", "sonnet", "opus"]);
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned),
strings(&["m/think", "m/plain"])
);
assert!(
warned.load(Ordering::Relaxed),
"the mismatch warning should have fired once"
);
}
#[test]
fn a_second_no_match_call_does_not_warn_again() {
// `tracing::warn!` output isn't captured in this crate's tests (no
// subscriber fixture exists), so this pins the guard itself: once
// `warned` is set, a second no-match call leaves it set rather than
// toggling or re-arming, which is what keeps the log line from
// repeating on every `/api/state` poll.
let configured = strings(&["haiku"]);
let warned = AtomicBool::new(false);
filter_offered_models(strings(&["m/think"]), Some(&configured), &warned);
assert!(warned.load(Ordering::Relaxed));
let second = filter_offered_models(strings(&["m/think"]), Some(&configured), &warned);
assert_eq!(second, strings(&["m/think"]));
assert!(warned.load(Ordering::Relaxed));
}
#[test]
fn offered_pickers_applies_the_configured_model_filter() {
let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: None,
};
let configured = strings(&["m/plain"]);
let filtered = offered_pickers("m/think".into(), "high".into(), offered, Some(&configured));
assert_eq!(filtered.available_models, strings(&["m/plain"]));
} }
} }

View file

@ -383,10 +383,9 @@ fn status_if_running(
/// Read the active Claude model from `hyperhive-harness.json` (the /// Read the active Claude model from `hyperhive-harness.json` (the
/// consolidated harness state file in the agent's state dir). Written by /// consolidated harness state file in the agent's state dir). Written by
/// the harness on startup and on every `set_model` / `emit_status` call, /// the harness on startup and on every `apply_model` / `emit_status` call,
/// so it always reflects the resolved priority (nix config > runtime /// so it always reflects the model the agent runs. Returns `None` when the
/// override > default). Returns `None` when the field is absent or the /// field is absent or the harness has not yet started a turn.
/// harness has not yet started a turn.
fn read_active_model(name: &hive_types::Ident) -> Option<String> { fn read_active_model(name: &hive_types::Ident) -> Option<String> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
let raw = std::fs::read_to_string(path).ok()?; let raw = std::fs::read_to_string(path).ok()?;

View file

@ -65,10 +65,9 @@ in
description = '' description = ''
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL` Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
environment variable; the harness applies it at boot and it takes environment variable; the harness applies it at boot and it takes
priority over any persisted runtime override. The operator can still priority over a `hyperhive-model` file in the harness dir. A model the
switch the model at runtime via the per-agent web UI — that choice swarm sets for the agent (the swarm UI's agents page, or
is tracked in the state dir for the current session but is reset by `swarmctl agent inference set`) wins over this option.
any rebuild that changes this option.
Valid values are the short model names that `claude --model` accepts: Valid values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
@ -91,22 +90,13 @@ in
"opus" "opus"
]; ];
description = '' description = ''
Models offered in the per-agent web UI's model quick-picker. Rendered Models this agent may run. `services.hyperhive.agent.model` must be one
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated) of them, and the subagent daemon refuses a `start` or `continue` on any
which the harness surfaces to the agent UI, so the picker lists exactly other model (rendered into its `HIVE_AVAILABLE_MODELS`) unless the swarm
these models instead of a hardcoded set. sets `subagent_models` for the agent. An empty list restricts nothing.
Configure hive-wide by setting a shared default (e.g. in your
`agent.nix` shared defaults) or per-agent to narrow the menu — for example a
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
still set by `services.hyperhive.agent.model` and remains switchable at runtime via the
UI; this option only controls which choices the picker presents.
Values are the short model names that `claude --model` accepts: Values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). On an ACP `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
agent this same list also filters the model picker, matched against
the model ids opencode reports (`<provider-id>/<model-id>`); a list
that matches none of them shows every model the session offers.
''; '';
}; };
@ -390,8 +380,8 @@ in
assertion = config.services.hyperhive.agent.model != ""; assertion = config.services.hyperhive.agent.model != "";
message = "services.hyperhive.agent.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")"; message = "services.hyperhive.agent.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
} }
# The current model must appear in the quick-picker menu, otherwise the # The current model must be one this agent may run; the subagent daemon
# UI would offer no way back to the model the agent is actually running. # checks its own picks against the same list.
{ {
assertion = assertion =
config.services.hyperhive.agent.availableModels == [ ] config.services.hyperhive.agent.availableModels == [ ]
@ -436,14 +426,10 @@ in
env = lib.mkIf (preset != null) (lib.mkDefault preset.env); env = lib.mkIf (preset != null) (lib.mkDefault preset.env);
}; };
# HIVE_DEFAULT_MODEL seeds the initial model selection when no # HIVE_DEFAULT_MODEL is the model the agent runs while the swarm sets
# persisted model choice exists in the state dir. # none; it wins over a `hyperhive-model` file.
environment.variables = { environment.variables = {
HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model; HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model;
# Comma-separated menu for the per-agent UI model quick-picker
# (see services.hyperhive.agent.availableModels). The harness surfaces it to the
# frontend; an empty value falls back to the built-in default list.
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.services.hyperhive.agent.availableModels;
# Per-agent baseline effort (see services.hyperhive.agent.effortLevel). The # Per-agent baseline effort (see services.hyperhive.agent.effortLevel). The
# harness resolves operator-override-file → this env → "medium" # harness resolves operator-override-file → this env → "medium"
# and passes it to claude --effort at turn launch. # and passes it to claude --effort at turn launch.

View file

@ -415,13 +415,11 @@ in
HIVE_ACP_API_KEY_ENV = acpApiKeyEnv; HIVE_ACP_API_KEY_ENV = acpApiKeyEnv;
# Same `services.hyperhive.agent.availableModels` the harness's own assertions gate # Same `services.hyperhive.agent.availableModels` the harness's own assertions gate
# the primary session's model against, so a subagent can't be spawned # the primary session's model against, so a subagent can't be spawned
# on a model the operator didn't make available to this agent. The # on a model the operator didn't make available to this agent. `null`
# option renders into the *global* environment for the web UI's # for an empty list, which systemd drops: the daemon reads an absent
# quick-picker, which a systemd unit doesn't inherit, so set it here # var as "no restriction" (this is a safety rail, not a security
# from the option directly. `null` for an empty list, which systemd # boundary), matching the harness assertion that an empty list waives
# drops: the daemon reads an absent var as "no restriction" (this is # too.
# a safety rail, not a security boundary), matching the harness
# assertion that an empty list waives too.
HIVE_AVAILABLE_MODELS = HIVE_AVAILABLE_MODELS =
if config.services.hyperhive.agent.availableModels == [ ] then if config.services.hyperhive.agent.availableModels == [ ] then
null null

View file

@ -81,8 +81,8 @@ pub enum Source {
Swarm, Swarm,
/// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix. /// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix.
AgentNix, AgentNix,
/// `hyperhive-model` or `hyperhive-effort`, written by a `/model` or /// `hyperhive-effort`, written by an `/effort` pick in its web UI, or a
/// `/effort` pick in its web UI. /// `hyperhive-model` file, which the agent only reads.
LocalFile, LocalFile,
/// The harness's built-in default. /// The harness's built-in default.
Default, Default,

View file

@ -214,8 +214,8 @@ enum AgentVerb {
/// Show or change the model, effort and subagent models the swarm sets /// Show or change the model, effort and subagent models the swarm sets
/// for an agent. /// for an agent.
/// ///
/// A field the swarm sets wins over the agent's own value (from its /// A field the swarm sets wins over the agent's own value (its agent.nix,
/// `/model` or `/effort` pick or its agent.nix) from its next turn on; /// its `/effort` pick, or a `hyperhive-model` file) from its next turn on;
/// the agent reads its settings every ten seconds. A field the swarm /// the agent reads its settings every ten seconds. A field the swarm
/// doesn't set, or clears, leaves the agent on its own value. /// doesn't set, or clears, leaves the agent on its own value.
Inference { Inference {