feat(#1597): persist + apply per-agent claude effort override
This commit is contained in:
parent
d7bba48176
commit
ec343046ea
3 changed files with 166 additions and 1 deletions
|
|
@ -56,6 +56,34 @@ fn persist_model(name: &str) -> std::io::Result<()> {
|
||||||
std::fs::write(path, format!("{name}\n"))
|
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
|
// Consolidated harness state file
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -189,6 +217,10 @@ pub enum LiveEvent {
|
||||||
/// updates the chip + the per-turn stats sink will key off this
|
/// updates the chip + the per-turn stats sink will key off this
|
||||||
/// to mark the boundary in its log.
|
/// to mark the boundary in its log.
|
||||||
ModelChanged { model: String },
|
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:
|
/// Token usage for the turn just ended. Carries two snapshots:
|
||||||
/// - `ctx` is the LAST inference's usage block (the actual context
|
/// - `ctx` is the LAST inference's usage block (the actual context
|
||||||
/// window in use right now — what the operator needs to decide
|
/// window in use right now — what the operator needs to decide
|
||||||
|
|
@ -236,6 +268,7 @@ impl EventStore {
|
||||||
LiveEvent::TurnEnd { .. } => "turn_end",
|
LiveEvent::TurnEnd { .. } => "turn_end",
|
||||||
LiveEvent::StatusChanged { .. } => "status_changed",
|
LiveEvent::StatusChanged { .. } => "status_changed",
|
||||||
LiveEvent::ModelChanged { .. } => "model_changed",
|
LiveEvent::ModelChanged { .. } => "model_changed",
|
||||||
|
LiveEvent::EffortChanged { .. } => "effort_changed",
|
||||||
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
|
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
|
||||||
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
|
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
|
||||||
};
|
};
|
||||||
|
|
@ -449,6 +482,34 @@ pub fn default_model() -> &'static str {
|
||||||
configured_model().unwrap_or(DEFAULT_MODEL)
|
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.
|
/// Context-window size in tokens for a given model name.
|
||||||
///
|
///
|
||||||
/// Canonical per-model sizes are declared in `harness-base.nix` as
|
/// 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
|
/// Model name passed to `claude --model`. Default `haiku`; the
|
||||||
/// operator can override at runtime via `POST /api/model`.
|
/// operator can override at runtime via `POST /api/model`.
|
||||||
model: Arc<Mutex<String>>,
|
model: Arc<Mutex<String>>,
|
||||||
|
effort: Arc<Mutex<String>>,
|
||||||
/// Last-inference token usage from the most recent turn's final
|
/// Last-inference token usage from the most recent turn's final
|
||||||
/// `assistant` event. Represents the actual context window size at
|
/// `assistant` event. Represents the actual context window size at
|
||||||
/// turn-end — the number the operator watches to decide whether to
|
/// 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()),
|
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||||
str::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
|
// Restore rate_limited (and needs_login) from the consolidated
|
||||||
// harness state file so the dashboard shows the correct status
|
// harness state file so the dashboard shows the correct status
|
||||||
// on cold load if the harness crashed while parked.
|
// on cold load if the harness crashed while parked.
|
||||||
|
|
@ -591,6 +660,7 @@ impl Bus {
|
||||||
store,
|
store,
|
||||||
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
|
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
|
||||||
model: Arc::new(Mutex::new(initial_model)),
|
model: Arc::new(Mutex::new(initial_model)),
|
||||||
|
effort: Arc::new(Mutex::new(initial_effort)),
|
||||||
last_ctx_usage: Arc::new(Mutex::new(None)),
|
last_ctx_usage: Arc::new(Mutex::new(None)),
|
||||||
last_cost_usage: Arc::new(Mutex::new(None)),
|
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||||
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||||
|
|
@ -660,6 +730,35 @@ impl Bus {
|
||||||
self.emit(LiveEvent::ModelChanged { model: value });
|
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
|
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
|
||||||
/// emitting a SSE event. Used by the bin entrypoints to backfill
|
/// 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
|
/// from the most recent `turn_stats` row so the per-agent web UI's
|
||||||
|
|
@ -952,9 +1051,29 @@ impl Default for Bus {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::TokenUsage;
|
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, TokenUsage, is_valid_effort};
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn resolved_model_from_assistant_event() {
|
fn resolved_model_from_assistant_event() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
|
|
|
||||||
|
|
@ -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".
|
// failure notification to the manager) instead of just "exit 1".
|
||||||
const STDERR_TAIL_LINES: usize = 20;
|
const STDERR_TAIL_LINES: usize = 20;
|
||||||
let model = bus.model();
|
let model = bus.model();
|
||||||
|
let effort = bus.effort();
|
||||||
let resume = !bus.take_skip_continue();
|
let resume = !bus.take_skip_continue();
|
||||||
if !resume {
|
if !resume {
|
||||||
bus.emit(LiveEvent::Note {
|
bus.emit(LiveEvent::Note {
|
||||||
|
|
@ -670,6 +671,8 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
||||||
.arg("stream-json")
|
.arg("stream-json")
|
||||||
.arg("--model")
|
.arg("--model")
|
||||||
.arg(&model)
|
.arg(&model)
|
||||||
|
.arg("--effort")
|
||||||
|
.arg(&effort)
|
||||||
.arg("--settings")
|
.arg("--settings")
|
||||||
.arg(&files.settings);
|
.arg(&files.settings);
|
||||||
if resume {
|
if resume {
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ pub async fn serve(
|
||||||
.route("/api/cancel", post(post_cancel_turn))
|
.route("/api/cancel", post(post_cancel_turn))
|
||||||
.route("/api/compact", post(post_compact))
|
.route("/api/compact", post(post_compact))
|
||||||
.route("/api/model", post(post_set_model))
|
.route("/api/model", post(post_set_model))
|
||||||
|
.route("/api/effort", post(post_set_effort))
|
||||||
.route("/api/new-session", post(post_new_session))
|
.route("/api/new-session", post(post_new_session))
|
||||||
.route("/api/logout", post(post_logout))
|
.route("/api/logout", post(post_logout))
|
||||||
.route("/api/loose-ends", get(api_loose_ends))
|
.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
|
/// per entry in this list, so operators can add new models or drop
|
||||||
/// ones they don't want without touching the frontend code.
|
/// ones they don't want without touching the frontend code.
|
||||||
available_models: Vec<String>,
|
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
|
/// 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));
|
.unwrap_or_else(|| crate::events::context_window_tokens(&model));
|
||||||
let ctx_usage = state.bus.last_ctx_usage();
|
let ctx_usage = state.bus.last_ctx_usage();
|
||||||
let cost_usage = state.bus.last_cost_usage();
|
let cost_usage = state.bus.last_cost_usage();
|
||||||
|
let effort = state.bus.effort();
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
seq,
|
seq,
|
||||||
label: state.label.clone(),
|
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(),
|
hive_name: crate::identity::hive_name(),
|
||||||
swarm_name: crate::identity::swarm_name(),
|
swarm_name: crate::identity::swarm_name(),
|
||||||
available_models: available_models(),
|
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()
|
(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 {
|
async fn post_compact(State(state): State<AppState>) -> Response {
|
||||||
// Clone the Arc before locking so the guard's lifetime is tied to the
|
// 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`.
|
// clone (which we can move into the spawn) rather than to `state`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue