Watch
0
0
Fork
You've already forked hyperhive
0

agents: remove the hive-level /effort control too

mara on #5034: "effort shares /models fate". The per-agent web UI no
longer sets effort: the `/effort` slash command, the effort picker,
`POST /api/effort`, `Bus::set_effort`, the `hyperhive-effort` writer,
`EFFORT_LEVELS`/`is_valid_effort` and the ACP effort-list payload are
gone, and `lib/modelEffort.ts` with them. The effort chip is
display-only and shows its source (`effort_source` in `/api/state`);
on ACP it stays hidden while the session offers no effort level.

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

Refs #5034
This commit is contained in:
atlas 2026-10-11 01:26:39 +02:00
commit ed271e655f
25 changed files with 170 additions and 464 deletions

View file

@ -313,7 +313,8 @@ Under `/var/lib/hyperhive/agents/<name>/`:
tables above. tables above.
- `hyperhive-events.sqlite` — turn-loop event log. - `hyperhive-events.sqlite` — turn-loop event log.
- `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-turn-stats.sqlite` — per-turn timing stats.
- `hyperhive-model` — single-line model name file, read-only fallback. - `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 - `work/` — the agent's working space for git worktrees, build dirs
and other large working data. Bind-mounted RW to and other large working data. Bind-mounted RW to
`/agents/<name>/work` inside the container and owned by the agent `/agents/<name>/work` inside the container and owned by the agent

View file

@ -27,8 +27,9 @@ swarmctl agent inference clear iris
The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The
swarm UI's agents page edits the same settings; see [UI](ui.md). The 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 swarm is the only place to change an agent's model or effort at runtime:
shows the model but has no picker and no `/model` command. its web UI shows both but has no picker and no `/model` or `/effort`
command.
## What an agent runs on ## What an agent runs on
@ -48,14 +49,15 @@ Each running value names where it comes from:
| ------------ | -------------------------------------------------------------------------- | | ------------ | -------------------------------------------------------------------------- |
| `swarm` | the value set here | | `swarm` | the value set here |
| `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix | | `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix |
| `local_file` | an `/effort` pick in the agent's web UI, or a `hyperhive-model` file | | `local_file` | a `hyperhive-model` or `hyperhive-effort` file in the agent's harness dir |
| `default` | the harness's built-in default (`haiku`, `medium`) | | `default` | the harness's built-in default (`haiku`, `medium`) |
When the agent starts without a swarm value, its agent.nix model wins over When the agent starts without a swarm value, its agent.nix model wins over
a `hyperhive-model` file, and its `/effort` pick wins over its agent.nix a `hyperhive-model` file, and a `hyperhive-effort` file wins over its
effort. A `hyperhive-model` file comes from a runtime model pick the agent.nix effort. These files come from runtime picks the agent's web UI no
agent's web UI no longer offers; the agent still reads it but never writes longer offers; the agent still reads them but never writes them, so a
it. `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. The agent reports these whenever they change, and again once a minute.
swarm-controller keeps only what it has received since it started, so for up swarm-controller keeps only what it has received since it started, so for up
@ -67,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 read: its next turn runs on the new model or effort, and its web UI badge
shows it. A turn already running keeps what it started with. shows it. A turn already running keeps what it started with.
- **Its own value is the one it runs without the swarm**: its agent.nix, an - **Its own value is the one it runs without the swarm**: its agent.nix or a
`/effort` pick in its web UI, or a `hyperhive-model` file. Setting a swarm `hyperhive-model` / `hyperhive-effort` file. Setting a swarm value doesn't
value doesn't overwrite it. overwrite it.
- **Clearing a field** (or the whole key) puts the agent back on its own - **Clearing a field** (or the whole key) puts the agent back on its own
value on the next read. value on the next read.
- **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 - **After a restart**, the agent runs its own values until its first read
of its settings succeeds. of its settings succeeds.

View file

@ -146,7 +146,7 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
Show or change the model, effort and subagent models the swarm sets for an agent. Show or change the model, effort and subagent models the swarm sets for an agent.
A field the swarm sets wins over the agent's own value (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. 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>` **Usage:** `swarmctl agent inference <COMMAND>`

View file

@ -44,7 +44,7 @@ Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix` read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix`
from the `prompts/claude-settings.json` asset. `effortLevel` is from the `prompts/claude-settings.json` asset. `effortLevel` is
deliberately not in that file — the `--effort` flag 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. live, which managed scope would otherwise lock.
The harness reads `<name>` from `Bus::model()` on each turn. The harness reads `<name>` from `Bus::model()` on each turn.

View file

