Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-09-30 07:59:27 +02:00
commit f189724a4c
13 changed files with 559 additions and 44 deletions

View file

@ -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