hive-runtime, hive-agent: model/effort picker for ACP agents from configOptions
An ACP agent's model and effort pickers now list what its session offers (its `model` and `thought_level` config options) instead of the claude model list and EFFORT_LEVELS. A pick goes through the same Bus::set_model / Bus::set_effort -> Config.model / Config.effort path as on claude; before each prompt the ACP runtime sets it with `session/set_config_option`, model first, and only when the session offers that value. Options are re-read from the set response and from `config_option_update`, so the effort picker disappears when the chosen model offers no effort levels, and is hidden while a newly picked model waits for the next turn. The session's options reach the web UI through a `Choices` handle from a new `Runtime::choices`, registered on the bus the way `canceller` is. /api/model and /api/effort accept only offered values on ACP. The claude path is unchanged. Refs #4391
This commit is contained in:
parent
abd547f600
commit
f189724a4c
13 changed files with 559 additions and 44 deletions
|
|
@ -335,6 +335,11 @@ pub struct Bus {
|
|||
/// by the serve loop when it builds the session; unset on claude, whose
|
||||
/// turn `/api/cancel` stops by signalling the `claude` process.
|
||||
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_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
|
||||
/// every `turn_stats` row until the next fresh session. `None` before
|
||||
|
|
@ -427,6 +432,7 @@ impl Bus {
|
|||
compact_pending: Arc::new(Mutex::new(None)),
|
||||
post_compact_wake: Arc::new(Mutex::new(None)),
|
||||
turn_canceller: Arc::default(),
|
||||
session_choices: Arc::default(),
|
||||
session_id: Arc::new(Mutex::new(None)),
|
||||
fresh_session: Arc::new(AtomicBool::new(false)),
|
||||
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
|
|
@ -463,6 +469,19 @@ impl Bus {
|
|||
self.turn_canceller.get()
|
||||
}
|
||||
|
||||
/// Record where the session's model and effort choices are read. Only the
|
||||
/// first call takes effect; there is one session per harness.
|
||||
pub fn set_session_choices(&self, choices: hive_runtime::Choices) {
|
||||
let _ = self.session_choices.set(choices);
|
||||
}
|
||||
|
||||
/// Where the session's model and effort choices are read, if the runtime
|
||||
/// offers them (see [`Self::set_session_choices`]).
|
||||
#[must_use]
|
||||
pub fn session_choices(&self) -> Option<&hive_runtime::Choices> {
|
||||
self.session_choices.get()
|
||||
}
|
||||
|
||||
/// Request a session reset (operator `POST /api/new-session`). Deferred:
|
||||
/// the flag is consumed at the next turn boundary by `drive_turn`, which
|
||||
/// archives the current session so no claude process is mid-write when the
|
||||
|
|
|
|||
|
|
@ -635,6 +635,16 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Hand the bus the session's handles the web UI reads from outside a turn.
|
||||
fn share_session_handles(bus: &Bus, session: &turn::AgentSession) {
|
||||
if let Some(canceller) = hive_runtime::Runtime::canceller(session) {
|
||||
bus.set_turn_canceller(canceller);
|
||||
}
|
||||
if let Some(choices) = hive_runtime::Runtime::choices(session) {
|
||||
bus.set_session_choices(choices);
|
||||
}
|
||||
}
|
||||
|
||||
/// The long-running message loop. Long-polls the broker via
|
||||
/// `S::recv_next`, drives a turn per message, parks on auth-failed,
|
||||
/// otherwise retries.
|
||||
|
|
@ -662,9 +672,7 @@ async fn serve_loop<S: Surface>(
|
|||
// The durable agent session, built once and reused for every turn +
|
||||
// idle compaction below (it's effectively stateless).
|
||||
let session = turn::make_session(&bus)?;
|
||||
if let Some(canceller) = hive_runtime::Runtime::canceller(&session) {
|
||||
bus.set_turn_canceller(canceller);
|
||||
}
|
||||
share_session_handles(&bus, &session);
|
||||
// Tracks the last observed pause state so the transitions get logged
|
||||
// once each instead of twelve lines a minute while parked.
|
||||
let mut was_paused = false;
|
||||
|
|
|
|||
|
|
@ -136,7 +136,8 @@ pub(super) struct ModelForm {
|
|||
/// `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.
|
||||
/// 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>,
|
||||
|
|
@ -145,10 +146,17 @@ pub(super) async fn post_set_model(
|
|||
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 !offered.iter().any(|m| m == name) {
|
||||
return not_offered("model", &offered);
|
||||
}
|
||||
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: format!("operator: /model — claude model set to '{name}' for future turns"),
|
||||
});
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text });
|
||||
tracing::info!(%name, "operator set model");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
|
@ -163,13 +171,22 @@ pub(super) struct EffortForm {
|
|||
/// 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).
|
||||
/// 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();
|
||||
if !crate::harness_state::is_valid_effort(level) {
|
||||
let text = if state.bus.session_choices().is_some() {
|
||||
let offered = super::state::pickers(&state.bus).available_efforts;
|
||||
if !offered.iter().any(|e| e == level) {
|
||||
return not_offered("effort", &offered);
|
||||
}
|
||||
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!(
|
||||
|
|
@ -177,15 +194,23 @@ pub(super) async fn post_set_effort(
|
|||
crate::harness_state::EFFORT_LEVELS.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
state.bus.set_effort(level);
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: format!("operator: /effort — claude effort set to '{level}' for future sessions"),
|
||||
});
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text });
|
||||
tracing::info!(%level, "operator set effort");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub(super) struct MarkTodosDoneForm {
|
||||
/// Comma-separated todo ids. Same "one field, JS joins the checked
|
||||
|
|
|
|||
|
|
@ -46,7 +46,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 effort = state.bus.effort();
|
||||
let pickers = pickers(&state.bus);
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
|
|
@ -57,7 +57,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
inbox,
|
||||
turn_state,
|
||||
turn_state_since,
|
||||
model,
|
||||
model: pickers.model,
|
||||
resolved_model,
|
||||
context_window_tokens,
|
||||
ctx_usage,
|
||||
|
|
@ -68,12 +68,9 @@ 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: available_models(),
|
||||
effort,
|
||||
available_efforts: crate::harness_state::EFFORT_LEVELS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
available_models: pickers.available_models,
|
||||
effort: pickers.effort,
|
||||
available_efforts: pickers.available_efforts,
|
||||
paused: crate::paths::paused_marker().exists(),
|
||||
})
|
||||
}
|
||||
|
|
@ -190,6 +187,7 @@ pub(super) struct StateSnapshot {
|
|||
/// 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 instead (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
|
||||
|
|
@ -199,6 +197,8 @@ pub(super) struct StateSnapshot {
|
|||
/// (`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).
|
||||
|
|
@ -443,3 +443,123 @@ fn available_models() -> Vec<String> {
|
|||
models
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
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 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 {
|
||||
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
||||
Some(c) if !c.values.contains(&wanted) => c.current.clone(),
|
||||
_ => wanted,
|
||||
};
|
||||
let model_pending = offered
|
||||
.model
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.current != model && c.values.contains(&model));
|
||||
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| c.values).unwrap_or_default(),
|
||||
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hive_runtime::{Choice, SessionChoices};
|
||||
|
||||
use super::{Pickers, offered_pickers};
|
||||
|
||||
fn choice(current: &str, values: &[&str]) -> Choice {
|
||||
Choice {
|
||||
current: current.to_owned(),
|
||||
values: values.iter().map(|v| (*v).to_owned()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
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);
|
||||
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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_pickers_are_empty_before_a_session_offers_anything() {
|
||||
let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default());
|
||||
assert!(none.available_models.is_empty() && none.available_efforts.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue