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

@ -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<String> {
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<Mutex<String>>,
effort: Arc<Mutex<String>>,
/// 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<String>) {
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!({