When an ACP agent answered session/set_config_option with an error, choose() only logged it, so the session's current value never moved and offered_pickers() kept treating the refused model as pending: the picker showed the refused model as current and hid the effort picker. hive-runtime now keeps the refused value per category on Choices, exposed as Choice::refused, set on the RPC error and cleared when the agent next accepts a value for that category. offered_pickers() treats a refused value as not settable, so the picker shows the session's actual model and its effort levels. The fake ACP agent gains REFUSE, which errors the first session/set_config_option to that value. Refs #4832 (ACP half only)
726 lines
30 KiB
Rust
726 lines
30 KiB
Rust
//! `/api/state` + `/api/dashboard-state` snapshot builders.
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use axum::extract::State;
|
|
use serde::Serialize;
|
|
|
|
use crate::login::LoginState;
|
|
use crate::login_session::drop_if_finished;
|
|
|
|
use super::AppState;
|
|
|
|
pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|
// Capture seq *before* any reads so the dedupe contract is
|
|
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
|
let seq = state.bus.current_seq();
|
|
drop_if_finished(&state.session);
|
|
let login = *state.login.lock().unwrap();
|
|
let session_snapshot = state.session.lock().unwrap().clone();
|
|
let (status, session_view) = match (login, session_snapshot) {
|
|
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
|
|
(LoginState::Online, _) => ("online", None),
|
|
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
|
|
(LoginState::NeedsLogin, Some(s)) => (
|
|
"needs_login_in_progress",
|
|
Some(SessionView {
|
|
url: s.url(),
|
|
output: s.output(),
|
|
finished: s.finished(),
|
|
exit_note: s.exit_note(),
|
|
}),
|
|
),
|
|
};
|
|
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u16>().ok())
|
|
.unwrap_or(7000);
|
|
let inbox = recent_inbox(&state.socket).await;
|
|
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
|
let model = state.bus.model();
|
|
// The alias the operator selected (e.g. "sonnet") vs. what the last
|
|
// turn actually ran on (e.g. "claude-sonnet-4-5-20260805") — mara:
|
|
// "i cannot see if sonnet became claude-sonnet-5 or something else".
|
|
// `None` until the first turn completes (no assistant event to
|
|
// resolve from yet); same source `serve_common.rs`'s turn-stats
|
|
// rollup already prefers over the requested alias.
|
|
let resolved_model = state.bus.last_resolved_model();
|
|
let context_window_tokens = state.bus.effective_context_window(&model);
|
|
let ctx_usage = state.bus.last_ctx_usage();
|
|
let cost_usage = state.bus.last_cost_usage();
|
|
let pickers = pickers(&state.bus);
|
|
axum::Json(StateSnapshot {
|
|
seq,
|
|
label: state.label.clone(),
|
|
qualified_label: crate::identity::qualify(&state.label),
|
|
dashboard_port,
|
|
status,
|
|
session: session_view,
|
|
inbox,
|
|
turn_state,
|
|
turn_state_since,
|
|
model: pickers.model,
|
|
resolved_model,
|
|
context_window_tokens,
|
|
ctx_usage,
|
|
cost_usage,
|
|
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
|
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
|
|
.ok()
|
|
.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(),
|
|
})
|
|
}
|
|
|
|
/// Lean snapshot of the agent-owned fields that the dashboard card
|
|
/// needs. Served at `GET /api/dashboard-state` (accessible through the
|
|
/// gateway at `/agent/<name>/api/dashboard-state`). The dashboard
|
|
/// fetches this once per running agent to get fresh, agent-authoritative
|
|
/// values instead of relying on hive-c0re's periodic file-reads.
|
|
///
|
|
/// Structural fields (running, `needs_update`, `deployed_sha`, parent, …)
|
|
/// continue to come from hive-c0re's `/api/state`; this endpoint covers
|
|
/// only the fields the agent itself is the source of truth for.
|
|
pub(super) async fn api_dashboard_state(
|
|
State(state): State<AppState>,
|
|
) -> axum::Json<DashboardState> {
|
|
let (status_text, status_set_at) = read_own_status();
|
|
let rate_limited = state.bus.is_rate_limited();
|
|
let model = state.bus.model();
|
|
let context_window_tokens = state.bus.effective_context_window(&model);
|
|
// Full context-window size = input + cache-read + cache-creation. Using
|
|
// raw `input_tokens` here reported only the *uncached* sliver, which is
|
|
// ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match
|
|
// the agent page (which ships the whole `ctx_usage` and sums it) and the
|
|
// cache-TTL logic in turn.rs, both of which use `context_tokens()`.
|
|
let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.context_tokens());
|
|
axum::Json(DashboardState {
|
|
status_text,
|
|
status_set_at,
|
|
ctx_tokens,
|
|
context_window_tokens,
|
|
rate_limited,
|
|
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub(super) struct StateSnapshot {
|
|
/// Bus seq at the moment this snapshot was assembled. Clients dedupe
|
|
/// their buffered SSE traffic against this value: events with
|
|
/// `seq <= snapshot.seq` are already reflected (or pre-date the
|
|
/// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on
|
|
/// harness restart — clients treat reconnect as a fresh world.
|
|
seq: u64,
|
|
label: String,
|
|
/// Hive-qualified long name (`${label}@${hyperhive.domain}`) when
|
|
/// the host has been configured for a multi-hive swarm; falls back
|
|
/// to the short label when the hive domain env var is unset.
|
|
/// The frontend uses this for the page title / agent self-introduction;
|
|
/// when it equals `label`, the page renders the short form unchanged.
|
|
qualified_label: String,
|
|
dashboard_port: u16,
|
|
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
|
status: &'static str,
|
|
/// Present when `status == "needs_login_in_progress"`.
|
|
session: Option<SessionView>,
|
|
/// Last N messages addressed to this agent, newest-first. Pulled
|
|
/// from the broker via the per-agent socket on each render.
|
|
/// Empty on transport failure.
|
|
inbox: Vec<hive_sh4re::inbox::InboxRow>,
|
|
/// Authoritative turn-loop state from the harness and the unix
|
|
/// timestamp the state was entered. The JS computes the age
|
|
/// 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`.
|
|
model: String,
|
|
/// 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
|
|
/// operator picked, which claude itself maps to a specific dated
|
|
/// snapshot. `None` before any turn has completed.
|
|
resolved_model: Option<String>,
|
|
/// Effective context-window token budget for the current model.
|
|
/// Primary source: API-reported `modelUsage.*.contextWindow` from
|
|
/// the last result event (authoritative per-inference active window).
|
|
/// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000.
|
|
/// Consumers (e.g. dashboard badge) use this to render ctx-usage %.
|
|
context_window_tokens: u64,
|
|
/// Last-inference token usage from the most recent completed
|
|
/// turn — represents the current context-window size at turn-end.
|
|
/// `null` until the first turn finishes.
|
|
ctx_usage: Option<hive_claude::TokenUsage>,
|
|
/// Cumulative token usage across the most recent turn's inferences
|
|
/// (cost signal). `null` until the first turn finishes.
|
|
cost_usage: Option<hive_claude::TokenUsage>,
|
|
/// Navigation links for this agent page. Also served via
|
|
/// `DashboardState.links` (`GET /api/dashboard-state`) for the
|
|
/// dashboard card's icon strip. Both are produced by `agent_links()`
|
|
/// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`]
|
|
/// for the frontend resolver + which links appear in which conditions.
|
|
links: Vec<AgentLink>,
|
|
/// Public URL of the forge (e.g.
|
|
/// `"https://forge.pr1ma.darkest.space"`). Sourced from
|
|
/// `HIVE_FORGE_PUBLIC_URL` (set from `services.hyperhive.forge.
|
|
/// publicUrl`); `None` when unset. The frontend uses this to build
|
|
/// forge nav-strip links, and **hides** the forge link entirely
|
|
/// when absent rather than guessing `<hostname>:3000`.
|
|
forge_public_url: Option<String>,
|
|
/// Human name of this hive instance (e.g. `"pr1ma"`). Sourced
|
|
/// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend
|
|
/// uses this for the page `<title>` and header label so browser
|
|
/// tabs disambiguate when multiple hives are open in parallel.
|
|
hive_name: Option<String>,
|
|
/// 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.
|
|
/// 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
|
|
/// keeps serving this page + its MCP daemons but drives no turns).
|
|
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
|
|
/// (`crate::paths::paused_marker`) — read directly here rather than
|
|
/// asking hive-c0re over the socket, since the harness already has
|
|
/// the file locally. hive-c0re owns the actual pause/resume *writes*
|
|
/// (via hive-priv, this process runs unprivileged) — the per-agent
|
|
/// page's own `⋯` menu POSTs to hive-c0re's existing
|
|
/// `/api/pause/<name>` / `/api/resume/<name>` (same endpoints
|
|
/// `<hive-agent-menu>` on the dashboard already uses), this field
|
|
/// only tells the frontend which of the two to show.
|
|
paused: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SessionView {
|
|
/// First `https://…` claude emitted on stdout, if any.
|
|
url: Option<String>,
|
|
/// Accumulated stdout + stderr.
|
|
output: String,
|
|
finished: bool,
|
|
exit_note: Option<String>,
|
|
}
|
|
|
|
/// One navigation link in the agent page header row. The same JSON
|
|
/// shape appears in both `StateSnapshot.links` (`GET /api/state`,
|
|
/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`,
|
|
/// dashboard card icon strip). `agent_links()` is the single source
|
|
/// of truth for what links an agent exposes.
|
|
#[derive(Serialize)]
|
|
struct AgentLink {
|
|
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
|
/// The frontend prepends the right base before rendering.
|
|
url: String,
|
|
icon: String,
|
|
label: String,
|
|
kind: AgentLinkKind,
|
|
}
|
|
|
|
/// Resolution hint for `AgentLink.url`. The agent backend can't know
|
|
/// which hostname the browser sees (especially when the dashboard
|
|
/// proxies the call from a different origin), so it labels each link
|
|
/// and lets the frontend prepend the right base.
|
|
#[derive(Serialize, Clone, Copy)]
|
|
#[serde(rename_all = "snake_case")]
|
|
enum AgentLinkKind {
|
|
/// `url` is a path on the agent's container web UI (`/stats`,
|
|
/// `/screen`). Agent page: same-origin path. Dashboard:
|
|
/// `http://<host>:<container.port><url>`.
|
|
Container,
|
|
/// `url` is a path on the local Forgejo (`/<label>`,
|
|
/// `/agent-configs/<label>`). Both surfaces:
|
|
/// `http://<host>:3000<url>`.
|
|
Forge,
|
|
/// `url` is already a fully-qualified absolute URL — use as-is.
|
|
/// Agent-declared `services.hyperhive.agent.dashboardLinks` extras arrive here.
|
|
External,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
pub(super) struct DashboardState {
|
|
/// Free-text status set by `set_status`, read directly from the
|
|
/// `hyperhive-status` file the harness writes. `None` when unset.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
status_text: Option<String>,
|
|
/// Unix timestamp (seconds) when the status file was last written.
|
|
/// `None` when no status is set.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
status_set_at: Option<i64>,
|
|
/// Full context-window size from the most recent completed turn
|
|
/// (`ctx_usage.context_tokens()` = input + cache-read + cache-creation).
|
|
/// `None` until the first turn finishes. Drives the `ctx·Nk` card badge.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ctx_tokens: Option<u64>,
|
|
/// Effective context-window budget for the current model. Same
|
|
/// derivation as `StateSnapshot::context_window_tokens`.
|
|
context_window_tokens: u64,
|
|
/// True while the harness is parked after a rate-limit response.
|
|
rate_limited: bool,
|
|
/// Navigation links for the dashboard card's icon strip. This is
|
|
/// the authoritative source — includes the screen link (GUI agents)
|
|
/// which hive-c0re's disk-based fallback cannot determine.
|
|
links: Vec<AgentLink>,
|
|
}
|
|
|
|
/// Read the agent's own free-text status and the timestamp when it was
|
|
/// set, directly from the `hyperhive-status` file in the state dir.
|
|
/// Mirrors `hive_c0re::container_view::read_agent_status` but runs
|
|
/// inside the agent container using its own state dir.
|
|
fn read_own_status() -> (Option<String>, Option<i64>) {
|
|
let path = crate::paths::state_dir().join("hyperhive-status");
|
|
let meta = std::fs::metadata(&path).ok();
|
|
let text = std::fs::read_to_string(&path)
|
|
.ok()
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|t| !t.is_empty())
|
|
.map(str::to_owned);
|
|
let mtime = meta.and_then(|m| {
|
|
m.modified().ok().and_then(|t| {
|
|
t.duration_since(std::time::UNIX_EPOCH)
|
|
.ok()
|
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
|
})
|
|
});
|
|
if text.is_none() {
|
|
(None, None)
|
|
} else {
|
|
(text, mtime)
|
|
}
|
|
}
|
|
|
|
/// Build the navigation link list for the agent page header. URLs
|
|
/// are paths (relative) for `Container`/`Forge` targets and absolute
|
|
/// for `External`; the frontend resolves each against its `kind`
|
|
/// against the right base so the backend never has to guess the
|
|
/// operator's browser host. See
|
|
/// `docs/web-ui/dashboard.md::Container row` for
|
|
/// the resolver + how `deployed:<sha>` ships alongside.
|
|
///
|
|
/// Icons are real emoji, not unicode arrow/geometric glyphs — mara:
|
|
/// operator-declared `dashboardLinks` extras are already free-choice
|
|
/// emoji, so the built-ins should read as the same family rather than
|
|
/// mixing in plain text glyphs (which also don't reliably render as a
|
|
/// hexagon/arrow across fonts to begin with). `config` went through two
|
|
/// rounds on this: gear (`⚙`, with and without a variation selector)
|
|
/// still rendered broken in mara's browser either way — same
|
|
/// font-unreliable codepoint `icons.tsx` already documents avoiding.
|
|
/// Landed on the wrench (`🔧`) instead: a supplementary-plane pictograph
|
|
/// with `Emoji_Presentation=Yes`, same category as every other icon
|
|
/// here (`📊`/`🖥`/`🔨`/`🧭`), so it doesn't need a variation selector to
|
|
/// render as color emoji in the first place.
|
|
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
|
let mut links = Vec::new();
|
|
|
|
links.push(AgentLink {
|
|
url: "stats.html".to_owned(),
|
|
icon: "📊".to_owned(),
|
|
label: "stats".to_owned(),
|
|
kind: AgentLinkKind::Container,
|
|
});
|
|
|
|
if gui_enabled {
|
|
links.push(AgentLink {
|
|
url: "screen.html".to_owned(),
|
|
icon: "🖥".to_owned(),
|
|
label: "screen".to_owned(),
|
|
kind: AgentLinkKind::Container,
|
|
});
|
|
}
|
|
|
|
// Either copy of the forge token: the one fetched from the swarm secret
|
|
// store (`HIVE_FORGE_TOKEN_FILE`), or the state-dir file the hive used
|
|
// to write.
|
|
let fetched_token = std::env::var_os("HIVE_FORGE_TOKEN_FILE")
|
|
.is_some_and(|p| std::path::Path::new(&p).is_file());
|
|
if fetched_token || crate::paths::state_dir().join("forge-token").is_file() {
|
|
links.push(AgentLink {
|
|
url: format!("/{label}"),
|
|
icon: "🔨".to_owned(),
|
|
label: "forge".to_owned(),
|
|
kind: AgentLinkKind::Forge,
|
|
});
|
|
links.push(AgentLink {
|
|
url: format!("/agent-configs/{label}"),
|
|
icon: "🔧".to_owned(),
|
|
label: "config".to_owned(),
|
|
kind: AgentLinkKind::Forge,
|
|
});
|
|
}
|
|
|
|
// Agent-declared extras (`services.hyperhive.agent.dashboardLinks` → the
|
|
// `hive-dashboard-links` NixOS oneshot writes them to
|
|
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
|
|
// is `{label, icon, url}` with absolute URLs — those become
|
|
// `kind = External` links, passed through verbatim.
|
|
let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json");
|
|
if let Ok(text) = std::fs::read_to_string(&extras_path)
|
|
&& !text.trim().is_empty()
|
|
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
|
|
{
|
|
for e in extras {
|
|
links.push(AgentLink {
|
|
url: e.url,
|
|
icon: e.icon,
|
|
label: e.label,
|
|
kind: AgentLinkKind::External,
|
|
});
|
|
}
|
|
}
|
|
|
|
links
|
|
}
|
|
|
|
/// On-disk shape of `hyperhive-dashboard-links.json` (the
|
|
/// `hive-dashboard-links` NixOS oneshot's output). Mapped to
|
|
/// `AgentLink { kind: External }` inside `agent_links`.
|
|
#[derive(serde::Deserialize)]
|
|
struct ExtraLink {
|
|
label: String,
|
|
#[serde(default)]
|
|
icon: String,
|
|
url: String,
|
|
}
|
|
|
|
/// Best-effort: pull the last 30 messages addressed to us via the
|
|
/// per-agent / manager socket. Empty list on any transport / decode
|
|
/// failure — the inbox section is decorative, not authoritative.
|
|
async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxRow> {
|
|
const LIMIT: u64 = 30;
|
|
// Deadline-bounded (via `broker_request`): `/api/state` must render even
|
|
// when hive-c0re is busy — an empty inbox section beats a hung snapshot.
|
|
match super::broker_request(
|
|
socket,
|
|
&hive_core_agent_sock::Request::Recent { limit: LIMIT },
|
|
)
|
|
.await
|
|
{
|
|
Ok(hive_core_agent_sock::Response::Recent { rows }) => rows,
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
#[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
|
|
/// [`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())
|
|
}
|
|
None => Pickers {
|
|
model,
|
|
available_models: available_models(),
|
|
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
|
|
/// 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.
|
|
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)
|
|
};
|
|
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
|
Some(c) if !settable(&wanted, c) => c.current.clone(),
|
|
_ => wanted,
|
|
};
|
|
let model_pending = offered
|
|
.model
|
|
.as_ref()
|
|
.is_some_and(|c| c.current != model && settable(&model, c));
|
|
let effort_choice = offered.effort.filter(|_| !model_pending);
|
|
Pickers {
|
|
model: shown(model, offered.model.as_ref()),
|
|
effort: shown(effort, effort_choice.as_ref()),
|
|
available_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};
|
|
|
|
fn choice(current: &str, values: &[&str]) -> Choice {
|
|
Choice {
|
|
current: current.to_owned(),
|
|
values: values.iter().map(|v| (*v).to_owned()).collect(),
|
|
refused: None,
|
|
}
|
|
}
|
|
|
|
fn strings(values: &[&str]) -> Vec<String> {
|
|
values.iter().map(|v| (*v).to_owned()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn acp_pickers_list_what_the_session_offers() {
|
|
let offered = SessionChoices {
|
|
model: Some(choice("m/think", &["m/think", "m/plain"])),
|
|
effort: Some(choice("low", &["low", "high"])),
|
|
};
|
|
// `haiku` and `max` are not on offer: the session's own values show.
|
|
assert_eq!(
|
|
offered_pickers("haiku".into(), "max".into(), offered.clone(), None),
|
|
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);
|
|
assert_eq!(picked.effort, "high");
|
|
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
|
|
}
|
|
|
|
#[test]
|
|
fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() {
|
|
let plain = SessionChoices {
|
|
model: Some(choice("m/plain", &["m/think", "m/plain"])),
|
|
effort: None,
|
|
};
|
|
let shown = offered_pickers("m/plain".into(), "high".into(), plain, None);
|
|
assert_eq!(shown.model, "m/plain");
|
|
assert!(shown.available_efforts.is_empty());
|
|
|
|
let think = SessionChoices {
|
|
model: Some(choice("m/think", &["m/think", "m/plain"])),
|
|
effort: Some(choice("low", &["low", "high"])),
|
|
};
|
|
let pending = offered_pickers("m/plain".into(), "high".into(), think, None);
|
|
assert_eq!(pending.model, "m/plain");
|
|
assert!(pending.available_efforts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
|
|
let refused = SessionChoices {
|
|
model: Some(Choice {
|
|
refused: Some("m/plain".into()),
|
|
..choice("m/think", &["m/think", "m/plain"])
|
|
}),
|
|
effort: Some(choice("low", &["low", "high"])),
|
|
};
|
|
assert_eq!(
|
|
offered_pickers("m/plain".into(), "high".into(), refused, None),
|
|
Pickers {
|
|
model: "m/think".into(),
|
|
available_models: strings(&["m/think", "m/plain"]),
|
|
effort: "high".into(),
|
|
available_efforts: strings(&["low", "high"]),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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"]));
|
|
}
|
|
}
|