diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 7bd9a955..0acc3bd5 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -56,6 +56,34 @@ fn persist_model(name: &str) -> std::io::Result<()> { std::fs::write(path, format!("{name}\n")) } +/// Path to the persisted effort-level file. Sibling of `hyperhive-model`, +/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise +/// derived from the agent's harness dir. +fn effort_file_path() -> PathBuf { + std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else( + || crate::paths::harness_dir().join("hyperhive-effort"), + PathBuf::from, + ) +} + +fn load_effort() -> Option { + let s = std::fs::read_to_string(effort_file_path()).ok()?; + let level = s.trim(); + if level.is_empty() { + None + } else { + Some(level.to_owned()) + } +} + +fn persist_effort(level: &str) -> std::io::Result<()> { + let path = effort_file_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(path, format!("{level}\n")) +} + // --------------------------------------------------------------------------- // Consolidated harness state file // --------------------------------------------------------------------------- @@ -189,6 +217,10 @@ pub enum LiveEvent { /// updates the chip + the per-turn stats sink will key off this /// to mark the boundary in its log. ModelChanged { model: String }, + /// `/api/effort` switched the active claude effort level. Applies on + /// the next session start; the web UI updates the picker chip to + /// reflect it. + EffortChanged { effort: String }, /// Token usage for the turn just ended. Carries two snapshots: /// - `ctx` is the LAST inference's usage block (the actual context /// window in use right now — what the operator needs to decide @@ -236,6 +268,7 @@ impl EventStore { LiveEvent::TurnEnd { .. } => "turn_end", LiveEvent::StatusChanged { .. } => "status_changed", LiveEvent::ModelChanged { .. } => "model_changed", + LiveEvent::EffortChanged { .. } => "effort_changed", LiveEvent::TokenUsageChanged { .. } => "token_usage_changed", LiveEvent::TurnStateChanged { .. } => "turn_state_changed", }; @@ -449,6 +482,34 @@ pub fn default_model() -> &'static str { configured_model().unwrap_or(DEFAULT_MODEL) } +/// Compiled-in fallback effort level — matches the `effortLevel` baked +/// into `prompts/claude-settings.json`. +pub const DEFAULT_EFFORT: &str = "medium"; + +/// Valid claude `--effort` levels, ascending. The operator picker is +/// constrained to these; [`is_valid_effort`] guards the persist path. +pub const EFFORT_LEVELS: [&str; 3] = ["medium", "high", "xhigh"]; + +/// True iff `level` is one of [`EFFORT_LEVELS`]. +#[must_use] +pub fn is_valid_effort(level: &str) -> bool { + EFFORT_LEVELS.contains(&level) +} + +/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from +/// `hyperhive.effortLevel` in `agent.nix`), or `None` if absent / empty. +/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted +/// runtime override takes precedence over this baseline (see `Bus::new`): +/// the operator's effort pick sticks across harness restart, with the nix +/// value only the default when no override was ever set. +#[must_use] +pub fn configured_effort() -> Option<&'static str> { + std::env::var("HIVE_DEFAULT_EFFORT") + .ok() + .filter(|s| !s.trim().is_empty()) + .map(|s| &*Box::leak(s.into_boxed_str())) +} + /// Context-window size in tokens for a given model name. /// /// Canonical per-model sizes are declared in `harness-base.nix` as @@ -506,6 +567,7 @@ pub struct Bus { /// Model name passed to `claude --model`. Default `haiku`; the /// operator can override at runtime via `POST /api/model`. model: Arc>, + effort: Arc>, /// Last-inference token usage from the most recent turn's final /// `assistant` event. Represents the actual context window size at /// turn-end — the number the operator watches to decide whether to @@ -581,6 +643,13 @@ impl Bus { || load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()), str::to_owned, ); + // Effort precedence is the inverse of model: a persisted operator + // pick wins over the nix `HIVE_DEFAULT_EFFORT` baseline so the + // runtime choice survives harness restart (it only resets on + // `--purge`). nix is the default for a never-touched agent. + let initial_effort = load_effort() + .or_else(|| configured_effort().map(str::to_owned)) + .unwrap_or_else(|| DEFAULT_EFFORT.to_owned()); // Restore rate_limited (and needs_login) from the consolidated // harness state file so the dashboard shows the correct status // on cold load if the harness crashed while parked. @@ -591,6 +660,7 @@ impl Bus { store, state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))), model: Arc::new(Mutex::new(initial_model)), + effort: Arc::new(Mutex::new(initial_effort)), last_ctx_usage: Arc::new(Mutex::new(None)), last_cost_usage: Arc::new(Mutex::new(None)), rate_limited: Arc::new(AtomicBool::new(was_rate_limited)), @@ -660,6 +730,35 @@ impl Bus { self.emit(LiveEvent::ModelChanged { model: value }); } + /// Currently-selected claude effort level. Read at session start to + /// build the `--effort` arg. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + #[must_use] + pub fn effort(&self) -> String { + self.effort.lock().unwrap().clone() + } + + /// Switch the effort level for future sessions. Applies on the next + /// claude launch (no mid-session swap). Persisted to the agent's + /// state dir (`hyperhive-effort`) so the override survives harness + /// restart and container rebuild (gone on `--purge`). Callers must + /// pre-validate with [`is_valid_effort`]. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + pub fn set_effort(&self, level: impl Into) { + let value: String = level.into(); + self.effort.lock().unwrap().clone_from(&value); + if let Err(e) = persist_effort(&value) { + tracing::warn!(error = ?e, "effort: persist failed"); + } + self.emit(LiveEvent::EffortChanged { effort: value }); + } + /// Seed `last_ctx_usage` + `last_cost_usage` at startup without /// emitting a SSE event. Used by the bin entrypoints to backfill /// from the most recent `turn_stats` row so the per-agent web UI's @@ -952,9 +1051,29 @@ impl Default for Bus { #[cfg(test)] mod tests { - use super::TokenUsage; + use super::{DEFAULT_EFFORT, EFFORT_LEVELS, TokenUsage, is_valid_effort}; use serde_json::json; + #[test] + fn effort_validation_accepts_only_known_levels() { + for level in EFFORT_LEVELS { + assert!(is_valid_effort(level), "{level} should be valid"); + } + // Out-of-set, empty, and wrong-case inputs are rejected so a bad + // picker POST never reaches `claude --effort`. + for bad in ["low", "", "MEDIUM", "ultra", "high "] { + assert!(!is_valid_effort(bad), "{bad:?} should be rejected"); + } + } + + #[test] + fn compiled_default_effort_is_selectable() { + // The fallback must itself be a valid level, and match the value + // baked into prompts/claude-settings.json. + assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT)); + assert_eq!(DEFAULT_EFFORT, "medium"); + } + #[test] fn resolved_model_from_assistant_event() { let v = json!({ diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index a06cf1c9..450fc3a8 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -649,6 +649,7 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, // failure notification to the manager) instead of just "exit 1". const STDERR_TAIL_LINES: usize = 20; let model = bus.model(); + let effort = bus.effort(); let resume = !bus.take_skip_continue(); if !resume { bus.emit(LiveEvent::Note { @@ -670,6 +671,8 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, .arg("stream-json") .arg("--model") .arg(&model) + .arg("--effort") + .arg(&effort) .arg("--settings") .arg(&files.settings); if resume { diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 25ed295e..f52c2dae 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -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, + /// 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, } /// One navigation link in the agent page header row. The same JSON @@ -549,6 +559,7 @@ async fn api_state(State(state): State) -> axum::Json { .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) -> axum::Json { 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, Form(form): Form, Form(form): Form) -> 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) -> 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`.