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",
"tokio",
"tokio-stream",
"tower",
"tower-http 0.7.0",
"tracing",
]

View file

@ -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
<name>` 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/<name>/`:
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/<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
`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.

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.
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>`

View file

@ -50,13 +50,12 @@ live, which managed scope would otherwise lock.
The harness reads `<name>` from `Bus::model()` on each turn.
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
back to `"haiku"` if unset). The operator can flip it at runtime
with `/model <name>` 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

View file

@ -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 · <name> ▾`): a real picker — selecting it opens
a `Dropdown` of `state.available_models`, selecting one POSTs
`/api/model` immediately (same endpoint the `/model <name>` slash
command uses). A header badge: the control and its display sit
together.
- **Effort badge** (`effort · <level> ▾`): 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 · <name>`): 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 · <level> ▾`): 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 <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
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=<name>`) — switch the model for
future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/effort` (`effort=<level>`) — 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.

View file

@ -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) => {

View file

@ -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 `<span>` badge
(alive, state, ctx, cost — no `onClick`) stays compact, so the row
(effort picker, pause) while a plain display `<span>` 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 `<Badge>` component. Scoped to this row rather than
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
// 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<void>;
}
const MODEL_DESCRIPTIONS: Record<string, string> = {
haiku: "fast",
sonnet: "balanced",
opus: "powerful",
const SOURCE_LABEL: Record<ModelSource, string> = {
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<string, string> = {
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}
/>
<Picker
<Badge
label="model"
value={model}
options={availableModels}
descriptions={MODEL_DESCRIPTIONS}
onSelect={onSelectModel}
title={resolvedModel ? `resolved to ${resolvedModel}` : undefined}
title={
[
modelSource ? SOURCE_LABEL[modelSource] : null,
resolvedModel ? `resolved to ${resolvedModel}` : null,
]
.filter(Boolean)
.join(" · ") || undefined
}
/>
{availableEfforts.length ? (
<Picker

View file

@ -11,7 +11,7 @@
// (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from "preact/hooks";
import { postModel, postEffort } from "../lib/modelEffort.js";
import { postEffort } from "../lib/modelEffort.js";
import {
postCancelTurn,
postCompact,
@ -32,10 +32,6 @@ const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" },
{
name: "/model",
desc: "/model <name> — switch claude model for future turns",
},
{
name: "/effort",
desc: "/effort <level> — 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(

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
// 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);

View file

@ -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 <form>
// submit this replaces.
//

View file

@ -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;

View file

@ -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`).

View file

@ -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<Arc<EventStore>>,
/// Current turn-loop state + since-when (unix seconds).
state: Arc<Mutex<(TurnState, i64)>>,
/// 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<Mutex<String>>,
effort: Arc<Mutex<String>>,
/// 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>>,
/// 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<OnceLock<hive_runtime::Choices>>,
/// 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 <name>` 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<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.
/// 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
///

View file

@ -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<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`,
/// 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<String> {
/// 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.

View file

@ -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(());

View file

@ -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(),

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()
}
#[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)]
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.

View file

@ -67,6 +67,29 @@ struct AppState {
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.
///
/// `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<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/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/<name>/` 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<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);
}
}

Some files were not shown because too many files have changed in this diff Show more