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:
parent
46e9966e17
commit
594693f4c7
26 changed files with 244 additions and 425 deletions
|
|
@ -57,6 +57,7 @@ tracing.workspace = true
|
|||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
# Single harness serve-loop binary: `hive-agent` (from `src/main.rs`).
|
||||
# The sibling MCP server is its own bin crate now (`hive-agent-mcp`).
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use tokio::sync::broadcast;
|
|||
|
||||
use crate::harness_state::{
|
||||
configured_effort, configured_model, context_window_tokens, local_effort, local_model,
|
||||
persist_effort, persist_model, read_harness_state, write_harness_state,
|
||||
persist_effort, read_harness_state, write_harness_state,
|
||||
};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
|
|
@ -115,8 +115,8 @@ pub enum LiveEvent {
|
|||
/// during the short-lived in-progress window — the client
|
||||
/// re-fetches only while that flow is active.
|
||||
StatusChanged { status: String },
|
||||
/// `/api/model` switched the active claude model. The web UI
|
||||
/// updates the chip + the per-turn stats sink will key off this
|
||||
/// The active claude model changed (a swarm value applied or cleared).
|
||||
/// The web UI updates the chip + the per-turn stats sink will key off this
|
||||
/// to mark the boundary in its log.
|
||||
ModelChanged { model: String },
|
||||
/// `/api/effort` switched the active claude effort level. Applies on
|
||||
|
|
@ -282,8 +282,8 @@ pub struct Bus {
|
|||
store: Option<Arc<EventStore>>,
|
||||
/// Current turn-loop state + since-when (unix seconds).
|
||||
state: Arc<Mutex<(TurnState, i64)>>,
|
||||
/// Model name passed to `claude --model`. Default `haiku`; the
|
||||
/// operator can override at runtime via `POST /api/model`.
|
||||
/// Model name passed to `claude --model`. Default `haiku`; a value the
|
||||
/// swarm sets replaces it at runtime (`crate::swarm_inference`).
|
||||
model: Arc<Mutex<String>>,
|
||||
effort: Arc<Mutex<String>>,
|
||||
/// Where `model` and `effort` come from, as the swarm is told in this
|
||||
|
|
@ -341,8 +341,7 @@ pub struct Bus {
|
|||
turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>,
|
||||
/// The model and effort the session offers, for a runtime that reads them
|
||||
/// from its session (ACP). Set once by the serve loop when it builds the
|
||||
/// session; unset on claude, whose pickers list the configured models and
|
||||
/// `EFFORT_LEVELS`.
|
||||
/// session; unset on claude, whose effort picker lists `EFFORT_LEVELS`.
|
||||
session_choices: Arc<OnceLock<hive_runtime::Choices>>,
|
||||
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
||||
/// bin loop after minting a session row on a fresh start; stamped onto
|
||||
|
|
@ -407,7 +406,7 @@ impl Bus {
|
|||
// Write the resolved active model to hyperhive-harness.json on
|
||||
// startup so hive-c0re can surface the model badge without reading
|
||||
// hyperhive-model directly. Written once here; updated on every
|
||||
// set_model() call (runtime override) and emit_status() call.
|
||||
// apply_model() call and emit_status() call.
|
||||
write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model));
|
||||
Self {
|
||||
tx: Arc::new(tx),
|
||||
|
|
@ -562,7 +561,7 @@ impl Bus {
|
|||
}
|
||||
|
||||
/// Currently-selected claude model name. Read on every turn so a
|
||||
/// `/model <name>` flip takes effect on the next turn.
|
||||
/// swarm change takes effect on the next turn.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
|
|
@ -572,27 +571,10 @@ impl Bus {
|
|||
self.model.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch the model for future turns. The current turn (if any)
|
||||
/// keeps the model it was already running. Persisted to the agent's
|
||||
/// state dir (`hyperhive-model`) so the override survives harness
|
||||
/// restart and container rebuild (gone on `--purge`, matching
|
||||
/// every other piece of agent state).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_model(&self, name: impl Into<String>) {
|
||||
let value: String = name.into();
|
||||
if let Err(e) = persist_model(&value) {
|
||||
tracing::warn!(error = ?e, "model: persist failed");
|
||||
}
|
||||
self.apply_model(value, Source::LocalFile);
|
||||
}
|
||||
|
||||
/// Switch the model for future turns without persisting it, so
|
||||
/// `hyperhive-model` keeps the agent's local value. For a value the
|
||||
/// swarm sets (`crate::swarm_inference`). Emits `ModelChanged` even when
|
||||
/// only `source` changed.
|
||||
/// Switch the model for future turns. Never writes `hyperhive-model`, so
|
||||
/// clearing the swarm value finds the agent's local value again. For a
|
||||
/// value the swarm sets (`crate::swarm_inference`). Emits `ModelChanged`
|
||||
/// even when only `source` changed.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ use std::path::PathBuf;
|
|||
|
||||
use swarm_queue_client::agent_inference::Source;
|
||||
|
||||
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
|
||||
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||
/// Path to the `hyperhive-model` file. The harness only reads it, as the
|
||||
/// fallback in [`local_model`]. Overridable via `HYPERHIVE_MODEL_FILE` for
|
||||
/// dev / tests; otherwise derived from the agent's harness dir.
|
||||
fn model_file_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|
||||
|| crate::paths::harness_dir().join("hyperhive-model"),
|
||||
|
|
@ -27,14 +28,6 @@ pub(crate) fn load_model() -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_model(name: &str) -> std::io::Result<()> {
|
||||
let path = model_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, format!("{name}\n"))
|
||||
}
|
||||
|
||||
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
|
||||
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
|
||||
/// derived from the agent's harness dir.
|
||||
|
|
@ -57,8 +50,8 @@ pub(crate) fn load_effort() -> Option<String> {
|
|||
|
||||
/// The model this agent runs when the swarm sets none, and where it comes
|
||||
/// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from
|
||||
/// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime
|
||||
/// override > [`DEFAULT_MODEL`].
|
||||
/// `services.hyperhive.agent.model` in agent.nix) > an existing
|
||||
/// `hyperhive-model` file > [`DEFAULT_MODEL`].
|
||||
pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) {
|
||||
model_from(configured, load_model())
|
||||
}
|
||||
|
|
@ -220,13 +213,13 @@ pub(crate) fn write_api_key_mode(enabled: bool) {
|
|||
}
|
||||
|
||||
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
|
||||
/// persisted runtime override is present.
|
||||
/// `hyperhive-model` file is present.
|
||||
pub const DEFAULT_MODEL: &str = "haiku";
|
||||
|
||||
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
|
||||
/// `services.hyperhive.agent.model` in `agent.nix`), or `None` if the env var is absent /
|
||||
/// empty. When `Some`, this takes precedence over any persisted runtime
|
||||
/// override so that nix config changes always take effect on rebuild.
|
||||
/// empty. When `Some`, this takes precedence over a `hyperhive-model` file
|
||||
/// so that nix config changes always take effect on rebuild.
|
||||
#[must_use]
|
||||
pub fn configured_model() -> Option<&'static str> {
|
||||
// Leak once at startup — acceptable for a single config value.
|
||||
|
|
|
|||
|
|
@ -131,3 +131,8 @@ pub fn claude_dir() -> PathBuf {
|
|||
}
|
||||
PathBuf::from("/root/.claude")
|
||||
}
|
||||
|
||||
/// Held by every test that points `HYPERHIVE_HARNESS_DIR` at a temp dir:
|
||||
/// cargo runs a crate's tests on parallel threads of one process.
|
||||
#[cfg(test)]
|
||||
pub(crate) static HARNESS_DIR_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
|
|
|||
|
|
@ -321,11 +321,14 @@ mod tests {
|
|||
#[test]
|
||||
fn the_swarm_value_is_not_persisted_over_the_local_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _env = crate::paths::HARNESS_DIR_ENV
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
|
||||
// SAFETY: no other test in this crate reads or sets this variable.
|
||||
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
|
||||
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
|
||||
let bus = Bus::new();
|
||||
bus.set_model("sonnet");
|
||||
std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap();
|
||||
bus.set_effort("low");
|
||||
let target = Harness {
|
||||
bus: bus.clone(),
|
||||
|
|
|
|||
|
|
@ -126,41 +126,6 @@ pub(super) async fn post_new_session(State(state): State<AppState>) -> Response
|
|||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ModelForm {
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// Switch the model for future turns. The current turn (if any)
|
||||
/// keeps its model; `/model <name>` applies starting with the next
|
||||
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
|
||||
/// claude-side validation — we just hand the string through to
|
||||
/// `claude --model <name>`; an unknown model surfaces as a turn
|
||||
/// failure in the live panel and the operator can revert. On ACP the name
|
||||
/// must be one the session offers, since the runtime sets no other.
|
||||
pub(super) async fn post_set_model(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<ModelForm>,
|
||||
) -> Response {
|
||||
let name = form.model.trim();
|
||||
if name.is_empty() {
|
||||
return error_response(StatusCode::BAD_REQUEST, "model: name required");
|
||||
}
|
||||
let text = if state.bus.session_choices().is_some() {
|
||||
let offered = super::state::pickers(&state.bus).available_models;
|
||||
if let Some(response) = accept_or_refuse("model", name, &offered) {
|
||||
return response;
|
||||
}
|
||||
format!("operator: /model — model set to '{name}' from the next turn")
|
||||
} else {
|
||||
format!("operator: /model — claude model set to '{name}' for future turns")
|
||||
};
|
||||
state.bus.set_model(name);
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text });
|
||||
tracing::info!(%name, "operator set model");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EffortForm {
|
||||
effort: String,
|
||||
|
|
@ -201,7 +166,7 @@ pub(super) async fn post_set_effort(
|
|||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// The ACP branch of `post_set_model`/`post_set_effort`: `None` when `name`
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,29 @@ struct AppState {
|
|||
interrupted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The API routes, without the extra proxies and the static fallback.
|
||||
fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/state", get(state::api_state))
|
||||
.route("/api/dashboard-state", get(state::api_dashboard_state))
|
||||
.route("/events/stream", get(stream::events_stream))
|
||||
.route("/events/history", get(stream::events_history))
|
||||
.route("/send", post(actions::post_send))
|
||||
.route("/login/start", post(auth::post_login_start))
|
||||
.route("/login/code", post(auth::post_login_code))
|
||||
.route("/login/cancel", post(auth::post_login_cancel))
|
||||
.route("/api/cancel", post(actions::post_cancel_turn))
|
||||
.route("/api/compact", post(actions::post_compact))
|
||||
.route("/api/effort", post(actions::post_set_effort))
|
||||
.route("/api/new-session", post(actions::post_new_session))
|
||||
.route("/api/logout", post(auth::post_logout))
|
||||
.route("/api/todos", get(stats::api_todos))
|
||||
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
|
||||
.route("/api/stats", get(stats::api_stats))
|
||||
.route("/screen/ws", get(screen::screen_ws))
|
||||
.route("/icon", get(screen::serve_icon))
|
||||
}
|
||||
|
||||
/// Bind the per-container web listener and serve the SPA.
|
||||
///
|
||||
/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the
|
||||
|
|
@ -110,26 +133,7 @@ pub async fn serve(
|
|||
gui_vnc_port,
|
||||
interrupted,
|
||||
};
|
||||
let app: Router<AppState> = Router::new()
|
||||
.route("/api/state", get(state::api_state))
|
||||
.route("/api/dashboard-state", get(state::api_dashboard_state))
|
||||
.route("/events/stream", get(stream::events_stream))
|
||||
.route("/events/history", get(stream::events_history))
|
||||
.route("/send", post(actions::post_send))
|
||||
.route("/login/start", post(auth::post_login_start))
|
||||
.route("/login/code", post(auth::post_login_code))
|
||||
.route("/login/cancel", post(auth::post_login_cancel))
|
||||
.route("/api/cancel", post(actions::post_cancel_turn))
|
||||
.route("/api/compact", post(actions::post_compact))
|
||||
.route("/api/model", post(actions::post_set_model))
|
||||
.route("/api/effort", post(actions::post_set_effort))
|
||||
.route("/api/new-session", post(actions::post_new_session))
|
||||
.route("/api/logout", post(auth::post_logout))
|
||||
.route("/api/todos", get(stats::api_todos))
|
||||
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
|
||||
.route("/api/stats", get(stats::api_stats))
|
||||
.route("/screen/ws", get(screen::screen_ws))
|
||||
.route("/icon", get(screen::serve_icon));
|
||||
let app = routes();
|
||||
// Mount any `services.hyperhive.agent.extraWebProxies` under `/extra/<name>/` before the
|
||||
// static fallback so declared proxies win over `ServeDir`.
|
||||
let app = proxy::mount_extra_proxies(app)
|
||||
|
|
@ -419,3 +423,64 @@ fn broker_error_response(err: &BrokerError, action: &str) -> Response {
|
|||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{AppState, routes};
|
||||
use crate::events::Bus;
|
||||
use crate::login::LoginState;
|
||||
|
||||
fn form(path: &str, body: &'static str) -> Request<Body> {
|
||||
Request::post(path)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The swarm sets the model, so the agent serves no route for it; effort
|
||||
/// is still set here.
|
||||
#[test]
|
||||
fn the_model_has_no_route_and_effort_still_does() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _env = crate::paths::HARNESS_DIR_ENV
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
|
||||
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
|
||||
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
|
||||
let state = AppState {
|
||||
label: "test".to_owned(),
|
||||
login: Arc::new(Mutex::new(LoginState::Online)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus: Bus::new(),
|
||||
socket: dir.path().join("mcp.sock"),
|
||||
gui_vnc_port: None,
|
||||
interrupted: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
let (model, effort) = 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())
|
||||
});
|
||||
match prev {
|
||||
// SAFETY: as above.
|
||||
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
|
||||
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
|
||||
}
|
||||
assert_eq!(model.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(effort.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue