feat(#1597): persist + apply per-agent claude effort override

This commit is contained in:
damocles 2026-06-10 01:15:50 +02:00 committed by mara
commit ec343046ea
3 changed files with 166 additions and 1 deletions

View file

@ -119,6 +119,7 @@ pub async fn serve(
.route("/api/cancel", post(post_cancel_turn))
.route("/api/compact", post(post_compact))
.route("/api/model", post(post_set_model))
.route("/api/effort", post(post_set_effort))
.route("/api/new-session", post(post_new_session))
.route("/api/logout", post(post_logout))
.route("/api/loose-ends", get(api_loose_ends))
@ -444,6 +445,15 @@ struct StateSnapshot {
/// per entry in this list, so operators can add new models or drop
/// ones they don't want without touching the frontend code.
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
/// (`medium`, `high`, `xhigh`) — sourced from
/// [`crate::events::EFFORT_LEVELS`], not operator-configurable like
/// `available_models`. The frontend renders one button per entry.
available_efforts: Vec<String>,
}
/// One navigation link in the agent page header row. The same JSON
@ -549,6 +559,7 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
.unwrap_or_else(|| crate::events::context_window_tokens(&model));
let ctx_usage = state.bus.last_ctx_usage();
let cost_usage = state.bus.last_cost_usage();
let effort = state.bus.effort();
axum::Json(StateSnapshot {
seq,
label: state.label.clone(),
@ -570,6 +581,11 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(),
available_models: available_models(),
effort,
available_efforts: crate::events::EFFORT_LEVELS
.iter()
.map(ToString::to_string)
.collect(),
})
}
@ -941,6 +957,33 @@ async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelFor
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
struct EffortForm {
effort: String,
}
/// Switch the claude effort level for future sessions. Operator-only
/// (the dashboard picker POSTs here through the gateway). Validated
/// server-side against [`crate::events::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).
async fn post_set_effort(State(state): State<AppState>, Form(form): Form<EffortForm>) -> Response {
let level = form.effort.trim();
if !crate::events::is_valid_effort(level) {
return error_response(&format!(
"effort: level must be one of {}",
crate::events::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"),
});
tracing::info!(%level, "operator set effort");
(axum::http::StatusCode::OK, "ok").into_response()
}
async fn post_compact(State(state): State<AppState>) -> Response {
// Clone the Arc before locking so the guard's lifetime is tied to the
// clone (which we can move into the spawn) rather than to `state`.