Watch
0
0
Fork
You've already forked hyperhive
0

agents: remove the hive-level /model control; the swarm editor sets the model

The per-agent web UI no longer sets the model: the `/model` slash
command, the model picker, `POST /api/model`, `Bus::set_model` and the
`hyperhive-model` writer are gone. The model chip is display-only and
shows where the model comes from (`model_source` in `/api/state`). The
model is set on the swarm (swarm UI agents page, `swarmctl agent
inference set`).

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

With the picker gone, its model list goes too: `available_models` in
`/api/state`, the ACP model-list filter, and the harness's global
`HIVE_AVAILABLE_MODELS`. The subagent daemon keeps its own
`HIVE_AVAILABLE_MODELS` from mcp.nix, and the `availableModels`
assertion stays.

`/effort` (command, picker, route, file) is unchanged.

Refs #5034
This commit is contained in:
atlas 2026-10-10 23:06:15 +02:00
commit 594693f4c7
26 changed files with 244 additions and 425 deletions

View file

@ -1,9 +1,8 @@
//! `/api/state` + `/api/dashboard-state` snapshot builders.
use std::sync::atomic::{AtomicBool, Ordering};
use axum::extract::State;
use serde::Serialize;
use swarm_queue_client::agent_inference::Source;
use crate::login::LoginState;
use crate::login_session::drop_if_finished;
@ -60,6 +59,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
turn_state,
turn_state_since,
model: pickers.model,
model_source: state.bus.model_source(),
resolved_model,
context_window_tokens,
ctx_usage,
@ -70,7 +70,6 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
.filter(|s| !s.is_empty()),
hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(),
available_models: pickers.available_models,
effort: pickers.effort,
available_efforts: pickers.available_efforts,
paused: crate::paths::paused_marker().exists(),
@ -138,10 +137,11 @@ pub(super) struct StateSnapshot {
/// client-side off this rather than tracking it from SSE events.
turn_state: crate::events::TurnState,
turn_state_since: i64,
/// Currently-active claude model name. Reflected on the page so
/// the operator can see what they just switched to (and what's
/// in flight). Mutable at runtime via `POST /api/model`.
/// Currently-active claude model name, shown on the page. The swarm
/// sets it (`crate::swarm_inference`); the page cannot.
model: String,
/// Where `model` comes from.
model_source: Source,
/// The concrete model id the most recent completed turn actually
/// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that
/// turn's assistant events — `model` above is the alias the
@ -182,24 +182,14 @@ pub(super) struct StateSnapshot {
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
swarm_name: Option<String>,
/// Ordered list of model short-names the operator has declared as
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
/// (comma-separated, set by `services.hyperhive.availableModels`).
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
/// absent or empty. The frontend model quick-picker renders one button
/// per entry in this list, so operators can add new models or drop
/// ones they don't want without touching the frontend code.
/// On ACP, the models the session offers, narrowed to this same list
/// when at least one entry matches one it offers (see [`pickers`]).
available_models: Vec<String>,
/// Currently-active claude effort level. Reflected on the page so the
/// operator's effort picker shows the live selection. Mutable at
/// runtime via `POST /api/effort`; applies on the next session.
effort: String,
/// Selectable effort levels for the picker, ascending. Fixed set
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
/// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like
/// `available_models`. The frontend renders one button per entry.
/// [`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>,
@ -426,107 +416,24 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
}
}
/// Parse `HIVE_AVAILABLE_MODELS` (comma-separated, injected by
/// `services.hyperhive.agent.availableModels`) into its entries. Empty
/// (absent env, or nothing left after trimming) means different things to
/// the two callers below, so neither the empty check nor a fallback lives
/// here.
fn parse_available_models_env() -> Vec<String> {
std::env::var("HIVE_AVAILABLE_MODELS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// The claude picker's model list. Falls back to `["haiku", "sonnet",
/// "opus"]` when the env var is absent or resolves to an empty list after
/// trimming.
fn available_models() -> Vec<String> {
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
let models = parse_available_models_env();
if models.is_empty() {
DEFAULT.iter().map(ToString::to_string).collect()
} else {
models
}
}
/// The operator's configured model list, for filtering the ACP picker.
/// `None` when the env is absent or empty, so [`filter_offered_models`] can
/// tell "nothing configured" apart from a real (if unmatched) list — unlike
/// [`available_models`], which folds both into its claude-only fallback.
fn configured_models() -> Option<Vec<String>> {
let models = parse_available_models_env();
(!models.is_empty()).then_some(models)
}
/// Set the first time [`filter_offered_models`] logs its "no match" warning.
/// `pickers()` runs on every `/api/state` poll (every 4s per open dashboard
/// tab) and on every `post_set_model`/`post_set_effort` call, and all of them
/// funnel through the one [`filter_offered_models`] call site in
/// [`offered_pickers`], so one flag guards every caller.
static MODEL_MISMATCH_WARNED: AtomicBool = AtomicBool::new(false);
/// Keep only the entries of `offered` (an ACP session's own model list, in
/// its own order) that `configured` names. `configured: None` (the env is
/// unset) or a `configured` list matching none of `offered` both leave
/// `offered` unfiltered — the latter is what an ACP agent that has never
/// set `services.hyperhive.agent.availableModels` hits, since the option's
/// default is the claude names, so it must show every model the session
/// offers rather than an empty picker. `warned` gates the mismatch warning
/// to once per process; tests pass their own instead of [`MODEL_MISMATCH_WARNED`]
/// so they don't observe each other's state.
fn filter_offered_models(
offered: Vec<String>,
configured: Option<&[String]>,
warned: &AtomicBool,
) -> Vec<String> {
let Some(configured) = configured else {
return offered;
};
let matching: Vec<String> = offered
.iter()
.filter(|model| configured.iter().any(|c| c == *model))
.cloned()
.collect();
if matching.is_empty() {
if !warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
configured = configured.join(", "),
"HIVE_AVAILABLE_MODELS matches none of the models this ACP session offers; \
showing every model it offers instead"
);
}
offered
} else {
matching
}
}
/// The model and effort pickers: the value each shows and the values it
/// offers.
/// The model the page shows, and the effort picker: the value it shows and
/// the values it offers.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Pickers {
pub(super) model: String,
pub(super) available_models: Vec<String>,
pub(super) effort: String,
pub(super) available_efforts: Vec<String>,
}
/// On claude, the requested model and effort, the configured models and
/// 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 {
let (model, effort) = (bus.model(), bus.effort());
match bus.session_choices() {
Some(choices) => {
offered_pickers(model, effort, choices.get(), configured_models().as_deref())
}
Some(choices) => offered_pickers(model, effort, choices.get()),
None => Pickers {
model,
available_models: available_models(),
effort,
available_efforts: crate::harness_state::EFFORT_LEVELS
.iter()
@ -537,17 +444,15 @@ pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
}
/// Pickers for what an ACP session offers. The runtime sets a requested value
/// on the next turn, and only if the session offers it, so each picker shows
/// the requested value when offered and not refused by the agent, and the
/// session's own otherwise. The effort levels on offer come with the model,
/// so while a new model waits for the next turn the effort picker is empty. `configured` narrows the
/// model list per [`filter_offered_models`]; it never narrows which value is
/// shown as current, only which values the picker offers alongside it.
/// 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,
configured: Option<&[String]>,
) -> Pickers {
let settable = |wanted: &String, c: &hive_runtime::Choice| {
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
@ -564,21 +469,15 @@ fn offered_pickers(
Pickers {
model: shown(model, offered.model.as_ref()),
effort: shown(effort, effort_choice.as_ref()),
available_models: offered
.model
.map(|c| filter_offered_models(c.values, configured, &MODEL_MISMATCH_WARNED))
.unwrap_or_default(),
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use hive_runtime::{Choice, SessionChoices};
use super::{Pickers, filter_offered_models, offered_pickers};
use super::{Pickers, offered_pickers};
fn choice(current: &str, values: &[&str]) -> Choice {
Choice {
@ -600,15 +499,14 @@ mod tests {
};
// `haiku` and `max` are not on offer: the session's own values show.
assert_eq!(
offered_pickers("haiku".into(), "max".into(), offered.clone(), None),
offered_pickers("haiku".into(), "max".into(), offered.clone()),
Pickers {
model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
effort: "low".into(),
available_efforts: strings(&["low", "high"]),
}
);
let picked = offered_pickers("m/think".into(), "high".into(), offered, None);
let picked = offered_pickers("m/think".into(), "high".into(), offered);
assert_eq!(picked.effort, "high");
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
}
@ -619,7 +517,7 @@ mod tests {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
};
let shown = offered_pickers("m/plain".into(), "high".into(), plain, None);
let shown = offered_pickers("m/plain".into(), "high".into(), plain);
assert_eq!(shown.model, "m/plain");
assert!(shown.available_efforts.is_empty());
@ -627,7 +525,7 @@ mod tests {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])),
};
let pending = offered_pickers("m/plain".into(), "high".into(), think, None);
let pending = offered_pickers("m/plain".into(), "high".into(), think);
assert_eq!(pending.model, "m/plain");
assert!(pending.available_efforts.is_empty());
}
@ -642,10 +540,9 @@ mod tests {
effort: Some(choice("low", &["low", "high"])),
};
assert_eq!(
offered_pickers("m/plain".into(), "high".into(), refused, None),
offered_pickers("m/plain".into(), "high".into(), refused),
Pickers {
model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
effort: "high".into(),
available_efforts: strings(&["low", "high"]),
}
@ -654,73 +551,7 @@ mod tests {
#[test]
fn acp_pickers_are_empty_before_a_session_offers_anything() {
let none = offered_pickers(
"haiku".into(),
"high".into(),
SessionChoices::default(),
None,
);
assert!(none.available_models.is_empty() && none.available_efforts.is_empty());
}
#[test]
fn unconfigured_model_list_leaves_the_session_offer_untouched() {
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), None, &warned),
strings(&["m/think", "m/plain"])
);
}
#[test]
fn configured_model_list_keeps_only_the_matches_in_the_session_s_order() {
let configured = strings(&["m/plain", "m/other"]);
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned),
strings(&["m/plain"])
);
}
#[test]
fn configured_model_list_matching_nothing_falls_back_to_every_offered_model() {
// The claude names on an ACP agent that never set `availableModels`.
let configured = strings(&["haiku", "sonnet", "opus"]);
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned),
strings(&["m/think", "m/plain"])
);
assert!(
warned.load(Ordering::Relaxed),
"the mismatch warning should have fired once"
);
}
#[test]
fn a_second_no_match_call_does_not_warn_again() {
// `tracing::warn!` output isn't captured in this crate's tests (no
// subscriber fixture exists), so this pins the guard itself: once
// `warned` is set, a second no-match call leaves it set rather than
// toggling or re-arming, which is what keeps the log line from
// repeating on every `/api/state` poll.
let configured = strings(&["haiku"]);
let warned = AtomicBool::new(false);
filter_offered_models(strings(&["m/think"]), Some(&configured), &warned);
assert!(warned.load(Ordering::Relaxed));
let second = filter_offered_models(strings(&["m/think"]), Some(&configured), &warned);
assert_eq!(second, strings(&["m/think"]));
assert!(warned.load(Ordering::Relaxed));
}
#[test]
fn offered_pickers_applies_the_configured_model_filter() {
let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: None,
};
let configured = strings(&["m/plain"]);
let filtered = offered_pickers("m/think".into(), "high".into(), offered, Some(&configured));
assert_eq!(filtered.available_models, strings(&["m/plain"]));
let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default());
assert!(none.available_efforts.is_empty());
}
}