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:
parent
46e9966e17
commit
594693f4c7
26 changed files with 244 additions and 425 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1725,6 +1725,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-http 0.7.0",
|
||||
"tracing",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue