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:
parent
594693f4c7
commit
ed271e655f
25 changed files with 170 additions and 464 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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-
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue