diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 85aac555..bcea11ac 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -313,7 +313,8 @@ Under `/var/lib/hyperhive/agents//`: tables above. - `hyperhive-events.sqlite` — turn-loop event log. - `hyperhive-turn-stats.sqlite` — per-turn timing stats. - - `hyperhive-model` — single-line model name 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 and other large working data. Bind-mounted RW to `/agents//work` inside the container and owned by the agent diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 3490c29b..dfc257c9 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -27,8 +27,9 @@ swarmctl agent inference clear iris 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 is the only place to change an agent's model at runtime: its web UI -shows the model but has no picker and no `/model` command. +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 @@ -48,14 +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` | 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`) | 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 -effort. A `hyperhive-model` file comes from a runtime model pick the -agent's web UI no longer offers; the agent still reads it but never writes -it. +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 @@ -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 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 - `/effort` pick in its web UI, or a `hyperhive-model` file. 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. -- **An `/effort` pick while the swarm sets effort** changes the agent's own - value, but the swarm value comes back on the next read. - **After a restart**, the agent runs its own values until its first read of its settings succeeds. diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 0b7cf52d..ea9b3588 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -146,7 +146,7 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo Show or change the model, effort and subagent models the swarm sets for an agent. -A field the swarm sets wins over the agent's own value (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 ` diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index d5c58925..e6d3cf3e 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -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` 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 `` from `Bus::model()` on each turn. diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 11bce384..cafc87e9 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -47,21 +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 · `): display only. Its tooltip says where - the model comes from (`state.model_source`: set on the swarm, agent.nix, - an existing `hyperhive-model` file, or the built-in default) and what the - last turn resolved it to. The model is set on the swarm: the swarm UI's - agents page, or `swarmctl agent inference set` + - **Model and effort badges** (`model · `, `effort · `): + 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)). - - **Effort badge** (`effort · ▾`): a real picker — selecting it - opens a `Dropdown` of `state.available_efforts` and POSTs `/api/effort` - immediately. Shown when `state.available_efforts` is non-empty. - - On an ACP agent the model badge shows the session's own model when it - doesn't offer the requested one, and the effort picker lists the levels - its session offers (its `thought_level` config option), empty until the - first turn has attached a session. The effort picker is hidden when the - model offers no effort levels, and while a newly picked model waits for - the next turn, since the levels come with the model. + - 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). @@ -85,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 effort picker - is a real badge 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 @@ -237,10 +236,6 @@ Slash commands today: runs the `/compact` at the next turn boundary (end of the in-flight turn, or `turn::run_pending_compact` when idle); output streams into the live panel. Works mid-turn, not only when idle. -- `/effort ` — `POST /api/effort` setting the claude effort - level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next - turn. `StatusChips`' effort badge calls the same endpoint; both stay - 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. @@ -275,15 +270,6 @@ shaped). turn — only the stdin payload differs). Flips state to `Compacting` via `Bus::set_state`, which emits `TurnStateChanged`. -- `POST /api/effort` (`effort=`) — switch the claude - effort level for future sessions. Validated server-side against - `EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are - rejected rather than forwarded to `claude --effort`. Persists via - `Bus::set_effort`, which emits `EffortChanged`. Applies on the next - session start (no mid-session swap). -- On an ACP agent, `/api/effort` accepts only values the session offers, - and the runtime sets the level with `session/set_config_option` before - the next prompt. - `POST /api/new-session` — arm a one-shot for the next turn to drop `--continue`. Emits a `LiveEvent::Note`. - `POST /api/logout` — three-step teardown that re-uses the @@ -317,7 +303,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`, - `model`, `model_source`, `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 @@ -391,9 +377,8 @@ Bus events (new vocabulary on `/events/stream`): 429 response and clears it when the retry sleep expires. - `model_changed { model }` — drives the model chip. The harness emits it when it applies or clears a swarm value. -- `effort_changed { effort }` — drives the effort picker chip. - Emitted from `Bus::set_effort` on `POST /api/effort`; applies on - the next session start. +- `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 diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 360cd657..bf98c97c 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -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 { postEffort } from "./lib/modelEffort.js"; import { postCancelTurn, postLogout, @@ -220,9 +219,6 @@ export function Root() { statusLabel="… connecting" statusTone="neutral" model="" - effort="" - availableEfforts={[]} - onSelectEffort={() => {}} paused={false} onTogglePause={() => {}} thinking={false} @@ -308,10 +304,7 @@ export function Root() { resolvedModel={state.resolved_model ?? undefined} 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} diff --git a/frontend/packages/agent/src/components/StatusChips.css b/frontend/packages/agent/src/components/StatusChips.css index 32f54350..73cd33a6 100644 --- a/frontend/packages/agent/src/components/StatusChips.css +++ b/frontend/packages/agent/src/components/StatusChips.css @@ -13,8 +13,8 @@ /* mara, live, on a screenshot of this row: "badges and interactive elements on same row have inconsistent size." Real cause: Badge.css's `.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor - (effort picker, pause) while a plain display `` badge - (model, alive, state, ctx, cost — no `onClick`) stays compact, so the row + (pause) while a plain display `` 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 `` component. Scoped to this row rather than Badge.css itself — display-only badges elsewhere (e.g. the dashboard) diff --git a/frontend/packages/agent/src/components/StatusChips.tsx b/frontend/packages/agent/src/components/StatusChips.tsx index 0e7bedcf..febe8183 100644 --- a/frontend/packages/agent/src/components/StatusChips.tsx +++ b/frontend/packages/agent/src/components/StatusChips.tsx @@ -1,7 +1,7 @@ // — the agent page's status row: the consolidated status // badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in -// its dropdown), the model (display-only: the swarm sets it), the effort -// picker, 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) — @@ -14,7 +14,7 @@ import { useRef, useState } from "preact/hooks"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; -import type { ModelSource } from "../types.js"; +import type { InferenceSource } from "../types.js"; import "./StatusChips.css"; export interface StatusChipsProps { @@ -28,10 +28,10 @@ export interface StatusChipsProps { * turn has completed. */ resolvedModel?: string; /** Where `model` comes from (`/api/state` `model_source`). */ - modelSource?: ModelSource; - effort: string; - availableEfforts: string[]; - onSelectEffort: (level: string) => void; + modelSource?: InferenceSource; + /** Hidden when empty: an ACP session that offers no effort now. */ + effort?: string | null; + effortSource?: InferenceSource; ctxLabel?: string; costLabel?: string; paused: boolean; @@ -52,67 +52,12 @@ export interface StatusChipsProps { onLogout: () => Promise; } -const SOURCE_LABEL: Record = { +const SOURCE_LABEL: Record = { swarm: "set on the swarm", agent_nix: "from agent.nix", local_file: "from its own earlier pick", default: "the built-in default", }; -const EFFORT_DESCRIPTIONS: Record = { - low: "", - medium: "default", - high: "", - xhigh: "", - max: "", -}; - -function Picker({ - label, - value, - options, - descriptions, - onSelect, - title, -}: { - label: string; - value: string; - options: string[]; - descriptions: Record; - onSelect: (v: string) => void; - title?: string; -}) { - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); - const dropdownOptions: DropdownOption[] = options.map((name) => ({ - value: name, - label: name, - description: descriptions[name] || undefined, - })); - return ( -
- setOpen((o) => !o)} - expanded={open} - title={title} - /> - { - onSelect(v); - setOpen(false); - }} - onClose={() => setOpen(false)} - anchorRef={anchorRef} - /> -
- ); -} - // 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 @@ -240,8 +185,7 @@ export function StatusChips({ resolvedModel, modelSource, effort, - availableEfforts, - onSelectEffort, + effortSource, ctxLabel, costLabel, paused, @@ -277,13 +221,11 @@ export function StatusChips({ .join(" · ") || undefined } /> - {availableEfforts.length ? ( - ) : null} {ctxLabel ? ( diff --git a/frontend/packages/agent/src/components/TermInput.tsx b/frontend/packages/agent/src/components/TermInput.tsx index ca02cebc..bf7621b4 100644 --- a/frontend/packages/agent/src/components/TermInput.tsx +++ b/frontend/packages/agent/src/components/TermInput.tsx @@ -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 { postEffort } from "../lib/modelEffort.js"; import { postCancelTurn, postCompact, @@ -32,10 +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: "/effort", - desc: "/effort — set claude effort (medium/high/xhigh) for future turns", - }, { name: "/new-session", desc: "fresh claude session next turn (type twice to confirm)", @@ -80,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; @@ -127,16 +121,6 @@ export function TermInput({ ); } 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; diff --git a/frontend/packages/agent/src/hooks/useAgentState.ts b/frontend/packages/agent/src/hooks/useAgentState.ts index 4d6d16db..27b83d57 100644 --- a/frontend/packages/agent/src/hooks/useAgentState.ts +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -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; } diff --git a/frontend/packages/agent/src/lib/loginAction.ts b/frontend/packages/agent/src/lib/loginAction.ts index e647d780..9c58eedf 100644 --- a/frontend/packages/agent/src/lib/loginAction.ts +++ b/frontend/packages/agent/src/lib/loginAction.ts @@ -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. diff --git a/frontend/packages/agent/src/lib/modelEffort.ts b/frontend/packages/agent/src/lib/modelEffort.ts deleted file mode 100644 index 2b11b186..00000000 --- a/frontend/packages/agent/src/lib/modelEffort.ts +++ /dev/null @@ -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); diff --git a/frontend/packages/agent/src/lib/pauseAction.ts b/frontend/packages/agent/src/lib/pauseAction.ts index 8377273c..4eeb3e4f 100644 --- a/frontend/packages/agent/src/lib/pauseAction.ts +++ b/frontend/packages/agent/src/lib/pauseAction.ts @@ -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 `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
// submit this replaces. // diff --git a/frontend/packages/agent/src/lib/termActions.ts b/frontend/packages/agent/src/lib/termActions.ts index 3a215ec0..ac181241 100644 --- a/frontend/packages/agent/src/lib/termActions.ts +++ b/frontend/packages/agent/src/lib/termActions.ts @@ -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, diff --git a/frontend/packages/agent/src/types.ts b/frontend/packages/agent/src/types.ts index 3c30ea4a..92eeab09 100644 --- a/frontend/packages/agent/src/types.ts +++ b/frontend/packages/agent/src/types.ts @@ -5,7 +5,7 @@ // Extend as more of the page gets rebuilt against real data. // Mirrors `swarm_queue_client::agent_inference::Source`. -export type ModelSource = "swarm" | "agent_nix" | "local_file" | "default"; +export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default"; export interface AgentState { seq: number; @@ -27,9 +27,9 @@ export interface AgentState { cost_usage: TokenUsage | null; hive_name: string | null; swarm_name: string | null; - model_source: ModelSource; - effort: string; - available_efforts: string[]; + model_source: InferenceSource; + effort: string | null; + effort_source: InferenceSource; paused: boolean; inbox: InboxRow[]; links: AgentLink[]; diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index dea3fd31..0722a535 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -21,7 +21,7 @@ use tokio::sync::broadcast; use crate::harness_state::{ configured_effort, configured_model, context_window_tokens, local_effort, local_model, - persist_effort, read_harness_state, write_harness_state, + read_harness_state, write_harness_state, }; 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 /// 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 @@ -341,7 +340,7 @@ pub struct Bus { turn_canceller: Arc>, /// The model and effort the session offers, for a runtime that reads them /// from its session (ACP). Set once by the serve loop when it builds the - /// session; unset on claude, whose effort picker lists `EFFORT_LEVELS`. + /// session; unset on claude. session_choices: Arc>, /// Current fresh-claude-session id (FK to `sessions.id`). Set by the /// bin loop after minting a session row on a fresh start; stamped onto @@ -601,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) { - 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 diff --git a/hive-agent/src/harness_state.rs b/hive-agent/src/harness_state.rs index dfe17fdc..23e447e0 100644 --- a/hive-agent/src/harness_state.rs +++ b/hive-agent/src/harness_state.rs @@ -28,9 +28,9 @@ pub(crate) fn load_model() -> Option { } } -/// 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"), @@ -65,10 +65,9 @@ fn model_from(configured: Option<&str>, file: Option) -> (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()) } @@ -81,14 +80,6 @@ fn effort_from(configured: Option<&str>, file: Option) -> (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 // --------------------------------------------------------------------------- @@ -233,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") @@ -298,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"); } diff --git a/hive-agent/src/swarm_inference.rs b/hive-agent/src/swarm_inference.rs index dcc747ff..ad97e910 100644 --- a/hive-agent/src/swarm_inference.rs +++ b/hive-agent/src/swarm_inference.rs @@ -329,7 +329,7 @@ mod tests { unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) }; let bus = Bus::new(); 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 { bus: bus.clone(), nix_model: None, diff --git a/hive-agent/src/web_ui/actions.rs b/hive-agent/src/web_ui/actions.rs index a31adf64..ff512163 100644 --- a/hive-agent/src/web_ui/actions.rs +++ b/hive-agent/src/web_ui/actions.rs @@ -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, @@ -126,68 +126,6 @@ pub(super) async fn post_new_session(State(state): State) -> Response (axum::http::StatusCode::OK, "ok").into_response() } -#[derive(Deserialize)] -pub(super) struct 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, - Form(form): Form, -) -> 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 { - 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 @@ -230,31 +168,3 @@ pub(super) async fn post_mark_todos_done(Form(form): Form) -> struct MarkTodosDoneBody { 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); - } -} diff --git a/hive-agent/src/web_ui/mod.rs b/hive-agent/src/web_ui/mod.rs index f1fca758..cdae0793 100644 --- a/hive-agent/src/web_ui/mod.rs +++ b/hive-agent/src/web_ui/mod.rs @@ -80,7 +80,6 @@ fn routes() -> Router { .route("/login/cancel", post(auth::post_login_cancel)) .route("/api/cancel", post(actions::post_cancel_turn)) .route("/api/compact", post(actions::post_compact)) - .route("/api/effort", post(actions::post_set_effort)) .route("/api/new-session", post(actions::post_new_session)) .route("/api/logout", post(auth::post_logout)) .route("/api/todos", get(stats::api_todos)) @@ -444,10 +443,10 @@ mod tests { .unwrap() } - /// The swarm sets the model, so the agent serves no route for it; effort - /// is still set here. + /// The swarm sets the model and effort, so the agent serves no route for + /// either. #[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 _env = crate::paths::HARNESS_DIR_ENV .lock() @@ -465,15 +464,20 @@ mod tests { interrupted: Arc::new(AtomicBool::new(false)), }; 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() .unwrap() .block_on(async { let model = app.clone().oneshot(form("/api/model", "model=opus")); - // An unknown level reaches the handler, which refuses it - // before anything is applied. - let effort = app.oneshot(form("/api/effort", "effort=bogus")); - (model.await.unwrap(), effort.await.unwrap()) + 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(), + ) }); match prev { // SAFETY: as above. @@ -481,6 +485,7 @@ mod tests { None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") }, } 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); } } diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index 84afeed3..1a3190bc 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -47,7 +47,7 @@ pub(super) async fn api_state(State(state): State) -> axum::Json) -> axum::Json) -> axum::Json, - /// 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`]. 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, + /// Currently-active claude effort level, shown on the page; `None` per + /// [`Shown`]. The swarm sets it (`crate::swarm_inference`); the page + /// cannot. + effort: Option, + /// 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 @@ -416,48 +411,36 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec, + pub(super) effort: Option, } -/// On claude, the requested model and effort and -/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers -/// (see [`offered_pickers`]). -pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers { +/// 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()), - None => Pickers { + Some(choices) => offered_shown(model, effort, choices.get()), + None => Shown { model, - 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 the model and the -/// effort each show the requested value when offered and not refused by the -/// agent, and the session's own otherwise. The effort levels on offer come -/// with the model, so while a new model waits for the next turn the effort -/// picker is empty. -fn offered_pickers( - model: String, - effort: String, - offered: hive_runtime::SessionChoices, -) -> 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, }; @@ -466,10 +449,9 @@ 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_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))), } } @@ -477,7 +459,7 @@ fn offered_pickers( mod tests { use hive_runtime::{Choice, SessionChoices}; - use super::{Pickers, offered_pickers}; + use super::{Shown, offered_shown}; fn choice(current: &str, values: &[&str]) -> Choice { Choice { @@ -487,51 +469,52 @@ mod tests { } } - fn strings(values: &[&str]) -> Vec { - 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()), - Pickers { - model: "m/think".into(), - 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); - 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); - 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); - 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()), @@ -540,18 +523,16 @@ mod tests { effort: Some(choice("low", &["low", "high"])), }; assert_eq!( - offered_pickers("m/plain".into(), "high".into(), refused), - Pickers { - model: "m/think".into(), - 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()); - assert!(none.available_efforts.is_empty()); + fn acp_shows_the_requested_model_and_no_effort_before_a_session_offers_anything() { + assert_eq!( + offered_shown("haiku".into(), "high".into(), SessionChoices::default()), + shown("haiku", None) + ); } } diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index ad17554b..069cb0c9 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -113,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). ''; }; @@ -431,7 +431,7 @@ in environment.variables = { HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model; # 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; } diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 63b77aff..f77f790c 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -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- diff --git a/swarm-queue-client/src/agent_inference.rs b/swarm-queue-client/src/agent_inference.rs index 1153914f..35e26686 100644 --- a/swarm-queue-client/src/agent_inference.rs +++ b/swarm-queue-client/src/agent_inference.rs @@ -81,8 +81,8 @@ pub enum Source { Swarm, /// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix. AgentNix, - /// `hyperhive-effort`, written by an `/effort` pick in its web UI, or a - /// `hyperhive-model` file, which the agent only reads. + /// A `hyperhive-model` or `hyperhive-effort` file in its harness dir, which + /// the agent only reads. LocalFile, /// The harness's built-in default. Default, diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 6ba40938..0f9337e1 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -214,8 +214,8 @@ enum AgentVerb { /// Show or change the model, effort and subagent models the swarm sets /// for an agent. /// - /// A field the swarm sets wins over the agent's own value (its agent.nix, - /// its `/effort` pick, or a `hyperhive-model` file) 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 {