From 594693f4c7e04893445276c693e8dd9ac6881f34 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 10 Oct 2026 23:06:15 +0200 Subject: [PATCH] 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 --- Cargo.lock | 1 + docs/agent-lifecycle/persistence.md | 14 +- docs/swarm/inference.md | 20 +- docs/tools/swarmctl-cli.md | 2 +- docs/turn-loop/claude-invocation.md | 13 +- docs/web-ui/agent.md | 42 ++-- frontend/packages/agent/src/Root.tsx | 9 +- .../agent/src/components/StatusChips.css | 4 +- .../agent/src/components/StatusChips.tsx | 34 +-- .../agent/src/components/TermInput.tsx | 16 +- .../packages/agent/src/lib/modelEffort.ts | 3 +- .../packages/agent/src/lib/pauseAction.ts | 2 +- frontend/packages/agent/src/types.ts | 5 +- hive-agent/Cargo.toml | 1 + hive-agent/src/events.rs | 42 +--- hive-agent/src/harness_state.rs | 23 +- hive-agent/src/paths.rs | 5 + hive-agent/src/swarm_inference.rs | 7 +- hive-agent/src/web_ui/actions.rs | 37 +-- hive-agent/src/web_ui/mod.rs | 105 +++++++-- hive-agent/src/web_ui/state.rs | 219 ++---------------- hive-c0re/src/container_view.rs | 7 +- nix/agent-modules/agent-service.nix | 38 +-- nix/agent-modules/mcp.nix | 12 +- swarm-queue-client/src/agent_inference.rs | 4 +- swarmctl/src/main.rs | 4 +- 26 files changed, 244 insertions(+), 425 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fef61d4b..824935a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1725,6 +1725,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "tower", "tower-http 0.7.0", "tracing", ] diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index d579fdd1..85aac555 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -211,12 +211,12 @@ log row never blocks a rebuild. ### `/harness/hyperhive-model` (per agent) -Single-line text file holding the claude model name currently -selected for this agent (default `haiku` when absent). Written by -`Bus::set_model` whenever the operator flips it via `/model -` in the web terminal. Read once at harness boot in -`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`. -Survives destroy/recreate, gone on `--purge`. +Single-line text file holding a claude model name. The harness only +reads it, at boot in `Bus::new` and again when the swarm clears the +model it set. It holds the model the agent runs when neither the swarm nor +`HIVE_DEFAULT_MODEL` (agent.nix) names one. Nothing in the harness +writes it. Path overridable via `HYPERHIVE_MODEL_FILE`. Survives +destroy/recreate, gone on `--purge`. ### `/harness/paused` (per agent) @@ -313,7 +313,7 @@ Under `/var/lib/hyperhive/agents//`: tables above. - `hyperhive-events.sqlite` — turn-loop event log. - `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 and other large working data. Bind-mounted RW to `/agents//work` inside the container and owned by the agent diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 7c75b76e..3490c29b 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -20,14 +20,15 @@ swarmctl agent inference clear iris agent's `availableModels` as the models its subagent `start` and `continue` calls may name, read on each call ([`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 nothing for the agent), and then the model and effort the agent reports running on, each with where it comes from. - `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 -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 @@ -47,11 +48,14 @@ Each running value names where it comes from: | ------------ | -------------------------------------------------------------------------- | | `swarm` | the value set here | | `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`) | 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. 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 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` - or `/effort` pick in its web UI or from its agent.nix. Setting a swarm +- **Its own value is the one it runs without the swarm**: its agent.nix, an + `/effort` pick in its web UI, or a `hyperhive-model` file. Setting a swarm value doesn't overwrite it. - **Clearing a field** (or the whole key) puts the agent back on its own value on the next read. -- **A `/model` or `/effort` pick while the swarm sets that field** changes - the agent's own value, but the swarm value comes back on the next read. +- **An `/effort` pick while the swarm sets effort** changes the agent's own + value, but the swarm value comes back on the next read. - **After a restart**, the agent runs its own values until its first read of its settings succeeds. diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index f9928ad1..0b7cf52d 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -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. -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 ` diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index b6138e0a..d5c58925 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -50,13 +50,12 @@ live, which managed scope would otherwise lock. The harness reads `` 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 `"haiku"` if unset). The operator can flip it at runtime -with `/model ` in the web terminal — the next turn picks it -up. The harness persists the choice to `/harness/hyperhive-model` so it -survives restart; override path: `HYPERHIVE_MODEL_FILE` env var -for tests. A model or effort the swarm sets for the agent replaces the -`Bus` value, and the harness doesn't persist it; clearing it goes back to -the persisted or nix value → [`swarm/inference.md`](../swarm/inference.md). +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 diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index eb6bc4c2..11bce384 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -47,15 +47,18 @@ at a given viewport width. Two columns: `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`. - **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` / `○ offline` / `… booting` + age suffix. - - **Model badge** (`model · ▾`): a real picker — selecting it opens - a `Dropdown` of `state.available_models`, selecting one POSTs - `/api/model` immediately (same endpoint the `/model ` slash - command uses). A header badge: the control and its display sit - together. - - **Effort badge** (`effort · ▾`): same shape, `/api/effort`, - shown when `state.available_efforts` is non-empty. - - On an ACP agent both pickers list what its session offers (its - `model` and `thought_level` config options), and are empty until the + - **Model badge** (`model · `): display only. Its tooltip says where + the model comes from (`state.model_source`: set on the swarm, agent.nix, + an existing `hyperhive-model` file, or the built-in default) and what the + last turn resolved it to. The model is set on the swarm: the swarm UI's + agents page, or `swarmctl agent inference set` + ([`inference.md`](../swarm/inference.md)). + - **Effort badge** (`effort · ▾`): a real picker — selecting it + opens a `Dropdown` of `state.available_efforts` and POSTs `/api/effort` + 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 model offers no effort levels, and while a newly picked model waits for 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 `DashboardState.links` for the dashboard card's icon strip — `agent_links()` in hive-agent is the single source of truth for - both. The dashboard link lives in this 🔗 popover; the model/effort - pickers are real badges above; and `/new-session` / `/logout` are + both. The dashboard link lives in this 🔗 popover; the effort picker + is a real badge above; and `/new-session` / `/logout` are typed slash commands with their own type-twice confirm (see below). - `/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 turn, or `turn::run_pending_compact` when idle); output streams into the live panel. Works mid-turn, not only when idle. -- `/model ` — `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 ` — `POST /api/effort` setting the claude effort level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next turn. `StatusChips`' effort badge calls the same endpoint; both stay @@ -276,17 +275,15 @@ shaped). turn — only the stdin payload differs). Flips state to `Compacting` via `Bus::set_state`, which emits `TurnStateChanged`. -- `POST /api/model` (`model=`) — switch the model for - future turns. `Bus::set_model` emits `ModelChanged`. - `POST /api/effort` (`effort=`) — switch the claude effort level for future sessions. Validated server-side against `EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are rejected rather than forwarded to `claude --effort`. Persists via `Bus::set_effort`, which emits `EffortChanged`. Applies on the next session start (no mid-session swap). -- On an ACP agent, `/api/model` and `/api/effort` accept only values the - session offers, and the runtime sets them with `session/set_config_option` - before the next prompt. +- On an ACP agent, `/api/effort` accepts only values the session offers, + and the runtime sets the level with `session/set_config_option` before + the next prompt. - `POST /api/new-session` — arm a one-shot for the next turn to drop `--continue`. Emits a `LiveEvent::Note`. - `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 `status === 'needs_login_in_progress'`. Includes `turn_state`, `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 subsequent state updates arrive via SSE. - `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 alive-badge. The harness sets `rate_limited` when it detects a 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. Emitted from `Bus::set_effort` on `POST /api/effort`; applies on the next session start. diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index f2f45759..360cd657 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -26,7 +26,7 @@ import { useTodos } from "./hooks/useTodos.js"; import { fmtAge, fmtTokens } from "./lib/format.js"; import { resolveDashboardBase } from "./lib/dashboardBase.js"; import { submitPauseResume } from "./lib/pauseAction.js"; -import { postModel, postEffort } from "./lib/modelEffort.js"; +import { postEffort } from "./lib/modelEffort.js"; import { postCancelTurn, postLogout, @@ -220,8 +220,6 @@ export function Root() { statusLabel="… connecting" statusTone="neutral" model="" - availableModels={[]} - onSelectModel={() => {}} effort="" availableEfforts={[]} onSelectEffort={() => {}} @@ -308,10 +306,7 @@ export function Root() { statusTooltip={primaryStatus.tooltip} model={state.model} resolvedModel={state.resolved_model ?? undefined} - availableModels={state.available_models} - onSelectModel={(name) => { - postModel(name).then(() => refresh()); - }} + modelSource={state.model_source} effort={state.effort} availableEfforts={state.available_efforts} onSelectEffort={(level) => { diff --git a/frontend/packages/agent/src/components/StatusChips.css b/frontend/packages/agent/src/components/StatusChips.css index a22959b2..32f54350 100644 --- a/frontend/packages/agent/src/components/StatusChips.css +++ b/frontend/packages/agent/src/components/StatusChips.css @@ -13,8 +13,8 @@ /* mara, live, on a screenshot of this row: "badges and interactive elements on same row have inconsistent size." Real cause: Badge.css's `.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor - (model/effort pickers, pause) while a plain display `` badge - (alive, state, ctx, cost — no `onClick`) stays compact, so the row + (effort picker, pause) while a plain display `` badge + (model, alive, state, ctx, cost — no `onClick`) stays compact, so the row reads as two different pill sizes even though every badge here is the same `` component. Scoped to this row rather than Badge.css itself — display-only badges elsewhere (e.g. the dashboard) diff --git a/frontend/packages/agent/src/components/StatusChips.tsx b/frontend/packages/agent/src/components/StatusChips.tsx index dd7fd2b4..0e7bedcf 100644 --- a/frontend/packages/agent/src/components/StatusChips.tsx +++ b/frontend/packages/agent/src/components/StatusChips.tsx @@ -1,6 +1,7 @@ // — the agent page's status row: the consolidated status // 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 // (the old page's model/effort *pickers* lived in the `⋯` overflow menu // while the current model/effort only showed as a disconnected chip) — @@ -13,6 +14,7 @@ import { useRef, useState } from "preact/hooks"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; +import type { ModelSource } from "../types.js"; import "./StatusChips.css"; export interface StatusChipsProps { @@ -25,8 +27,8 @@ export interface StatusChipsProps { * operator can tell what an alias resolved to. `undefined` before any * turn has completed. */ resolvedModel?: string; - availableModels: string[]; - onSelectModel: (name: string) => void; + /** Where `model` comes from (`/api/state` `model_source`). */ + modelSource?: ModelSource; effort: string; availableEfforts: string[]; onSelectEffort: (level: string) => void; @@ -50,10 +52,11 @@ export interface StatusChipsProps { onLogout: () => Promise; } -const MODEL_DESCRIPTIONS: Record = { - haiku: "fast", - sonnet: "balanced", - opus: "powerful", +const SOURCE_LABEL: Record = { + swarm: "set on the swarm", + agent_nix: "from agent.nix", + local_file: "from its own earlier pick", + default: "the built-in default", }; const EFFORT_DESCRIPTIONS: Record = { low: "", @@ -235,8 +238,7 @@ export function StatusChips({ statusTooltip, model, resolvedModel, - availableModels, - onSelectModel, + modelSource, effort, availableEfforts, onSelectEffort, @@ -263,13 +265,17 @@ export function StatusChips({ onNewSession={onNewSession} onLogout={onLogout} /> - {availableEfforts.length ? ( — switch claude model for future turns", - }, { name: "/effort", desc: "/effort — set claude effort (medium/high/xhigh) for future turns", @@ -131,16 +127,6 @@ export function TermInput({ ); } 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": if (!arg) onLocalNote( diff --git a/frontend/packages/agent/src/lib/modelEffort.ts b/frontend/packages/agent/src/lib/modelEffort.ts index 06238b04..2b11b186 100644 --- a/frontend/packages/agent/src/lib/modelEffort.ts +++ b/frontend/packages/agent/src/lib/modelEffort.ts @@ -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 // pauseAction.ts). `redirect: 'manual'` + the broad "ok" check below is // 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) => post("api/effort", "effort", level); diff --git a/frontend/packages/agent/src/lib/pauseAction.ts b/frontend/packages/agent/src/lib/pauseAction.ts index 95b33402..8377273c 100644 --- a/frontend/packages/agent/src/lib/pauseAction.ts +++ b/frontend/packages/agent/src/lib/pauseAction.ts @@ -6,7 +6,7 @@ // 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 // 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
// submit this replaces. // diff --git a/frontend/packages/agent/src/types.ts b/frontend/packages/agent/src/types.ts index 00a8ad2e..3c30ea4a 100644 --- a/frontend/packages/agent/src/types.ts +++ b/frontend/packages/agent/src/types.ts @@ -4,6 +4,9 @@ // today, and the frontend only ever reads a handful of these fields. // 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 { seq: number; label: string; @@ -24,7 +27,7 @@ export interface AgentState { cost_usage: TokenUsage | null; hive_name: string | null; swarm_name: string | null; - available_models: string[]; + model_source: ModelSource; effort: string; available_efforts: string[]; paused: boolean; diff --git a/hive-agent/Cargo.toml b/hive-agent/Cargo.toml index d4bc292a..9be432ce 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -57,6 +57,7 @@ tracing.workspace = true [dev-dependencies] tempfile = "3" tokio = { workspace = true, features = ["test-util"] } +tower = { version = "0.5", features = ["util"] } # Single harness serve-loop binary: `hive-agent` (from `src/main.rs`). # The sibling MCP server is its own bin crate now (`hive-agent-mcp`). diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index 7974dfd9..dea3fd31 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -21,7 +21,7 @@ use tokio::sync::broadcast; use crate::harness_state::{ 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; @@ -115,8 +115,8 @@ pub enum LiveEvent { /// during the short-lived in-progress window — the client /// re-fetches only while that flow is active. StatusChanged { status: String }, - /// `/api/model` switched the active claude model. The web UI - /// updates the chip + the per-turn stats sink will key off this + /// The active claude model changed (a swarm value applied or cleared). + /// The web UI updates the chip + the per-turn stats sink will key off this /// to mark the boundary in its log. ModelChanged { model: String }, /// `/api/effort` switched the active claude effort level. Applies on @@ -282,8 +282,8 @@ pub struct Bus { store: Option>, /// Current turn-loop state + since-when (unix seconds). state: Arc>, - /// Model name passed to `claude --model`. Default `haiku`; the - /// operator can override at runtime via `POST /api/model`. + /// Model name passed to `claude --model`. Default `haiku`; a value the + /// swarm sets replaces it at runtime (`crate::swarm_inference`). model: Arc>, effort: Arc>, /// Where `model` and `effort` come from, as the swarm is told in this @@ -341,8 +341,7 @@ pub struct Bus { turn_canceller: Arc>, /// 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 - /// session; unset on claude, whose pickers list the configured models and - /// `EFFORT_LEVELS`. + /// session; unset on claude, whose effort picker lists `EFFORT_LEVELS`. session_choices: Arc>, /// 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 @@ -407,7 +406,7 @@ impl Bus { // Write the resolved active model to hyperhive-harness.json on // startup so hive-c0re can surface the model badge without reading // 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)); Self { tx: Arc::new(tx), @@ -562,7 +561,7 @@ impl Bus { } /// Currently-selected claude model name. Read on every turn so a - /// `/model ` flip takes effect on the next turn. + /// swarm change takes effect on the next turn. /// /// # Panics /// @@ -572,27 +571,10 @@ impl Bus { self.model.lock().unwrap().clone() } - /// Switch the model for future turns. The current turn (if any) - /// keeps the model it was already running. Persisted to the agent's - /// state dir (`hyperhive-model`) so the override survives harness - /// restart and container rebuild (gone on `--purge`, matching - /// every other piece of agent state). - /// - /// # Panics - /// - /// Panics if the internal lock is poisoned. - pub fn set_model(&self, name: impl Into) { - 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. + /// Switch the model for future turns. Never writes `hyperhive-model`, so + /// clearing the swarm value finds the agent's local value again. For a + /// value the swarm sets (`crate::swarm_inference`). Emits `ModelChanged` + /// even when only `source` changed. /// /// # Panics /// diff --git a/hive-agent/src/harness_state.rs b/hive-agent/src/harness_state.rs index 9ebd1eff..dfe17fdc 100644 --- a/hive-agent/src/harness_state.rs +++ b/hive-agent/src/harness_state.rs @@ -8,8 +8,9 @@ use std::path::PathBuf; use swarm_queue_client::agent_inference::Source; -/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE` -/// for dev / tests; otherwise derived from the agent's harness dir. +/// Path to the `hyperhive-model` file. The harness only reads it, as the +/// 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 { std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else( || crate::paths::harness_dir().join("hyperhive-model"), @@ -27,14 +28,6 @@ pub(crate) fn load_model() -> Option { } } -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`, /// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise /// derived from the agent's harness dir. @@ -57,8 +50,8 @@ pub(crate) fn load_effort() -> Option { /// The model this agent runs when the swarm sets none, and where it comes /// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from -/// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime -/// override > [`DEFAULT_MODEL`]. +/// `services.hyperhive.agent.model` in agent.nix) > an existing +/// `hyperhive-model` file > [`DEFAULT_MODEL`]. pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) { 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 -/// persisted runtime override is present. +/// `hyperhive-model` file is present. pub const DEFAULT_MODEL: &str = "haiku"; /// 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 / -/// empty. When `Some`, this takes precedence over any persisted runtime -/// override so that nix config changes always take effect on rebuild. +/// empty. When `Some`, this takes precedence over a `hyperhive-model` file +/// so that nix config changes always take effect on rebuild. #[must_use] pub fn configured_model() -> Option<&'static str> { // Leak once at startup — acceptable for a single config value. diff --git a/hive-agent/src/paths.rs b/hive-agent/src/paths.rs index fdc4e968..7c41cd87 100644 --- a/hive-agent/src/paths.rs +++ b/hive-agent/src/paths.rs @@ -131,3 +131,8 @@ pub fn claude_dir() -> PathBuf { } 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(()); diff --git a/hive-agent/src/swarm_inference.rs b/hive-agent/src/swarm_inference.rs index 3be47c2d..dcc747ff 100644 --- a/hive-agent/src/swarm_inference.rs +++ b/hive-agent/src/swarm_inference.rs @@ -321,11 +321,14 @@ mod tests { #[test] fn the_swarm_value_is_not_persisted_over_the_local_one() { 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"); - // 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()) }; let bus = Bus::new(); - bus.set_model("sonnet"); + std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap(); bus.set_effort("low"); let target = Harness { bus: bus.clone(), diff --git a/hive-agent/src/web_ui/actions.rs b/hive-agent/src/web_ui/actions.rs index a09488fd..a31adf64 100644 --- a/hive-agent/src/web_ui/actions.rs +++ b/hive-agent/src/web_ui/actions.rs @@ -126,41 +126,6 @@ pub(super) async fn post_new_session(State(state): State) -> 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 ` 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 `; 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, - Form(form): Form, -) -> 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)] pub(super) struct EffortForm { effort: String, @@ -201,7 +166,7 @@ pub(super) async fn post_set_effort( (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 /// [`not_offered`] refusal to return. Before a session has attached, /// `offered` is empty and every name is refused. diff --git a/hive-agent/src/web_ui/mod.rs b/hive-agent/src/web_ui/mod.rs index 88d848e6..f1fca758 100644 --- a/hive-agent/src/web_ui/mod.rs +++ b/hive-agent/src/web_ui/mod.rs @@ -67,6 +67,29 @@ struct AppState { interrupted: Arc, } +/// The API routes, without the extra proxies and the static fallback. +fn routes() -> Router { + 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. /// /// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the @@ -110,26 +133,7 @@ pub async fn serve( gui_vnc_port, interrupted, }; - let app: Router = 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/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)); + let app = routes(); // Mount any `services.hyperhive.agent.extraWebProxies` under `/extra//` before the // static fallback so declared proxies win over `ServeDir`. 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 { + 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); + } +} diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index f8234cec..84afeed3 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -1,9 +1,8 @@ //! `/api/state` + `/api/dashboard-state` snapshot builders. -use std::sync::atomic::{AtomicBool, Ordering}; - use axum::extract::State; use serde::Serialize; +use swarm_queue_client::agent_inference::Source; use crate::login::LoginState; use crate::login_session::drop_if_finished; @@ -60,6 +59,7 @@ pub(super) async fn api_state(State(state): State) -> axum::Json) -> axum::Json, - /// 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, /// Currently-active claude effort level. Reflected on the page so the /// operator's effort picker shows the live selection. Mutable at /// runtime via `POST /api/effort`; applies on the next session. effort: String, /// Selectable effort levels for the picker, ascending. Fixed set /// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from - /// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like - /// `available_models`. The frontend renders one button per entry. + /// [`crate::harness_state::EFFORT_LEVELS`]. The frontend renders one + /// button per entry. /// On ACP, the levels the session offers instead, empty when it offers /// none, which hides the picker (see [`pickers`]). available_efforts: Vec, @@ -426,107 +416,24 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec Vec { - 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 { - 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> { - 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, - configured: Option<&[String]>, - warned: &AtomicBool, -) -> Vec { - let Some(configured) = configured else { - return offered; - }; - let matching: Vec = 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. +/// The model the page shows, and the effort picker: the value it shows and +/// the values it offers. #[derive(Debug, PartialEq, Eq)] pub(super) struct Pickers { pub(super) model: String, - pub(super) available_models: Vec, pub(super) effort: String, pub(super) available_efforts: Vec, } -/// 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 /// (see [`offered_pickers`]). pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers { let (model, effort) = (bus.model(), bus.effort()); match bus.session_choices() { - Some(choices) => { - offered_pickers(model, effort, choices.get(), configured_models().as_deref()) - } + Some(choices) => offered_pickers(model, effort, choices.get()), None => Pickers { model, - available_models: available_models(), effort, available_efforts: crate::harness_state::EFFORT_LEVELS .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 -/// on the next turn, and only if the session offers it, so each picker shows -/// the requested value when offered and not refused by the agent, and the -/// session's own otherwise. The effort levels on offer come with the model, -/// so while a new model waits for the next turn the effort picker is empty. `configured` narrows the -/// model list per [`filter_offered_models`]; it never narrows which value is -/// shown as current, only which values the picker offers alongside it. +/// on the next turn, and only if the session offers it, so the model and the +/// effort each show the requested value when offered and not refused by the +/// agent, and the session's own otherwise. The effort levels on offer come +/// with the model, so while a new model waits for the next turn the effort +/// picker is empty. fn offered_pickers( model: String, effort: String, offered: hive_runtime::SessionChoices, - configured: Option<&[String]>, ) -> Pickers { let settable = |wanted: &String, c: &hive_runtime::Choice| { c.values.contains(wanted) && c.refused.as_ref() != Some(wanted) @@ -564,21 +469,15 @@ fn offered_pickers( Pickers { model: shown(model, offered.model.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(), } } #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - 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 { Choice { @@ -600,15 +499,14 @@ mod tests { }; // `haiku` and `max` are not on offer: the session's own values show. assert_eq!( - offered_pickers("haiku".into(), "max".into(), offered.clone(), None), + offered_pickers("haiku".into(), "max".into(), offered.clone()), Pickers { model: "m/think".into(), - available_models: strings(&["m/think", "m/plain"]), effort: "low".into(), 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.available_efforts, strings(&["low", "high"])); } @@ -619,7 +517,7 @@ mod tests { model: Some(choice("m/plain", &["m/think", "m/plain"])), 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!(shown.available_efforts.is_empty()); @@ -627,7 +525,7 @@ mod tests { model: Some(choice("m/think", &["m/think", "m/plain"])), 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!(pending.available_efforts.is_empty()); } @@ -642,10 +540,9 @@ mod tests { effort: Some(choice("low", &["low", "high"])), }; assert_eq!( - offered_pickers("m/plain".into(), "high".into(), refused, None), + offered_pickers("m/plain".into(), "high".into(), refused), Pickers { model: "m/think".into(), - available_models: strings(&["m/think", "m/plain"]), effort: "high".into(), available_efforts: strings(&["low", "high"]), } @@ -654,73 +551,7 @@ mod tests { #[test] fn acp_pickers_are_empty_before_a_session_offers_anything() { - let none = offered_pickers( - "haiku".into(), - "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"])); + let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default()); + assert!(none.available_efforts.is_empty()); } } diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 89f64e29..012a9451 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -383,10 +383,9 @@ fn status_if_running( /// Read the active Claude model from `hyperhive-harness.json` (the /// consolidated harness state file in the agent's state dir). Written by -/// the harness on startup and on every `set_model` / `emit_status` call, -/// so it always reflects the resolved priority (nix config > runtime -/// override > default). Returns `None` when the field is absent or the -/// harness has not yet started a turn. +/// the harness on startup and on every `apply_model` / `emit_status` call, +/// so it always reflects the model the agent runs. Returns `None` when the +/// field is absent or the harness has not yet started a turn. fn read_active_model(name: &hive_types::Ident) -> Option { let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); let raw = std::fs::read_to_string(path).ok()?; diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index 4662b04e..ad17554b 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -65,10 +65,9 @@ in description = '' Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL` environment variable; the harness applies it at boot and it takes - priority over any persisted runtime override. The operator can still - switch the model at runtime via the per-agent web UI — that choice - is tracked in the state dir for the current session but is reset by - any rebuild that changes this option. + priority over a `hyperhive-model` file in the harness dir. A model the + swarm sets for the agent (the swarm UI's agents page, or + `swarmctl agent inference set`) wins over this option. Valid values are the short model names that `claude --model` accepts: `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context @@ -91,22 +90,13 @@ in "opus" ]; description = '' - Models offered in the per-agent web UI's model quick-picker. Rendered - into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated) - which the harness surfaces to the agent UI, so the picker lists exactly - these models instead of a hardcoded set. - - 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. + Models this agent may run. `services.hyperhive.agent.model` must be one + of them, and the subagent daemon refuses a `start` or `continue` on any + other model (rendered into its `HIVE_AVAILABLE_MODELS`) unless the swarm + sets `subagent_models` for the agent. An empty list restricts nothing. Values are the short model names that `claude --model` accepts: - `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). On an ACP - agent this same list also filters the model picker, matched against - the model ids opencode reports (`/`); a list - that matches none of them shows every model the session offers. + `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). ''; }; @@ -390,8 +380,8 @@ in assertion = config.services.hyperhive.agent.model != ""; 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 - # UI would offer no way back to the model the agent is actually running. + # The current model must be one this agent may run; the subagent daemon + # checks its own picks against the same list. { assertion = config.services.hyperhive.agent.availableModels == [ ] @@ -436,14 +426,10 @@ in env = lib.mkIf (preset != null) (lib.mkDefault preset.env); }; - # HIVE_DEFAULT_MODEL seeds the initial model selection when no - # persisted model choice exists in the state dir. + # HIVE_DEFAULT_MODEL is the model the agent runs while the swarm sets + # none; it wins over a `hyperhive-model` file. environment.variables = { 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 # harness resolves operator-override-file → this env → "medium" # and passes it to claude --effort at turn launch. diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 77bb8068..131164c7 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -415,13 +415,11 @@ in HIVE_ACP_API_KEY_ENV = acpApiKeyEnv; # Same `services.hyperhive.agent.availableModels` the harness's own assertions gate # 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 - # option renders into the *global* environment for the web UI's - # quick-picker, which a systemd unit doesn't inherit, so set it here - # from the option directly. `null` for an empty list, which systemd - # drops: the daemon reads an absent var as "no restriction" (this is - # a safety rail, not a security boundary), matching the harness - # assertion that an empty list waives too. + # on a model the operator didn't make available to this agent. `null` + # for an empty list, which systemd drops: the daemon reads an absent + # var as "no restriction" (this is a safety rail, not a security + # boundary), matching the harness assertion that an empty list waives + # too. HIVE_AVAILABLE_MODELS = if config.services.hyperhive.agent.availableModels == [ ] then null diff --git a/swarm-queue-client/src/agent_inference.rs b/swarm-queue-client/src/agent_inference.rs index 1f64827f..1153914f 100644 --- a/swarm-queue-client/src/agent_inference.rs +++ b/swarm-queue-client/src/agent_inference.rs @@ -81,8 +81,8 @@ pub enum Source { Swarm, /// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix. AgentNix, - /// `hyperhive-model` or `hyperhive-effort`, written by a `/model` or - /// `/effort` pick in its web UI. + /// `hyperhive-effort`, written by an `/effort` pick in its web UI, or a + /// `hyperhive-model` file, which the agent only reads. LocalFile, /// The harness's built-in default. Default, diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 357bcccc..6ba40938 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -214,8 +214,8 @@ enum AgentVerb { /// 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; + /// 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. Inference {