@ -47,21 +47,20 @@ at a given viewport width. Two columns:
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`. `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
- **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` / - **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix. `○ offline` / `… booting` + age suffix.
- **Model badge** (`model · <name>`): display only. Its tooltip says where - **Model and effort badges** (`model · <name>`, `effort · <level>`):
the model comes from (`state.model_source`: set on the swarm, agent.nix, display only. Each tooltip says where the value comes from
an existing `hyperhive-model` file, or the built-in default) and what the (`state.model_source` / `state.effort_source`: set on the swarm,
last turn resolved it to. The model is set on the swarm: the swarm UI's agent.nix, an existing `hyperhive-model` / `hyperhive-effort` file, or
agents page, or `swarmctl agent inference set` 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)). ([`inference.md`](../swarm/inference.md)).
- **Effort badge** (`effort · <level> ▾`): a real picker — selecting it - On an ACP agent each badge shows the session's own value when it
opens a `Dropdown` of `state.available_efforts` and POSTs `/api/effort` doesn't offer the requested one. The effort badge is hidden
immediately. Shown when `state.available_efforts` is non-empty. (`state.effort` is `null`) until the first turn has attached a
- On an ACP agent the model badge shows the session's own model when it session, when the model offers no effort levels, and while a newly
doesn't offer the requested one, and the effort picker lists the levels set model waits for the next turn, since the levels come with the
its session offers (its `thought_level` config option), empty until the model.
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.
- **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size, - **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size,
tooltip shows % of context window) / `cost · 1.3M` (cumulative tooltip shows % of context window) / `cost · 1.3M` (cumulative
tokens billed across every inference in the last turn). tokens billed across every inference in the last turn).
@ -85,8 +84,8 @@ at a given viewport width. Two columns:
(`GET /api/state`'s `links` field) also feeds (`GET /api/state`'s `links` field) also feeds
`DashboardState.links` for the dashboard card's icon strip — `DashboardState.links` for the dashboard card's icon strip —
`agent_links()` in hive-agent is the single source of truth for `agent_links()` in hive-agent is the single source of truth for
both. The dashboard link lives in this 🔗 popover; the effort picker both. The dashboard link lives in this 🔗 popover, and
is a real badge above; and `/new-session` / `/logout` are `/new-session` / `/logout` are
typed slash commands with their own type-twice confirm (see typed slash commands with their own type-twice confirm (see
below). below).
- `/cancel` (slash command, below) is the only cancel-turn path; the - `/cancel` (slash command, below) is the only cancel-turn path; the
@ -237,10 +236,6 @@ Slash commands today:
runs the `/compact` at the next turn boundary (end of the in-flight runs the `/compact` at the next turn boundary (end of the in-flight
turn, or `turn::run_pending_compact` when idle); output streams into turn, or `turn::run_pending_compact` when idle); output streams into
the live panel. Works mid-turn, not only when idle. the live panel. Works mid-turn, not only when idle.
- `/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). - `/new-session` — `POST /api/new-session` (confirms first).
Arms a one-shot on the Bus; next turn runs without Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely. `--continue`, dropping the resume session entirely.
@ -275,15 +270,6 @@ shaped).
turn — only the stdin payload differs). Flips state to turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits `Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`. `TurnStateChanged`.
- `POST /api/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/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 - `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`. Emits a `LiveEvent::Note`. drop `--continue`. Emits a `LiveEvent::Note`.
- `POST /api/logout` — three-step teardown that re-uses the - `POST /api/logout` — three-step teardown that re-uses the
@ -317,7 +303,7 @@ shaped).
the frontend (`useAgentState.ts`) on page load and, on a poll, while the frontend (`useAgentState.ts`) on page load and, on a poll, while
`status === 'needs_login_in_progress'`. Includes `turn_state`, `status === 'needs_login_in_progress'`. Includes `turn_state`,
`context_window_tokens`, `qualified_label`, `context_window_tokens`, `qualified_label`,
`model`, `model_source`, `effort`, `available_efforts`, `links`, and `model`, `model_source`, `effort`, `effort_source`, `links`, and
other fields described inline throughout this document. All other fields described inline throughout this document. All
subsequent state updates arrive via SSE. subsequent state updates arrive via SSE.
- `GET /api/dashboard-state` — lean snapshot of agent-owned fields - `GET /api/dashboard-state` — lean snapshot of agent-owned fields
@ -391,9 +377,8 @@ Bus events (new vocabulary on `/events/stream`):
429 response and clears it when the retry sleep expires. 429 response and clears it when the retry sleep expires.
- `model_changed { model }` — drives the model chip. The harness emits it - `model_changed { model }` — drives the model chip. The harness emits it
when it applies or clears a swarm value. when it applies or clears a swarm value.
- `effort_changed { effort }` — drives the effort picker chip. - `effort_changed { effort }` — drives the effort chip. The harness
Emitted from `Bus::set_effort` on `POST /api/effort`; applies on emits it when it applies or clears a swarm value.
the next session start.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }` - `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from — drives the ctx + cost badges. Emitted from
`Bus::record_turn_usage` at turn-end; `ctx` is the last `Bus::record_turn_usage` at turn-end; `ctx` is the last

View file

@ -26,7 +26,6 @@ import { useTodos } from "./hooks/useTodos.js";
import { fmtAge, fmtTokens } from "./lib/format.js"; import { fmtAge, fmtTokens } from "./lib/format.js";
import { resolveDashboardBase } from "./lib/dashboardBase.js"; import { resolveDashboardBase } from "./lib/dashboardBase.js";
import { submitPauseResume } from "./lib/pauseAction.js"; import { submitPauseResume } from "./lib/pauseAction.js";
import { postEffort } from "./lib/modelEffort.js";
import { import {
postCancelTurn, postCancelTurn,
postLogout, postLogout,
@ -220,9 +219,6 @@ export function Root() {
statusLabel="… connecting" statusLabel="… connecting"
statusTone="neutral" statusTone="neutral"
model="" model=""
effort=""
availableEfforts={[]}
onSelectEffort={() => {}}
paused={false} paused={false}
onTogglePause={() => {}} onTogglePause={() => {}}
thinking={false} thinking={false}
@ -308,10 +304,7 @@ export function Root() {
resolvedModel={state.resolved_model ?? undefined} resolvedModel={state.resolved_model ?? undefined}
modelSource={state.model_source} modelSource={state.model_source}
effort={state.effort} effort={state.effort}
availableEfforts={state.available_efforts} effortSource={state.effort_source}
onSelectEffort={(level) => {
postEffort(level).then(() => refresh());
}}
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined} ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
costLabel={cost !== null ? fmtTokens(cost) : undefined} costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused} paused={state.paused}

View file

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

View file

@ -1,7 +1,7 @@
// <StatusChips> — the agent page's status row: the consolidated status // <StatusChips> — the agent page's status row: the consolidated status
// badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in // badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in
// its dropdown), the model (display-only: the swarm sets it), the effort // its dropdown), the model and effort (display-only: the swarm sets
// picker, and context/cost usage. // them), and context/cost usage.
// This is the concrete fix for the design guide's own named anti-example // This is the concrete fix for the design guide's own named anti-example
// (the old page's model/effort *pickers* lived in the `⋯` overflow menu // (the old page's model/effort *pickers* lived in the `⋯` overflow menu
// while the current model/effort only showed as a disconnected chip) — // while the current model/effort only showed as a disconnected chip) —
@ -14,7 +14,7 @@
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import type { ModelSource } from "../types.js"; import type { InferenceSource } from "../types.js";
import "./StatusChips.css"; import "./StatusChips.css";
export interface StatusChipsProps { export interface StatusChipsProps {
@ -28,10 +28,10 @@ export interface StatusChipsProps {
* turn has completed. */ * turn has completed. */
resolvedModel?: string; resolvedModel?: string;
/** Where `model` comes from (`/api/state` `model_source`). */ /** Where `model` comes from (`/api/state` `model_source`). */
modelSource?: ModelSource; modelSource?: InferenceSource;
effort: string; /** Hidden when empty: an ACP session that offers no effort now. */
availableEfforts: string[]; effort?: string | null;
onSelectEffort: (level: string) => void; effortSource?: InferenceSource;
ctxLabel?: string; ctxLabel?: string;
costLabel?: string; costLabel?: string;
paused: boolean; paused: boolean;
@ -52,67 +52,12 @@ export interface StatusChipsProps {
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
} }
const SOURCE_LABEL: Record<ModelSource, string> = { const SOURCE_LABEL: Record<InferenceSource, string> = {
swarm: "set on the swarm", swarm: "set on the swarm",
agent_nix: "from agent.nix", agent_nix: "from agent.nix",
local_file: "from its own earlier pick", local_file: "from its own earlier pick",
default: "the built-in default", 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 // The consolidated alive/thinking/paused badge (mara: "why do we
// have separate alive badge? ... it could be one badge that shows // have separate alive badge? ... it could be one badge that shows
// thinking / idle / paused / ... with a dropdown that opens when // thinking / idle / paused / ... with a dropdown that opens when
@ -240,8 +185,7 @@ export function StatusChips({
resolvedModel, resolvedModel,
modelSource, modelSource,
effort, effort,
availableEfforts, effortSource,
onSelectEffort,
ctxLabel, ctxLabel,
costLabel, costLabel,
paused, paused,
@ -277,13 +221,11 @@ export function StatusChips({
.join(" · ") || undefined .join(" · ") || undefined
} }
/> />
{availableEfforts.length ? ( {effort ? (
<Picker <Badge
label="effort" label="effort"
value={effort} value={effort}
options={availableEfforts} title={effortSource ? SOURCE_LABEL[effortSource] : undefined}
descriptions={EFFORT_DESCRIPTIONS}
onSelect={onSelectEffort}
/> />
) : null} ) : null}
{ctxLabel ? ( {ctxLabel ? (

View file

@ -11,7 +11,6 @@
// (a local note explains what confirming does), typing it again within // (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm. // the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { postEffort } from "../lib/modelEffort.js";
import { import {
postCancelTurn, postCancelTurn,
postCompact, postCompact,
@ -32,10 +31,6 @@ const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: "/clear", desc: "wipe the terminal panel (local-only)" }, { name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" }, { name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" }, { name: "/compact", desc: "compact the persistent claude session" },
{
name: "/effort",
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
},
{ {
name: "/new-session", name: "/new-session",
desc: "fresh claude session next turn (type twice to confirm)", desc: "fresh claude session next turn (type twice to confirm)",
@ -80,8 +75,7 @@ export function TermInput({
function handleSlash(line: string): boolean { function handleSlash(line: string): boolean {
const trimmed = line.trim(); const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/); const [cmd] = trimmed.split(/\s+/);
const arg = rest.join(" ");
const wasArmed = armedRef.current === cmd; const wasArmed = armedRef.current === cmd;
armedRef.current = null; armedRef.current = null;
@ -127,16 +121,6 @@ export function TermInput({
); );
} }
return true; 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: default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`); onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
return true; return true;

View file

@ -14,7 +14,7 @@ const RETRY_MS = 5000;
export interface UseAgentStateResult { export interface UseAgentStateResult {
state: AgentState | null; state: AgentState | null;
error: string | 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; refresh: () => void;
} }

View file

@ -2,7 +2,7 @@
// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a // (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a
// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin // plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin
// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` / // 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 // these three routes actually redirect today, but the same tolerant
// check costs nothing and keeps every same-origin POST in this package // check costs nothing and keeps every same-origin POST in this package
// behaving identically. // behaving identically.

View file

@ -1,38 +0,0 @@
// 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
// 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 postEffort = (level: string) =>
post("api/effort", "effort", level);

View file

@ -5,8 +5,7 @@
// origin, but accessed directly they're different ports) and this way // 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 // hive-c0re doesn't need to grow CORS headers for it — the tradeoff is
// an opaque, unreadable response, which is fine here since the caller // an opaque, unreadable response, which is fine here since the caller
// doesn't need to read it, only trigger a state refresh afterward (same // doesn't need to read it, only trigger a state refresh afterward. `credentials:
// shape as `postEffort` in modelEffort.ts). `credentials:
// 'include'` matches the cookie-sending behavior of the plain <form> // 'include'` matches the cookie-sending behavior of the plain <form>
// submit this replaces. // submit this replaces.
// //

