Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baefb32bd5 | ||
|
|
a7e640514c | ||
|
|
4719395b31 | ||
|
|
ed271e655f | ||
|
|
594693f4c7 |
33 changed files with 315 additions and 812 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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -216,12 +216,12 @@ the retention sweep never deletes open rows.
|
|||
|
||||
### `/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)
|
||||
|
||||
|
|
@ -318,7 +318,8 @@ 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` / `hyperhive-effort` — single-line model name and
|
||||
effort level files, read-only fallbacks.
|
||||
- `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,16 @@ 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 or effort at runtime:
|
||||
its web UI shows both but has no picker and no `/model` or `/effort`
|
||||
command.
|
||||
|
||||
## What an agent runs on
|
||||
|
||||
|
|
@ -47,11 +49,15 @@ 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` | a `hyperhive-model` or `hyperhive-effort` file in the agent's harness dir |
|
||||
| `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 a `hyperhive-effort` file wins over its
|
||||
agent.nix effort. These files come from runtime picks the agent's web UI no
|
||||
longer offers; the agent still reads them but never writes them, so a
|
||||
`local_file` value stays until the swarm sets that field or someone deletes
|
||||
the file.
|
||||
|
||||
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 +69,11 @@ 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
|
||||
value doesn't overwrite it.
|
||||
- **Its own value is the one it runs without the swarm**: its agent.nix or a
|
||||
`hyperhive-model` / `hyperhive-effort` 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.
|
||||
- **After a restart**, the agent runs its own values until its first read
|
||||
of its settings succeeds.
|
||||
|
||||
|
|
|
|||
|
|
@ -337,8 +337,6 @@ happens until the daemon's first connection to the swarm queue succeeds.
|
|||
The match ignores case. On an ACP agent the names are the agent's own, such
|
||||
as `<provider>/<model>` on `opencode`.
|
||||
|
||||
The web UI's model picker still offers `availableModels`.
|
||||
|
||||
## Runtime
|
||||
|
||||
A subagent runs on its parent agent's runtime
|
||||
|
|
|
|||
|
|
@ -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 or a `hyperhive-model`/`hyperhive-effort` 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>`
|
||||
|
||||
|
|
|
|||
|
|
@ -44,19 +44,18 @@ Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
|
|||
read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix`
|
||||
from the `prompts/claude-settings.json` asset. `effortLevel` is
|
||||
deliberately not in that file — the `--effort` flag
|
||||
(`HIVE_DEFAULT_EFFORT` / the per-agent UI slider) controls effort
|
||||
(`HIVE_DEFAULT_EFFORT` / the swarm's effort setting) controls effort
|
||||
live, which managed scope would otherwise lock.
|
||||
|
||||
The harness reads `<name>` from `Bus::model()` on each turn.
|
||||
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
|
||||
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
|
||||
back to `"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,18 +47,20 @@ 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
|
||||
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.
|
||||
- **Model and effort badges** (`model · <name>`, `effort · <level>`):
|
||||
display only. Each tooltip says where the value comes from
|
||||
(`state.model_source` / `state.effort_source`: set on the swarm,
|
||||
agent.nix, an existing `hyperhive-model` / `hyperhive-effort` file, or
|
||||
the built-in default); the model's also says what the last turn
|
||||
resolved it to. Both are set on the swarm: the swarm UI's agents page,
|
||||
or `swarmctl agent inference set`
|
||||
([`inference.md`](../swarm/inference.md)).
|
||||
- On an ACP agent each badge shows the session's own value when it
|
||||
doesn't offer the requested one. The effort badge is hidden
|
||||
(`state.effort` is `null`) until the first turn has attached a
|
||||
session, when the model offers no effort levels, and while a newly
|
||||
set model waits for the next turn, since the levels come with the
|
||||
model.
|
||||
- **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size,
|
||||
tooltip shows % of context window) / `cost · 1.3M` (cumulative
|
||||
tokens billed across every inference in the last turn).
|
||||
|
|
@ -82,8 +84,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, 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
|
||||
|
|
@ -238,14 +240,6 @@ Slash commands today:
|
|||
turn, or `turn::run_pending_compact` when idle); output streams into
|
||||
the live panel. Works mid-turn, not only when idle. Either way the live
|
||||
panel ends with a `/compact done` or `/compact failed: …` note.
|
||||
- `/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
|
||||
in sync via `StateSnapshot.effort`.
|
||||
- `/new-session` — `POST /api/new-session` (confirms first).
|
||||
Arms a one-shot on the Bus; next turn runs without
|
||||
`--continue`, dropping the resume session entirely.
|
||||
|
|
@ -280,17 +274,6 @@ 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.
|
||||
- `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
|
||||
|
|
@ -324,7 +307,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`, `effort_source`, `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
|
||||
|
|
@ -397,10 +380,10 @@ 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.
|
||||
- `effort_changed { effort }` — drives the effort picker chip.
|
||||
Emitted from `Bus::set_effort` on `POST /api/effort`; applies on
|
||||
the next session start.
|
||||
- `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 chip. The harness
|
||||
emits it when it applies or clears a swarm value.
|
||||
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
|
||||
— drives the ctx + cost badges. Emitted from
|
||||
`Bus::record_turn_usage` at turn-end; `ctx` is the last
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ 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 {
|
||||
postCancelTurn,
|
||||
postLogout,
|
||||
|
|
@ -220,11 +219,6 @@ export function Root() {
|
|||
statusLabel="… connecting"
|
||||
statusTone="neutral"
|
||||
model=""
|
||||
availableModels={[]}
|
||||
onSelectModel={() => {}}
|
||||
effort=""
|
||||
availableEfforts={[]}
|
||||
onSelectEffort={() => {}}
|
||||
paused={false}
|
||||
onTogglePause={() => {}}
|
||||
thinking={false}
|
||||
|
|
@ -308,15 +302,9 @@ 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) => {
|
||||
postEffort(level).then(() => refresh());
|
||||
}}
|
||||
effortSource={state.effort_source}
|
||||
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
|
||||
costLabel={cost !== null ? fmtTokens(cost) : undefined}
|
||||
paused={state.paused}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(pause) while a plain display `<span>` badge (model, effort,
|
||||
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 and effort (display-only: the swarm sets
|
||||
// them), 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 { InferenceSource } from "../types.js";
|
||||
import "./StatusChips.css";
|
||||
|
||||
export interface StatusChipsProps {
|
||||
|
|
@ -25,11 +27,11 @@ 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;
|
||||
effort: string;
|
||||
availableEfforts: string[];
|
||||
onSelectEffort: (level: string) => void;
|
||||
/** Where `model` comes from (`/api/state` `model_source`). */
|
||||
modelSource?: InferenceSource;
|
||||
/** Hidden when empty: an ACP session that offers no effort now. */
|
||||
effort?: string | null;
|
||||
effortSource?: InferenceSource;
|
||||
ctxLabel?: string;
|
||||
costLabel?: string;
|
||||
paused: boolean;
|
||||
|
|
@ -50,66 +52,12 @@ export interface StatusChipsProps {
|
|||
onLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||
haiku: "fast",
|
||||
sonnet: "balanced",
|
||||
opus: "powerful",
|
||||
const SOURCE_LABEL: Record<InferenceSource, 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: "",
|
||||
medium: "default",
|
||||
high: "",
|
||||
xhigh: "",
|
||||
max: "",
|
||||
};
|
||||
|
||||
function Picker({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
descriptions,
|
||||
onSelect,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
descriptions: Record<string, string>;
|
||||
onSelect: (v: string) => void;
|
||||
title?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownOptions: DropdownOption[] = options.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
description: descriptions[name] || undefined,
|
||||
}));
|
||||
return (
|
||||
<div class="status-chip-anchor" ref={anchorRef}>
|
||||
<Badge
|
||||
label={label}
|
||||
value={value}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
expanded={open}
|
||||
title={title}
|
||||
/>
|
||||
<Dropdown
|
||||
open={open}
|
||||
options={dropdownOptions}
|
||||
activeValue={value}
|
||||
label={`select ${label}`}
|
||||
onSelect={(v) => {
|
||||
onSelect(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The consolidated alive/thinking/paused badge (mara: "why do we
|
||||
// have separate alive badge? ... it could be one badge that shows
|
||||
// thinking / idle / paused / ... with a dropdown that opens when
|
||||
|
|
@ -235,11 +183,9 @@ export function StatusChips({
|
|||
statusTooltip,
|
||||
model,
|
||||
resolvedModel,
|
||||
availableModels,
|
||||
onSelectModel,
|
||||
modelSource,
|
||||
effort,
|
||||
availableEfforts,
|
||||
onSelectEffort,
|
||||
effortSource,
|
||||
ctxLabel,
|
||||
costLabel,
|
||||
paused,
|
||||
|
|
@ -263,21 +209,23 @@ 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
|
||||
{effort ? (
|
||||
<Badge
|
||||
label="effort"
|
||||
value={effort}
|
||||
options={availableEfforts}
|
||||
descriptions={EFFORT_DESCRIPTIONS}
|
||||
onSelect={onSelectEffort}
|
||||
title={effortSource ? SOURCE_LABEL[effortSource] : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{ctxLabel ? (
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
// (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 {
|
||||
postCancelTurn,
|
||||
postCompact,
|
||||
|
|
@ -32,14 +31,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",
|
||||
},
|
||||
{
|
||||
name: "/new-session",
|
||||
desc: "fresh claude session next turn (type twice to confirm)",
|
||||
|
|
@ -84,8 +75,7 @@ export function TermInput({
|
|||
|
||||
function handleSlash(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
const [cmd, ...rest] = trimmed.split(/\s+/);
|
||||
const arg = rest.join(" ");
|
||||
const [cmd] = trimmed.split(/\s+/);
|
||||
const wasArmed = armedRef.current === cmd;
|
||||
armedRef.current = null;
|
||||
|
||||
|
|
@ -131,26 +121,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(
|
||||
"✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)",
|
||||
);
|
||||
else
|
||||
postEffort(arg).then(
|
||||
(r) => !r.ok && reportFailure("/effort", r.detail),
|
||||
);
|
||||
return true;
|
||||
default:
|
||||
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const RETRY_MS = 5000;
|
|||
export interface UseAgentStateResult {
|
||||
state: AgentState | null;
|
||||
error: string | null;
|
||||
/** Force an immediate refresh (e.g. right after a model/effort POST). */
|
||||
/** Force an immediate refresh (e.g. right after a pause/resume POST). */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a
|
||||
// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin
|
||||
// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` /
|
||||
// broad-2xx-4xx-treated-as-ok contract as `modelEffort.ts` — none of
|
||||
// broad-2xx-4xx-treated-as-ok contract as `termActions.ts` — none of
|
||||
// these three routes actually redirect today, but the same tolerant
|
||||
// check costs nothing and keeps every same-origin POST in this package
|
||||
// behaving identically.
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
// POST `/api/model` / `/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
|
||||
// today, but treating an opaque redirect as success costs nothing and
|
||||
// matches the existing contract exactly.
|
||||
async function post(
|
||||
path: string,
|
||||
field: string,
|
||||
value: string,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ [field]: value }),
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postModel = (name: string) => post("api/model", "model", name);
|
||||
export const postEffort = (level: string) =>
|
||||
post("api/effort", "effort", level);
|
||||
|
|
@ -5,8 +5,7 @@
|
|||
// origin, but accessed directly they're different ports) and this way
|
||||
// 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:
|
||||
// doesn't need to read it, only trigger a state refresh afterward. `credentials:
|
||||
// 'include'` matches the cookie-sending behavior of the plain <form>
|
||||
// submit this replaces.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Same-origin fetch POST helpers for the term-input slash commands —
|
||||
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
|
||||
// throwing), reused by TermInput.tsx.
|
||||
// Same-origin fetch POST helpers for the term-input slash commands
|
||||
// ({ ok, detail } rather than throwing), reused by TermInput.tsx.
|
||||
async function post(
|
||||
path: string,
|
||||
body?: URLSearchParams,
|
||||
|
|
|
|||
|
|
@ -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 InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
|
||||
|
||||
export interface AgentState {
|
||||
seq: number;
|
||||
label: string;
|
||||
|
|
@ -24,9 +27,9 @@ export interface AgentState {
|
|||
cost_usage: TokenUsage | null;
|
||||
hive_name: string | null;
|
||||
swarm_name: string | null;
|
||||
available_models: string[];
|
||||
effort: string;
|
||||
available_efforts: string[];
|
||||
model_source: InferenceSource;
|
||||
effort: string | null;
|
||||
effort_source: InferenceSource;
|
||||
paused: boolean;
|
||||
inbox: InboxRow[];
|
||||
links: AgentLink[];
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
// the `Badge` that opened it (../badge/Badge.tsx) so the control is
|
||||
// visually attached to the thing it affects, instead of living three
|
||||
// clicks away in a catch-all menu (the junk-drawer pattern
|
||||
// `docs/web-ui/design-guide.md` calls out by name against the old
|
||||
// per-agent terminal's model picker). Not a native `<dialog>` — a
|
||||
// `docs/web-ui/design-guide.md` calls out by name). Not a native `<dialog>` — a
|
||||
// `showModal()` dialog's backdrop would visually detach it from its
|
||||
// anchor badge, and a non-modal `show()` gets no click-outside-to-close
|
||||
// for free, so this hand-rolls the same "outside click or Escape
|
||||
|
|
|
|||
|
|
@ -167,8 +167,7 @@ function RefreshIntervalPickerSample() {
|
|||
}
|
||||
|
||||
// The badge-triggers-a-dropdown-right-underneath-itself pattern this is
|
||||
// meant to land — the model/effort picker shape from the per-agent
|
||||
// terminal, demoed here with sample data. The wrapper
|
||||
// meant to land, demoed here with sample data. The wrapper
|
||||
// needs `position: relative` (`.components-badge-anchor`) so `Dropdown`
|
||||
// anchors under this badge specifically, not the page.
|
||||
function BadgePickerSample() {
|
||||
|
|
@ -528,7 +527,7 @@ export function ComponentsPage() {
|
|||
<Sample label="with a label prefix">
|
||||
<Badge label="ctx" value="534k" />
|
||||
</Sample>
|
||||
<Sample label="interactive, opens a Dropdown (model/effort picker shape)">
|
||||
<Sample label="interactive, opens a Dropdown">
|
||||
<BadgePickerSample />
|
||||
</Sample>
|
||||
<Sample label="interactive, toggles itself (pause/resume shape)">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
// explicit — a dropdown just lets "up" be picked directly regardless
|
||||
// of what's inferred, no flip-to-the-opposite-of-a-guess involved
|
||||
// (mara: "when no state is declared, i want to set it to online").
|
||||
// Presentational only, same split as `StatusChips`'s `Picker`/
|
||||
// Presentational only, same split as `StatusChips`'s
|
||||
// `StatusMenu`: the caller owns what each selection actually does.
|
||||
//
|
||||
// "paused" has no separate resume option here — selecting "up" from a
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
read_harness_state, write_harness_state,
|
||||
};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
|
|
@ -115,13 +115,12 @@ 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
|
||||
/// the next session start; the web UI updates the picker chip to
|
||||
/// reflect it.
|
||||
/// The active claude effort level changed (a swarm value applied or
|
||||
/// cleared). The web UI updates the chip.
|
||||
EffortChanged { effort: String },
|
||||
/// Token usage for the turn just ended. Carries two snapshots:
|
||||
/// - `ctx` is the LAST inference's usage block (the actual context
|
||||
|
|
@ -282,8 +281,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 +340,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.
|
||||
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 +405,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 +560,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 +570,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
|
||||
///
|
||||
|
|
@ -619,26 +600,9 @@ impl Bus {
|
|||
self.effort.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch the effort level for future sessions. Applies on the next
|
||||
/// claude launch (no mid-session swap). Persisted to the agent's
|
||||
/// state dir (`hyperhive-effort`) so the override survives harness
|
||||
/// restart and container rebuild (gone on `--purge`). Callers must
|
||||
/// pre-validate with [`crate::harness_state::is_valid_effort`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_effort(&self, level: impl Into<String>) {
|
||||
let value: String = level.into();
|
||||
if let Err(e) = persist_effort(&value) {
|
||||
tracing::warn!(error = ?e, "effort: persist failed");
|
||||
}
|
||||
self.apply_effort(value, Source::LocalFile);
|
||||
}
|
||||
|
||||
/// Switch the effort level without persisting it, so `hyperhive-effort`
|
||||
/// keeps the agent's local value. For a value the swarm sets
|
||||
/// (`crate::swarm_inference`). Emits `EffortChanged` even when only
|
||||
/// Switch the effort level for future turns. Never writes
|
||||
/// `hyperhive-effort`, so clearing the swarm value finds the agent's local
|
||||
/// value again. For a value the swarm sets (`crate::swarm_inference`). Emits `EffortChanged` 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,17 +28,9 @@ 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.
|
||||
/// Path to the `hyperhive-effort` file. The harness only reads it, as the
|
||||
/// fallback in [`local_effort`]. Overridable via `HYPERHIVE_EFFORT_FILE` for
|
||||
/// dev / tests; otherwise derived from the agent's harness dir.
|
||||
fn effort_file_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|
||||
|| crate::paths::harness_dir().join("hyperhive-effort"),
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -72,10 +65,9 @@ fn model_from(configured: Option<&str>, file: Option<String>) -> (String, Source
|
|||
}
|
||||
|
||||
/// The effort this agent runs at when the swarm sets none, and where it
|
||||
/// comes from. The inverse of [`local_model`]: a persisted operator pick
|
||||
/// wins over `configured` (`HIVE_DEFAULT_EFFORT`), so the runtime choice
|
||||
/// survives a harness restart (it only resets on `--purge`); nix is the
|
||||
/// default for a never-touched agent.
|
||||
/// comes from. The inverse of [`local_model`]: an existing
|
||||
/// `hyperhive-effort` file wins over `configured` (`HIVE_DEFAULT_EFFORT`);
|
||||
/// nix is the default for an agent without one.
|
||||
pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) {
|
||||
effort_from(configured, load_effort())
|
||||
}
|
||||
|
|
@ -88,14 +80,6 @@ fn effort_from(configured: Option<&str>, file: Option<String>) -> (String, Sourc
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
|
||||
let path = effort_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, format!("{level}\n"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consolidated harness state file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -220,13 +204,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.
|
||||
|
|
@ -240,22 +224,11 @@ pub fn configured_model() -> Option<&'static str> {
|
|||
/// into `prompts/claude-settings.json`.
|
||||
pub const DEFAULT_EFFORT: &str = "medium";
|
||||
|
||||
/// Valid claude `--effort` levels, ascending. The operator picker is
|
||||
/// constrained to these; [`is_valid_effort`] guards the persist path.
|
||||
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// True iff `level` is one of [`EFFORT_LEVELS`].
|
||||
#[must_use]
|
||||
pub fn is_valid_effort(level: &str) -> bool {
|
||||
EFFORT_LEVELS.contains(&level)
|
||||
}
|
||||
|
||||
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
|
||||
/// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty.
|
||||
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted
|
||||
/// runtime override takes precedence over this baseline (see `Bus::new`):
|
||||
/// the operator's effort pick sticks across harness restart, with the nix
|
||||
/// value only the default when no override was ever set.
|
||||
/// Mirrors [`configured_model`]'s env shape. Unlike model, a
|
||||
/// `hyperhive-effort` file takes precedence over this baseline (see
|
||||
/// [`local_effort`]).
|
||||
#[must_use]
|
||||
pub fn configured_effort() -> Option<&'static str> {
|
||||
std::env::var("HIVE_DEFAULT_EFFORT")
|
||||
|
|
@ -305,28 +278,11 @@ pub fn context_window_tokens(model: &str) -> u64 {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DEFAULT_EFFORT, DEFAULT_MODEL, EFFORT_LEVELS, Source, effort_from, is_valid_effort,
|
||||
model_from,
|
||||
};
|
||||
use super::{DEFAULT_EFFORT, DEFAULT_MODEL, Source, effort_from, model_from};
|
||||
|
||||
#[test]
|
||||
fn effort_validation_accepts_only_known_levels() {
|
||||
for level in EFFORT_LEVELS {
|
||||
assert!(is_valid_effort(level), "{level} should be valid");
|
||||
}
|
||||
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
|
||||
// picker POST never reaches `claude --effort`.
|
||||
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
|
||||
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_default_effort_is_selectable() {
|
||||
// The fallback must itself be a valid level, and match the value
|
||||
// baked into prompts/claude-settings.json.
|
||||
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
|
||||
fn compiled_default_effort_matches_claude_settings() {
|
||||
// The value baked into prompts/claude-settings.json.
|
||||
assert_eq!(DEFAULT_EFFORT, "medium");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -323,8 +323,9 @@ mod tests {
|
|||
let (swarm, swarm_sources, files, cleared, cleared_sources) =
|
||||
crate::paths::with_harness_dir(|| {
|
||||
let bus = Bus::new();
|
||||
bus.set_model("sonnet");
|
||||
bus.set_effort("low");
|
||||
let dir = crate::paths::harness_dir();
|
||||
std::fs::write(dir.join("hyperhive-model"), "sonnet\n").unwrap();
|
||||
std::fs::write(dir.join("hyperhive-effort"), "low\n").unwrap();
|
||||
let target = Harness {
|
||||
bus: bus.clone(),
|
||||
nix_model: None,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Operator action POST handlers (send, cancel, compact, model, effort,
|
||||
//! reset, todos mark-done).
|
||||
//! Operator action POST handlers (send, cancel, compact, reset, todos
|
||||
//! mark-done).
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
|
|
@ -127,103 +127,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,
|
||||
}
|
||||
|
||||
/// Switch the claude effort level for future sessions. Operator-only
|
||||
/// (the dashboard picker POSTs here through the gateway). Validated
|
||||
/// server-side against [`crate::harness_state::EFFORT_LEVELS`] — an out-of-set
|
||||
/// value is rejected rather than handed to `claude --effort`, since an
|
||||
/// unknown level would fail every subsequent launch. Applies on the next
|
||||
/// session start (no mid-session swap). On ACP the level must instead be one
|
||||
/// the session offers for its model, and applies from the next turn.
|
||||
pub(super) async fn post_set_effort(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<EffortForm>,
|
||||
) -> Response {
|
||||
let level = form.effort.trim();
|
||||
let text = if state.bus.session_choices().is_some() {
|
||||
let offered = super::state::pickers(&state.bus).available_efforts;
|
||||
if let Some(response) = accept_or_refuse("effort", level, &offered) {
|
||||
return response;
|
||||
}
|
||||
format!("operator: /effort — effort set to '{level}' from the next turn")
|
||||
} else if crate::harness_state::is_valid_effort(level) {
|
||||
format!("operator: /effort — claude effort set to '{level}' for future sessions")
|
||||
} else {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"effort: level must be one of {}",
|
||||
crate::harness_state::EFFORT_LEVELS.join(", ")
|
||||
),
|
||||
);
|
||||
};
|
||||
state.bus.set_effort(level);
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text });
|
||||
tracing::info!(%level, "operator set effort");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// The ACP branch of `post_set_model`/`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.
|
||||
fn accept_or_refuse(what: &str, name: &str, offered: &[String]) -> Option<Response> {
|
||||
if offered.iter().any(|o| o == name) {
|
||||
None
|
||||
} else {
|
||||
Some(not_offered(what, offered))
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a pick the ACP session does not offer.
|
||||
fn not_offered(what: &str, offered: &[String]) -> Response {
|
||||
let message = if offered.is_empty() {
|
||||
format!("{what}: the agent's session offers no choice now")
|
||||
} else {
|
||||
format!("{what}: the agent's session offers {}", offered.join(", "))
|
||||
};
|
||||
error_response(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct MarkTodosDoneForm {
|
||||
/// Comma-separated todo ids. Same "one field, JS joins the checked
|
||||
|
|
@ -317,7 +220,7 @@ struct MarkTodosDoneBody {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{accept_or_refuse, mark_ids_done, settle_mark_done};
|
||||
use super::{mark_ids_done, settle_mark_done};
|
||||
|
||||
async fn body_text(response: axum::response::Response) -> String {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
|
|
@ -393,27 +296,4 @@ mod tests {
|
|||
];
|
||||
assert_eq!(settle_mark_done(answers).unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offered_value_is_accepted() {
|
||||
let offered = vec!["m/think".to_owned(), "m/plain".to_owned()];
|
||||
assert!(accept_or_refuse("model", "m/plain", &offered).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_the_session_does_not_offer_is_refused_with_400() {
|
||||
let offered = vec!["m/think".to_owned()];
|
||||
let response = accept_or_refuse("model", "m/plain", &offered).unwrap();
|
||||
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_offered_yet_refuses_with_400() {
|
||||
// Before a session has attached (or on the effort picker while a
|
||||
// model switch is pending), `pickers()` returns an empty list —
|
||||
// every name is refused, not just ones the session once offered
|
||||
// and later dropped.
|
||||
let response = accept_or_refuse("effort", "high", &[]).unwrap();
|
||||
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,28 @@ 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/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 +132,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)
|
||||
|
|
@ -444,9 +447,60 @@ mod tests {
|
|||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{SigintOutcome, sigint_turn};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{AppState, SigintOutcome, routes, sigint_turn};
|
||||
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 and effort, so the agent serves no route for
|
||||
/// either.
|
||||
#[test]
|
||||
fn neither_model_nor_effort_has_a_route() {
|
||||
let (model, effort, compact) = crate::paths::with_harness_dir(|| {
|
||||
let state = AppState {
|
||||
label: "test".to_owned(),
|
||||
login: Arc::new(Mutex::new(LoginState::Online)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus: Bus::new(),
|
||||
socket: crate::paths::harness_dir().join("mcp.sock"),
|
||||
gui_vnc_port: None,
|
||||
interrupted: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let model = app.clone().oneshot(form("/api/model", "model=opus"));
|
||||
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
|
||||
// A route the router does serve, so the 404s above are not
|
||||
// the router refusing everything.
|
||||
let compact = app.oneshot(form("/api/compact", ""));
|
||||
(
|
||||
model.await.unwrap(),
|
||||
effort.await.unwrap(),
|
||||
compact.await.unwrap(),
|
||||
)
|
||||
})
|
||||
});
|
||||
assert_eq!(model.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(compact.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// Poll `path` until it holds a complete, newline-terminated pid.
|
||||
fn read_pid(path: &Path) -> i32 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -48,7 +47,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
let context_window_tokens = state.bus.effective_context_window(&model);
|
||||
let ctx_usage = state.bus.last_ctx_usage();
|
||||
let cost_usage = state.bus.last_cost_usage();
|
||||
let pickers = pickers(&state.bus);
|
||||
let shown = shown(&state.bus);
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
|
|
@ -59,7 +58,8 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
inbox,
|
||||
turn_state,
|
||||
turn_state_since,
|
||||
model: pickers.model,
|
||||
model: shown.model,
|
||||
model_source: state.bus.model_source(),
|
||||
resolved_model,
|
||||
context_window_tokens,
|
||||
ctx_usage,
|
||||
|
|
@ -70,9 +70,8 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
.filter(|s| !s.is_empty()),
|
||||
hive_name: crate::identity::hive_name(),
|
||||
swarm_name: crate::identity::swarm_name(),
|
||||
available_models: pickers.available_models,
|
||||
effort: pickers.effort,
|
||||
available_efforts: pickers.available_efforts,
|
||||
effort: shown.effort,
|
||||
effort_source: state.bus.effort_source(),
|
||||
paused: crate::paths::paused_marker().exists(),
|
||||
})
|
||||
}
|
||||
|
|
@ -138,10 +137,11 @@ pub(super) struct StateSnapshot {
|
|||
/// client-side off this rather than tracking it from SSE events.
|
||||
turn_state: crate::events::TurnState,
|
||||
turn_state_since: i64,
|
||||
/// Currently-active claude model name. Reflected on the page so
|
||||
/// the operator can see what they just switched to (and what's
|
||||
/// in flight). Mutable at runtime via `POST /api/model`.
|
||||
/// Currently-active claude model name, shown on the page. The swarm
|
||||
/// sets it (`crate::swarm_inference`); the page cannot.
|
||||
model: String,
|
||||
/// Where `model` comes from.
|
||||
model_source: Source,
|
||||
/// The concrete model id the most recent completed turn actually
|
||||
/// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that
|
||||
/// turn's assistant events — `model` above is the alias the
|
||||
|
|
@ -182,27 +182,12 @@ pub(super) struct StateSnapshot {
|
|||
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
|
||||
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
|
||||
swarm_name: Option<String>,
|
||||
/// Ordered list of model short-names the operator has declared as
|
||||
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
|
||||
/// (comma-separated, set by `services.hyperhive.availableModels`).
|
||||
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
|
||||
/// absent or empty. The frontend model quick-picker renders one button
|
||||
/// per entry in this list, so operators can add new models or drop
|
||||
/// ones they don't want without touching the frontend code.
|
||||
/// On ACP, the models the session offers, narrowed to this same list
|
||||
/// when at least one entry matches one it offers (see [`pickers`]).
|
||||
available_models: Vec<String>,
|
||||
/// Currently-active claude effort level. Reflected on the page so the
|
||||
/// 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.
|
||||
/// On ACP, the levels the session offers instead, empty when it offers
|
||||
/// none, which hides the picker (see [`pickers`]).
|
||||
available_efforts: Vec<String>,
|
||||
/// Currently-active claude effort level, shown on the page; `None` per
|
||||
/// [`Shown`]. The swarm sets it (`crate::swarm_inference`); the page
|
||||
/// cannot.
|
||||
effort: Option<String>,
|
||||
/// Where `effort` comes from.
|
||||
effort_source: Source,
|
||||
/// Whether this agent's turn loop is currently parked (the harness
|
||||
/// keeps serving this page + its MCP daemons but drives no turns).
|
||||
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
|
||||
|
|
@ -426,133 +411,36 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
|
|||
}
|
||||
}
|
||||
|
||||
/// Parse `HIVE_AVAILABLE_MODELS` (comma-separated, injected by
|
||||
/// `services.hyperhive.agent.availableModels`) into its entries. Empty
|
||||
/// (absent env, or nothing left after trimming) means different things to
|
||||
/// the two callers below, so neither the empty check nor a fallback lives
|
||||
/// here.
|
||||
fn parse_available_models_env() -> Vec<String> {
|
||||
std::env::var("HIVE_AVAILABLE_MODELS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The claude picker's model list. Falls back to `["haiku", "sonnet",
|
||||
/// "opus"]` when the env var is absent or resolves to an empty list after
|
||||
/// trimming.
|
||||
fn available_models() -> Vec<String> {
|
||||
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
|
||||
let models = parse_available_models_env();
|
||||
if models.is_empty() {
|
||||
DEFAULT.iter().map(ToString::to_string).collect()
|
||||
} else {
|
||||
models
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's configured model list, for filtering the ACP picker.
|
||||
/// `None` when the env is absent or empty, so [`filter_offered_models`] can
|
||||
/// tell "nothing configured" apart from a real (if unmatched) list — unlike
|
||||
/// [`available_models`], which folds both into its claude-only fallback.
|
||||
fn configured_models() -> Option<Vec<String>> {
|
||||
let models = parse_available_models_env();
|
||||
(!models.is_empty()).then_some(models)
|
||||
}
|
||||
|
||||
/// Set the first time [`filter_offered_models`] logs its "no match" warning.
|
||||
/// `pickers()` runs on every `/api/state` poll (every 4s per open dashboard
|
||||
/// tab) and on every `post_set_model`/`post_set_effort` call, and all of them
|
||||
/// funnel through the one [`filter_offered_models`] call site in
|
||||
/// [`offered_pickers`], so one flag guards every caller.
|
||||
static MODEL_MISMATCH_WARNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Keep only the entries of `offered` (an ACP session's own model list, in
|
||||
/// its own order) that `configured` names. `configured: None` (the env is
|
||||
/// unset) or a `configured` list matching none of `offered` both leave
|
||||
/// `offered` unfiltered — the latter is what an ACP agent that has never
|
||||
/// set `services.hyperhive.agent.availableModels` hits, since the option's
|
||||
/// default is the claude names, so it must show every model the session
|
||||
/// offers rather than an empty picker. `warned` gates the mismatch warning
|
||||
/// to once per process; tests pass their own instead of [`MODEL_MISMATCH_WARNED`]
|
||||
/// so they don't observe each other's state.
|
||||
fn filter_offered_models(
|
||||
offered: Vec<String>,
|
||||
configured: Option<&[String]>,
|
||||
warned: &AtomicBool,
|
||||
) -> Vec<String> {
|
||||
let Some(configured) = configured else {
|
||||
return offered;
|
||||
};
|
||||
let matching: Vec<String> = offered
|
||||
.iter()
|
||||
.filter(|model| configured.iter().any(|c| c == *model))
|
||||
.cloned()
|
||||
.collect();
|
||||
if matching.is_empty() {
|
||||
if !warned.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
configured = configured.join(", "),
|
||||
"HIVE_AVAILABLE_MODELS matches none of the models this ACP session offers; \
|
||||
showing every model it offers instead"
|
||||
);
|
||||
}
|
||||
offered
|
||||
} else {
|
||||
matching
|
||||
}
|
||||
}
|
||||
|
||||
/// The model and effort pickers: the value each shows and the values it
|
||||
/// offers.
|
||||
/// The model and effort the page shows. No effort on an ACP agent whose
|
||||
/// session offers no effort level now.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Pickers {
|
||||
pub(super) struct Shown {
|
||||
pub(super) model: String,
|
||||
pub(super) available_models: Vec<String>,
|
||||
pub(super) effort: String,
|
||||
pub(super) available_efforts: Vec<String>,
|
||||
pub(super) effort: Option<String>,
|
||||
}
|
||||
|
||||
/// On claude, the requested model and effort, the configured models and
|
||||
/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers
|
||||
/// (see [`offered_pickers`]).
|
||||
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
|
||||
/// On claude, the requested model and effort. On ACP, see [`offered_shown`].
|
||||
pub(super) fn shown(bus: &crate::events::Bus) -> Shown {
|
||||
let (model, effort) = (bus.model(), bus.effort());
|
||||
match bus.session_choices() {
|
||||
Some(choices) => {
|
||||
offered_pickers(model, effort, choices.get(), configured_models().as_deref())
|
||||
}
|
||||
None => Pickers {
|
||||
Some(choices) => offered_shown(model, effort, choices.get()),
|
||||
None => Shown {
|
||||
model,
|
||||
available_models: available_models(),
|
||||
effort,
|
||||
available_efforts: crate::harness_state::EFFORT_LEVELS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
effort: Some(effort),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn offered_pickers(
|
||||
model: String,
|
||||
effort: String,
|
||||
offered: hive_runtime::SessionChoices,
|
||||
configured: Option<&[String]>,
|
||||
) -> Pickers {
|
||||
/// What an ACP agent shows. The runtime sets a requested value 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 no effort shows.
|
||||
fn offered_shown(model: String, effort: String, offered: hive_runtime::SessionChoices) -> Shown {
|
||||
let settable = |wanted: &String, c: &hive_runtime::Choice| {
|
||||
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
|
||||
};
|
||||
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
||||
let pick = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
||||
Some(c) if !settable(&wanted, c) => c.current.clone(),
|
||||
_ => wanted,
|
||||
};
|
||||
|
|
@ -561,24 +449,17 @@ fn offered_pickers(
|
|||
.as_ref()
|
||||
.is_some_and(|c| c.current != model && settable(&model, c));
|
||||
let effort_choice = offered.effort.filter(|_| !model_pending);
|
||||
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(),
|
||||
Shown {
|
||||
model: pick(model, offered.model.as_ref()),
|
||||
effort: effort_choice.map(|c| pick(effort, Some(&c))),
|
||||
}
|
||||
}
|
||||
|
||||
#[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::{Shown, offered_shown};
|
||||
|
||||
fn choice(current: &str, values: &[&str]) -> Choice {
|
||||
Choice {
|
||||
|
|
@ -588,52 +469,52 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|v| (*v).to_owned()).collect()
|
||||
fn shown(model: &str, effort: Option<&str>) -> Shown {
|
||||
Shown {
|
||||
model: model.to_owned(),
|
||||
effort: effort.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_pickers_list_what_the_session_offers() {
|
||||
fn acp_shows_the_requested_values_the_session_offers() {
|
||||
let offered = SessionChoices {
|
||||
model: Some(choice("m/think", &["m/think", "m/plain"])),
|
||||
effort: Some(choice("low", &["low", "high"])),
|
||||
};
|
||||
// `haiku` and `max` are not on offer: the session's own values show.
|
||||
assert_eq!(
|
||||
offered_pickers("haiku".into(), "max".into(), offered.clone(), None),
|
||||
Pickers {
|
||||
model: "m/think".into(),
|
||||
available_models: strings(&["m/think", "m/plain"]),
|
||||
effort: "low".into(),
|
||||
available_efforts: strings(&["low", "high"]),
|
||||
}
|
||||
offered_shown("haiku".into(), "max".into(), offered.clone()),
|
||||
shown("m/think", Some("low"))
|
||||
);
|
||||
assert_eq!(
|
||||
offered_shown("m/think".into(), "high".into(), offered),
|
||||
shown("m/think", Some("high"))
|
||||
);
|
||||
let picked = offered_pickers("m/think".into(), "high".into(), offered, None);
|
||||
assert_eq!(picked.effort, "high");
|
||||
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() {
|
||||
let plain = SessionChoices {
|
||||
model: Some(choice("m/plain", &["m/think", "m/plain"])),
|
||||
effort: None,
|
||||
};
|
||||
let shown = offered_pickers("m/plain".into(), "high".into(), plain, None);
|
||||
assert_eq!(shown.model, "m/plain");
|
||||
assert!(shown.available_efforts.is_empty());
|
||||
|
||||
fn acp_shows_no_effort_without_one_on_offer_or_while_a_model_is_pending() {
|
||||
let think = SessionChoices {
|
||||
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);
|
||||
assert_eq!(pending.model, "m/plain");
|
||||
assert!(pending.available_efforts.is_empty());
|
||||
assert_eq!(
|
||||
offered_shown("m/plain".into(), "max".into(), think),
|
||||
shown("m/plain", None)
|
||||
);
|
||||
let plain = SessionChoices {
|
||||
model: Some(choice("m/plain", &["m/think", "m/plain"])),
|
||||
effort: None,
|
||||
};
|
||||
assert_eq!(
|
||||
offered_shown("m/plain".into(), "high".into(), plain),
|
||||
shown("m/plain", None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
|
||||
fn acp_shows_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
|
||||
let refused = SessionChoices {
|
||||
model: Some(Choice {
|
||||
refused: Some("m/plain".into()),
|
||||
|
|
@ -642,85 +523,16 @@ mod tests {
|
|||
effort: Some(choice("low", &["low", "high"])),
|
||||
};
|
||||
assert_eq!(
|
||||
offered_pickers("m/plain".into(), "high".into(), refused, None),
|
||||
Pickers {
|
||||
model: "m/think".into(),
|
||||
available_models: strings(&["m/think", "m/plain"]),
|
||||
effort: "high".into(),
|
||||
available_efforts: strings(&["low", "high"]),
|
||||
}
|
||||
offered_shown("m/plain".into(), "high".into(), refused),
|
||||
shown("m/think", Some("high"))
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn acp_shows_the_requested_model_and_no_effort_before_a_session_offers_anything() {
|
||||
assert_eq!(
|
||||
filter_offered_models(strings(&["m/think", "m/plain"]), None, &warned),
|
||||
strings(&["m/think", "m/plain"])
|
||||
offered_shown("haiku".into(), "high".into(), SessionChoices::default()),
|
||||
shown("haiku", None)
|
||||
);
|
||||
}
|
||||
|
||||
#[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"]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
|
||||
let raw = std::fs::read_to_string(path).ok()?;
|
||||
|
|
|
|||
|
|
@ -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 (`<provider-id>/<model-id>`); a list
|
||||
that matches none of them shows every model the session offers.
|
||||
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -123,15 +113,15 @@ in
|
|||
description = ''
|
||||
Baseline claude effort level for this agent. Rendered into the
|
||||
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
|
||||
effort as operator-override-file → this env → built-in `"medium"`,
|
||||
and passes the result to `claude --effort` at turn launch.
|
||||
effort as an existing `hyperhive-effort` file → this env → built-in
|
||||
`"medium"`, and passes the result to `claude --effort` at turn launch.
|
||||
An effort the swarm sets for the agent (the swarm UI's agents page, or
|
||||
`swarmctl agent inference set`) wins over all of these.
|
||||
|
||||
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
|
||||
(default — balanced), `"high"` (platform default), `"xhigh"`
|
||||
(recommended for autonomous coding on capable models), `"max"`
|
||||
(maximum thinking budget, highest cost). The operator can override
|
||||
at runtime per-agent via the web UI (applied on the next session);
|
||||
any rebuild that changes this option resets that override.
|
||||
(maximum thinking budget, highest cost).
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -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,16 +426,12 @@ 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"
|
||||
# harness resolves a `hyperhive-effort` file → this env → "medium"
|
||||
# and passes it to claude --effort at turn launch.
|
||||
HIVE_DEFAULT_EFFORT = config.services.hyperhive.agent.effortLevel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ in
|
|||
# un-overridable by user/project/CLI) — so the harness doesn't pass
|
||||
# `--settings` or copy the blob per turn. effortLevel is
|
||||
# deliberately NOT shipped here: effort is controlled live via the
|
||||
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
|
||||
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the swarm's effort setting),
|
||||
# which managed scope would otherwise override and lock.
|
||||
# Hive-enforced settings merged with a per-agent `env` block at BUILD
|
||||
# time via `jq` (not eval-time `readFile`, which would be import-from-
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// A `hyperhive-model` or `hyperhive-effort` file in its harness dir, which
|
||||
/// the agent only reads.
|
||||
LocalFile,
|
||||
/// The harness's built-in default.
|
||||
Default,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// or a `hyperhive-model`/`hyperhive-effort` 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue