Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-11 01:26:39 +02:00
commit ed271e655f
25 changed files with 170 additions and 464 deletions

View file

@ -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 ctx_usage = state.bus.last_ctx_usage();
let cost_usage = state.bus.last_cost_usage();
let pickers = pickers(&state.bus);
let shown = shown(&state.bus);
axum::Json(StateSnapshot {
seq,
label: state.label.clone(),
@ -58,7 +58,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
inbox,
turn_state,
turn_state_since,
model: pickers.model,
model: shown.model,
model_source: state.bus.model_source(),
resolved_model,
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()),
hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(),
effort: pickers.effort,
available_efforts: pickers.available_efforts,
effort: shown.effort,
effort_source: state.bus.effort_source(),
paused: crate::paths::paused_marker().exists(),
})
}
@ -182,17 +182,12 @@ pub(super) struct StateSnapshot {
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
swarm_name: Option<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`]. 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>,
/// Currently-active claude effort level, shown on the page; `None` per
/// [`Shown`]. The swarm sets it (`crate::swarm_inference`); the page
/// cannot.
effort: Option<String>,
/// Where `effort` comes from.
effort_source: Source,
/// Whether this agent's turn loop is currently parked (the harness
/// keeps serving this page + its MCP daemons but drives no turns).
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
@ -416,48 +411,36 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
}
}
/// The model the page shows, and the effort picker: the value it shows and
/// the values it offers.
/// The model and effort the page shows. No effort on an ACP agent whose
/// session offers no effort level now.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Pickers {
pub(super) struct Shown {
pub(super) model: String,
pub(super) effort: String,
pub(super) available_efforts: Vec<String>,
pub(super) effort: Option<String>,
}
/// On claude, the requested model and effort and
/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers
/// (see [`offered_pickers`]).
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
/// On claude, the requested model and effort. On ACP, see [`offered_shown`].
pub(super) fn shown(bus: &crate::events::Bus) -> Shown {
let (model, effort) = (bus.model(), bus.effort());
match bus.session_choices() {
Some(choices) => offered_pickers(model, effort, choices.get()),
None => Pickers {
Some(choices) => offered_shown(model, effort, choices.get()),
None => Shown {
model,
effort,
available_efforts: crate::harness_state::EFFORT_LEVELS
.iter()
.map(ToString::to_string)
.collect(),
effort: Some(effort),
},
}
}
/// Pickers for what an ACP session offers. The runtime sets a requested value
/// on the next turn, and only if the session offers it, so the model and the
/// effort each show the requested value when offered and not refused by the
/// agent, and the session's own otherwise. The effort levels on offer come
/// with the model, so while a new model waits for the next turn the effort
/// picker is empty.
fn offered_pickers(
model: String,
effort: String,
offered: hive_runtime::SessionChoices,
) -> Pickers {
/// What an ACP agent shows. The runtime sets a requested value on the next
/// turn, and only if the session offers it, so the model and the effort each
/// show the requested value when offered and not refused by the agent, and
/// the session's own otherwise. The effort levels on offer come with the
/// model, so while a new model waits for the next turn no effort shows.
fn offered_shown(model: String, effort: String, offered: hive_runtime::SessionChoices) -> Shown {
let settable = |wanted: &String, c: &hive_runtime::Choice| {
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
};
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
let pick = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
Some(c) if !settable(&wanted, c) => c.current.clone(),
_ => wanted,
};
@ -466,10 +449,9 @@ fn offered_pickers(
.as_ref()
.is_some_and(|c| c.current != model && settable(&model, c));
let effort_choice = offered.effort.filter(|_| !model_pending);
Pickers {
model: shown(model, offered.model.as_ref()),
effort: shown(effort, effort_choice.as_ref()),
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
Shown {
model: pick(model, offered.model.as_ref()),
effort: effort_choice.map(|c| pick(effort, Some(&c))),
}
}
@ -477,7 +459,7 @@ fn offered_pickers(
mod tests {
use hive_runtime::{Choice, SessionChoices};
use super::{Pickers, offered_pickers};
use super::{Shown, offered_shown};
fn choice(current: &str, values: &[&str]) -> Choice {
Choice {
@ -487,51 +469,52 @@ mod tests {
}
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_owned()).collect()
fn shown(model: &str, effort: Option<&str>) -> Shown {
Shown {
model: model.to_owned(),
effort: effort.map(str::to_owned),
}
}
#[test]
fn acp_pickers_list_what_the_session_offers() {
fn acp_shows_the_requested_values_the_session_offers() {
let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])),
};
// `haiku` and `max` are not on offer: the session's own values show.
assert_eq!(
offered_pickers("haiku".into(), "max".into(), offered.clone()),
Pickers {
model: "m/think".into(),
effort: "low".into(),
available_efforts: strings(&["low", "high"]),
}
offered_shown("haiku".into(), "max".into(), offered.clone()),
shown("m/think", Some("low"))
);
assert_eq!(
offered_shown("m/think".into(), "high".into(), offered),
shown("m/think", Some("high"))
);
let picked = offered_pickers("m/think".into(), "high".into(), offered);
assert_eq!(picked.effort, "high");
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
}
#[test]
fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() {
let plain = SessionChoices {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
};
let shown = offered_pickers("m/plain".into(), "high".into(), plain);
assert_eq!(shown.model, "m/plain");
assert!(shown.available_efforts.is_empty());
fn acp_shows_no_effort_without_one_on_offer_or_while_a_model_is_pending() {
let think = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])),
};
let pending = offered_pickers("m/plain".into(), "high".into(), think);
assert_eq!(pending.model, "m/plain");
assert!(pending.available_efforts.is_empty());
assert_eq!(
offered_shown("m/plain".into(), "max".into(), think),
shown("m/plain", None)
);
let plain = SessionChoices {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
};
assert_eq!(
offered_shown("m/plain".into(), "high".into(), plain),
shown("m/plain", None)
);
}
#[test]
fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
fn acp_shows_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
let refused = SessionChoices {
model: Some(Choice {
refused: Some("m/plain".into()),
@ -540,18 +523,16 @@ mod tests {
effort: Some(choice("low", &["low", "high"])),
};
assert_eq!(
offered_pickers("m/plain".into(), "high".into(), refused),
Pickers {
model: "m/think".into(),
effort: "high".into(),
available_efforts: strings(&["low", "high"]),
}
offered_shown("m/plain".into(), "high".into(), refused),
shown("m/think", Some("high"))
);
}
#[test]
fn acp_pickers_are_empty_before_a_session_offers_anything() {
let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default());
assert!(none.available_efforts.is_empty());
fn acp_shows_the_requested_model_and_no_effort_before_a_session_offers_anything() {
assert_eq!(
offered_shown("haiku".into(), "high".into(), SessionChoices::default()),
shown("haiku", None)
);
}
}