View file

@ -1,6 +1,5 @@
// Same-origin fetch POST helpers for the term-input slash commands — // Same-origin fetch POST helpers for the term-input slash commands
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than // ({ ok, detail } rather than throwing), reused by TermInput.tsx.
// throwing), reused by TermInput.tsx.
async function post( async function post(
path: string, path: string,
body?: URLSearchParams, body?: URLSearchParams,

View file

@ -5,7 +5,7 @@
// Extend as more of the page gets rebuilt against real data. // Extend as more of the page gets rebuilt against real data.
// Mirrors `swarm_queue_client::agent_inference::Source`. // Mirrors `swarm_queue_client::agent_inference::Source`.
export type ModelSource = "swarm" | "agent_nix" | "local_file" | "default"; export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
export interface AgentState { export interface AgentState {
seq: number; seq: number;
@ -27,9 +27,9 @@ export interface AgentState {
cost_usage: TokenUsage | null; cost_usage: TokenUsage | null;
hive_name: string | null; hive_name: string | null;
swarm_name: string | null; swarm_name: string | null;
model_source: ModelSource; model_source: InferenceSource;
effort: string; effort: string | null;
available_efforts: string[]; effort_source: InferenceSource;
paused: boolean; paused: boolean;
inbox: InboxRow[]; inbox: InboxRow[];
links: AgentLink[]; links: AgentLink[];

View file

@ -21,7 +21,7 @@ use tokio::sync::broadcast;
use crate::harness_state::{ use crate::harness_state::{
configured_effort, configured_model, context_window_tokens, local_effort, local_model, configured_effort, configured_model, context_window_tokens, local_effort, local_model,
persist_effort, read_harness_state, write_harness_state, read_harness_state, write_harness_state,
}; };
const CHANNEL_CAPACITY: usize = 256; const CHANNEL_CAPACITY: usize = 256;
@ -119,9 +119,8 @@ pub enum LiveEvent {
/// The web UI updates the chip + the per-turn stats sink will key off this /// The web UI updates the chip + the per-turn stats sink will key off this
/// to mark the boundary in its log. /// to mark the boundary in its log.
ModelChanged { model: String }, ModelChanged { model: String },
/// `/api/effort` switched the active claude effort level. Applies on /// The active claude effort level changed (a swarm value applied or
/// the next session start; the web UI updates the picker chip to /// cleared). The web UI updates the chip.
/// reflect it.
EffortChanged { effort: String }, EffortChanged { effort: String },
/// Token usage for the turn just ended. Carries two snapshots: /// Token usage for the turn just ended. Carries two snapshots:
/// - `ctx` is the LAST inference's usage block (the actual context /// - `ctx` is the LAST inference's usage block (the actual context
@ -341,7 +340,7 @@ pub struct Bus {
turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>, turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>,
/// The model and effort the session offers, for a runtime that reads them /// The model and effort the session offers, for a runtime that reads them
/// from its session (ACP). Set once by the serve loop when it builds the /// from its session (ACP). Set once by the serve loop when it builds the
/// session; unset on claude, whose effort picker lists `EFFORT_LEVELS`. /// session; unset on claude.
session_choices: Arc<OnceLock<hive_runtime::Choices>>, session_choices: Arc<OnceLock<hive_runtime::Choices>>,
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the /// Current fresh-claude-session id (FK to `sessions.id`). Set by the
/// bin loop after minting a session row on a fresh start; stamped onto /// bin loop after minting a session row on a fresh start; stamped onto
@ -601,26 +600,9 @@ impl Bus {
self.effort.lock().unwrap().clone() self.effort.lock().unwrap().clone()
} }
/// Switch the effort level for future sessions. Applies on the next /// Switch the effort level for future turns. Never writes
/// claude launch (no mid-session swap). Persisted to the agent's /// `hyperhive-effort`, so clearing the swarm value finds the agent's local
/// state dir (`hyperhive-effort`) so the override survives harness /// value again. For a value the swarm sets (`crate::swarm_inference`). Emits `EffortChanged` even when only
/// 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
/// `source` changed. /// `source` changed.
/// ///
/// # Panics /// # Panics

View file

@ -28,9 +28,9 @@ pub(crate) fn load_model() -> Option<String> {
} }
} }
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`, /// Path to the `hyperhive-effort` file. The harness only reads it, as the
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise /// fallback in [`local_effort`]. Overridable via `HYPERHIVE_EFFORT_FILE` for
/// derived from the agent's harness dir. /// dev / tests; otherwise derived from the agent's harness dir.
fn effort_file_path() -> PathBuf { fn effort_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else( std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-effort"), || crate::paths::harness_dir().join("hyperhive-effort"),
@ -65,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 /// 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 /// comes from. The inverse of [`local_model`]: an existing
/// wins over `configured` (`HIVE_DEFAULT_EFFORT`), so the runtime choice /// `hyperhive-effort` file wins over `configured` (`HIVE_DEFAULT_EFFORT`);
/// survives a harness restart (it only resets on `--purge`); nix is the /// nix is the default for an agent without one.
/// default for a never-touched agent.
pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) { pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) {
effort_from(configured, load_effort()) effort_from(configured, load_effort())
} }
@ -81,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 // Consolidated harness state file
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -233,22 +224,11 @@ pub fn configured_model() -> Option<&'static str> {
/// into `prompts/claude-settings.json`. /// into `prompts/claude-settings.json`.
pub const DEFAULT_EFFORT: &str = "medium"; 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 /// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
/// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty. /// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty.
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted /// Mirrors [`configured_model`]'s env shape. Unlike model, a
/// runtime override takes precedence over this baseline (see `Bus::new`): /// `hyperhive-effort` file takes precedence over this baseline (see
/// the operator's effort pick sticks across harness restart, with the nix /// [`local_effort`]).
/// value only the default when no override was ever set.
#[must_use] #[must_use]
pub fn configured_effort() -> Option<&'static str> { pub fn configured_effort() -> Option<&'static str> {
std::env::var("HIVE_DEFAULT_EFFORT") std::env::var("HIVE_DEFAULT_EFFORT")
@ -298,28 +278,11 @@ pub fn context_window_tokens(model: &str) -> u64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{DEFAULT_EFFORT, DEFAULT_MODEL, Source, effort_from, model_from};
DEFAULT_EFFORT, DEFAULT_MODEL, EFFORT_LEVELS, Source, effort_from, is_valid_effort,
model_from,
};
#[test] #[test]
fn effort_validation_accepts_only_known_levels() { fn compiled_default_effort_matches_claude_settings() {
for level in EFFORT_LEVELS { // The value baked into prompts/claude-settings.json.
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));
assert_eq!(DEFAULT_EFFORT, "medium"); assert_eq!(DEFAULT_EFFORT, "medium");
} }

View file

@ -329,7 +329,7 @@ mod tests {
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) }; unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
let bus = Bus::new(); let bus = Bus::new();
std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap(); std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap();
bus.set_effort("low"); std::fs::write(dir.path().join("hyperhive-effort"), "low\n").unwrap();
let target = Harness { let target = Harness {
bus: bus.clone(), bus: bus.clone(),
nix_model: None, nix_model: None,

View file

@ -1,5 +1,5 @@
//! Operator action POST handlers (send, cancel, compact, model, effort, //! Operator action POST handlers (send, cancel, compact, reset, todos
//! reset, todos mark-done). //! mark-done).
use axum::{ use axum::{
Form, Form,
@ -126,68 +126,6 @@ pub(super) async fn post_new_session(State(state): State<AppState>) -> Response
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
#[derive(Deserialize)]
pub(super) struct 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_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)] #[derive(Deserialize)]
pub(super) struct MarkTodosDoneForm { pub(super) struct MarkTodosDoneForm {
/// Comma-separated todo ids. Same "one field, JS joins the checked /// Comma-separated todo ids. Same "one field, JS joins the checked
@ -230,31 +168,3 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
struct MarkTodosDoneBody { struct MarkTodosDoneBody {
acked: u64, acked: u64,
} }
#[cfg(test)]
mod tests {
use super::accept_or_refuse;
#[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);
}
}

View file

@ -80,7 +80,6 @@ fn routes() -> Router<AppState> {
.route("/login/cancel", post(auth::post_login_cancel)) .route("/login/cancel", post(auth::post_login_cancel))
.route("/api/cancel", post(actions::post_cancel_turn)) .route("/api/cancel", post(actions::post_cancel_turn))
.route("/api/compact", post(actions::post_compact)) .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/new-session", post(actions::post_new_session))
.route("/api/logout", post(auth::post_logout)) .route("/api/logout", post(auth::post_logout))
.route("/api/todos", get(stats::api_todos)) .route("/api/todos", get(stats::api_todos))
@ -444,10 +443,10 @@ mod tests {
.unwrap() .unwrap()
} }
/// The swarm sets the model, so the agent serves no route for it; effort /// The swarm sets the model and effort, so the agent serves no route for
/// is still set here. /// either.
#[test] #[test]
fn the_model_has_no_route_and_effort_still_does() { fn neither_model_nor_effort_has_a_route() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let _env = crate::paths::HARNESS_DIR_ENV let _env = crate::paths::HARNESS_DIR_ENV
.lock() .lock()
@ -465,15 +464,20 @@ mod tests {
interrupted: Arc::new(AtomicBool::new(false)), interrupted: Arc::new(AtomicBool::new(false)),
}; };
let app = routes().with_state(state); let app = routes().with_state(state);
let (model, effort) = tokio::runtime::Builder::new_current_thread() let (model, effort, compact) = tokio::runtime::Builder::new_current_thread()
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
let model = app.clone().oneshot(form("/api/model", "model=opus")); let model = app.clone().oneshot(form("/api/model", "model=opus"));
// An unknown level reaches the handler, which refuses it let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
// before anything is applied. // A route the router does serve, so the 404s above are not
let effort = app.oneshot(form("/api/effort", "effort=bogus")); // the router refusing everything.
(model.await.unwrap(), effort.await.unwrap()) let compact = app.oneshot(form("/api/compact", ""));
(
model.await.unwrap(),
effort.await.unwrap(),
compact.await.unwrap(),
)
}); });
match prev { match prev {
// SAFETY: as above. // SAFETY: as above.
@ -481,6 +485,7 @@ mod tests {
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") }, None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
} }
assert_eq!(model.status(), StatusCode::NOT_FOUND); assert_eq!(model.status(), StatusCode::NOT_FOUND);
assert_eq!(effort.status(), StatusCode::BAD_REQUEST); assert_eq!(effort.status(), StatusCode::NOT_FOUND);
assert_eq!(compact.status(), StatusCode::OK);
} }
} }

View file

@ -47,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 context_window_tokens = state.bus.effective_context_window(&model);
let ctx_usage = state.bus.last_ctx_usage(); let ctx_usage = state.bus.last_ctx_usage();
let cost_usage = state.bus.last_cost_usage(); let cost_usage = state.bus.last_cost_usage();
let pickers = pickers(&state.bus); let shown = shown(&state.bus);
axum::Json(StateSnapshot { axum::Json(StateSnapshot {
seq, seq,
label: state.label.clone(), label: state.label.clone(),
@ -58,7 +58,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
inbox, inbox,
turn_state, turn_state,
turn_state_since, turn_state_since,
model: pickers.model, model: shown.model,
model_source: state.bus.model_source(), model_source: state.bus.model_source(),
resolved_model, resolved_model,
context_window_tokens, context_window_tokens,
@ -70,8 +70,8 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),
hive_name: crate::identity::hive_name(), hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(), swarm_name: crate::identity::swarm_name(),
effort: pickers.effort, effort: shown.effort,
available_efforts: pickers.available_efforts, effort_source: state.bus.effort_source(),
paused: crate::paths::paused_marker().exists(), paused: crate::paths::paused_marker().exists(),
}) })
} }
@ -182,17 +182,12 @@ pub(super) struct StateSnapshot {
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from /// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
/// `HYPERHIVE_SWARM_NAME`; `None` when unset. /// `HYPERHIVE_SWARM_NAME`; `None` when unset.
swarm_name: Option<String>, swarm_name: Option<String>,
/// Currently-active claude effort level. Reflected on the page so the /// Currently-active claude effort level, shown on the page; `None` per
/// operator's effort picker shows the live selection. Mutable at /// [`Shown`]. The swarm sets it (`crate::swarm_inference`); the page
/// runtime via `POST /api/effort`; applies on the next session. /// cannot.
effort: String, effort: Option<String>,
/// Selectable effort levels for the picker, ascending. Fixed set /// Where `effort` comes from.
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from effort_source: Source,
/// [`crate::harness_state::EFFORT_LEVELS`]. The frontend renders one
/// button per entry.
/// On ACP, the levels the session offers instead, empty when it offers
/// none, which hides the picker (see [`pickers`]).
available_efforts: Vec<String>,
/// Whether this agent's turn loop is currently parked (the harness /// Whether this agent's turn loop is currently parked (the harness
/// keeps serving this page + its MCP daemons but drives no turns). /// keeps serving this page + its MCP daemons but drives no turns).
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks /// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
@ -416,48 +411,36 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
} }
} }
/// The model the page shows, and the effort picker: the value it shows and /// The model and effort the page shows. No effort on an ACP agent whose
/// the values it offers. /// session offers no effort level now.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub(super) struct Pickers { pub(super) struct Shown {
pub(super) model: String, pub(super) model: String,
pub(super) effort: String, pub(super) effort: Option<String>,
pub(super) available_efforts: Vec<String>,
} }
/// On claude, the requested model and effort and /// On claude, the requested model and effort. On ACP, see [`offered_shown`].
/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers pub(super) fn shown(bus: &crate::events::Bus) -> Shown {
/// (see [`offered_pickers`]).
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
let (model, effort) = (bus.model(), bus.effort()); let (model, effort) = (bus.model(), bus.effort());
match bus.session_choices() { match bus.session_choices() {
Some(choices) => offered_pickers(model, effort, choices.get()), Some(choices) => offered_shown(model, effort, choices.get()),
None => Pickers { None => Shown {
model, model,
effort, effort: Some(effort),
available_efforts: crate::harness_state::EFFORT_LEVELS
.iter()
.map(ToString::to_string)
.collect(),
}, },
} }
} }
/// Pickers for what an ACP session offers. The runtime sets a requested value /// What an ACP agent shows. The runtime sets a requested value on the next
/// on the next turn, and only if the session offers it, so the model and the /// turn, and only if the session offers it, so the model and the effort each
/// effort each show the requested value when offered and not refused by the /// show the requested value when offered and not refused by the agent, and
/// agent, and the session's own otherwise. The effort levels on offer come /// the session's own otherwise. The effort levels on offer come with the
/// with the model, so while a new model waits for the next turn the effort /// model, so while a new model waits for the next turn no effort shows.
/// picker is empty. fn offered_shown(model: String, effort: String, offered: hive_runtime::SessionChoices) -> Shown {
fn offered_pickers(
model: String,
effort: String,
offered: hive_runtime::SessionChoices,
) -> Pickers {
let settable = |wanted: &String, c: &hive_runtime::Choice| { let settable = |wanted: &String, c: &hive_runtime::Choice| {
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted) c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
}; };
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(), Some(c) if !settable(&wanted, c) => c.current.clone(),
_ => wanted, _ => wanted,
}; };
@ -466,10 +449,9 @@ fn offered_pickers(
.as_ref() .as_ref()
.is_some_and(|c| c.current != model && settable(&model, c)); .is_some_and(|c| c.current != model && settable(&model, c));
let effort_choice = offered.effort.filter(|_| !model_pending); let effort_choice = offered.effort.filter(|_| !model_pending);
Pickers { Shown {
model: shown(model, offered.model.as_ref()), model: pick(model, offered.model.as_ref()),
effort: shown(effort, effort_choice.as_ref()), effort: effort_choice.map(|c| pick(effort, Some(&c))),
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
} }
} }
@ -477,7 +459,7 @@ fn offered_pickers(
mod tests { mod tests {
use hive_runtime::{Choice, SessionChoices}; use hive_runtime::{Choice, SessionChoices};
use super::{Pickers, offered_pickers}; use super::{Shown, offered_shown};
fn choice(current: &str, values: &[&str]) -> Choice { fn choice(current: &str, values: &[&str]) -> Choice {
Choice { Choice {
@ -487,51 +469,52 @@ mod tests {
} }
} }
fn strings(values: &[&str]) -> Vec<String> { fn shown(model: &str, effort: Option<&str>) -> Shown {
values.iter().map(|v| (*v).to_owned()).collect() Shown {
model: model.to_owned(),
effort: effort.map(str::to_owned),
}
} }
#[test] #[test]
fn acp_pickers_list_what_the_session_offers() { fn acp_shows_the_requested_values_the_session_offers() {
let offered = SessionChoices { let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])), model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])), effort: Some(choice("low", &["low", "high"])),
}; };
// `haiku` and `max` are not on offer: the session's own values show. // `haiku` and `max` are not on offer: the session's own values show.
assert_eq!( assert_eq!(
offered_pickers("haiku".into(), "max".into(), offered.clone()), offered_shown("haiku".into(), "max".into(), offered.clone()),
Pickers { shown("m/think", Some("low"))
model: "m/think".into(), );
effort: "low".into(), assert_eq!(
available_efforts: strings(&["low", "high"]), offered_shown("m/think".into(), "high".into(), offered),
} shown("m/think", Some("high"))
); );
let picked = offered_pickers("m/think".into(), "high".into(), offered);
assert_eq!(picked.effort, "high");
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
} }
#[test] #[test]
fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() { fn acp_shows_no_effort_without_one_on_offer_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);
assert_eq!(shown.model, "m/plain");
assert!(shown.available_efforts.is_empty());
let think = SessionChoices { let think = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])), model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])), effort: Some(choice("low", &["low", "high"])),
}; };
let pending = offered_pickers("m/plain".into(), "high".into(), think); assert_eq!(
assert_eq!(pending.model, "m/plain"); offered_shown("m/plain".into(), "max".into(), think),
assert!(pending.available_efforts.is_empty()); 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] #[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 { let refused = SessionChoices {
model: Some(Choice { model: Some(Choice {
refused: Some("m/plain".into()), refused: Some("m/plain".into()),
@ -540,18 +523,16 @@ mod tests {
effort: Some(choice("low", &["low", "high"])), effort: Some(choice("low", &["low", "high"])),
}; };
assert_eq!( assert_eq!(
offered_pickers("m/plain".into(), "high".into(), refused), offered_shown("m/plain".into(), "high".into(), refused),
Pickers { shown("m/think", Some("high"))
model: "m/think".into(),
effort: "high".into(),
available_efforts: strings(&["low", "high"]),
}
); );
} }
#[test] #[test]
fn acp_pickers_are_empty_before_a_session_offers_anything() { fn acp_shows_the_requested_model_and_no_effort_before_a_session_offers_anything() {
let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default()); assert_eq!(
assert!(none.available_efforts.is_empty()); offered_shown("haiku".into(), "high".into(), SessionChoices::default()),
shown("haiku", None)
);
} }
} }

View file

@ -113,15 +113,15 @@ in
description = '' description = ''
Baseline claude effort level for this agent. Rendered into the Baseline claude effort level for this agent. Rendered into the
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves `HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
effort as operator-override-file → this env → built-in `"medium"`, effort as an existing `hyperhive-effort` file → this env → built-in
and passes the result to `claude --effort` at turn launch. `"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"` Ascending scale: `"low"` (minimal thinking budget), `"medium"`
(default — balanced), `"high"` (platform default), `"xhigh"` (default — balanced), `"high"` (platform default), `"xhigh"`
(recommended for autonomous coding on capable models), `"max"` (recommended for autonomous coding on capable models), `"max"`
(maximum thinking budget, highest cost). The operator can override (maximum thinking budget, highest cost).
at runtime per-agent via the web UI (applied on the next session);
any rebuild that changes this option resets that override.
''; '';
}; };
@ -431,7 +431,7 @@ in
environment.variables = { environment.variables = {
HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model; HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model;
# Per-agent baseline effort (see services.hyperhive.agent.effortLevel). The # Per-agent baseline effort (see services.hyperhive.agent.effortLevel). The
# harness resolves operator-override-file → this env → "medium" # harness resolves a `hyperhive-effort` file → this env → "medium"
# and passes it to claude --effort at turn launch. # and passes it to claude --effort at turn launch.
HIVE_DEFAULT_EFFORT = config.services.hyperhive.agent.effortLevel; HIVE_DEFAULT_EFFORT = config.services.hyperhive.agent.effortLevel;
} }

View file

@ -274,7 +274,7 @@ in
# un-overridable by user/project/CLI) — so the harness doesn't pass # un-overridable by user/project/CLI) — so the harness doesn't pass
# `--settings` or copy the blob per turn. effortLevel is # `--settings` or copy the blob per turn. effortLevel is
# deliberately NOT shipped here: effort is controlled live via the # 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. # which managed scope would otherwise override and lock.
# Hive-enforced settings merged with a per-agent `env` block at BUILD # Hive-enforced settings merged with a per-agent `env` block at BUILD
# time via `jq` (not eval-time `readFile`, which would be import-from- # time via `jq` (not eval-time `readFile`, which would be import-from-

View file

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

View file

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