Both options are now ignored with a warning (the swarm-peers-removed.nix shape), so a host that still sets one keeps evaluating. - operatorPronouns: the agents' system prompt names the pronouns as she/her, the value the harness fell back to and the option's default. The --operator-pronouns flag, HiveEnv field, meta-flake let binding and HIVE_OPERATOR_PRONOUNS env var are gone. - contextWindowTokens: the per-family defaults (haiku 200000, sonnet 1000000, opus 1000000) move into hive-agent's harness_state as a table; the window the API reports after a turn still wins. The --context-window-tokens flag, HiveEnv field and the rendered HIVE_CONTEXT_WINDOW_TOKENS_<KEY> env vars are gone.
332 lines
13 KiB
Rust
332 lines
13 KiB
Rust
//! File-backed harness state, split out of [`crate::events`] (which owns
|
|
//! the live event bus + sqlite store). This module holds the runtime
|
|
//! model/effort selection and the consolidated `hyperhive-harness.json`
|
|
//! state file — the persisted knobs the harness reads/writes across
|
|
//! turns, none of which are about the live event stream.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use swarm_queue_client::agent_inference::Source;
|
|
|
|
/// Path to the `hyperhive-model` file. The harness only reads it, as the
|
|
/// fallback in [`local_model`]. Overridable via `HYPERHIVE_MODEL_FILE` for
|
|
/// dev / tests; otherwise derived from the agent's harness dir.
|
|
fn model_file_path() -> PathBuf {
|
|
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|
|
|| crate::paths::harness_dir().join("hyperhive-model"),
|
|
PathBuf::from,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn load_model() -> Option<String> {
|
|
let s = std::fs::read_to_string(model_file_path()).ok()?;
|
|
let name = s.trim();
|
|
if name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(name.to_owned())
|
|
}
|
|
}
|
|
|
|
/// Path to the `hyperhive-effort` file. The harness only reads it, as the
|
|
/// fallback in [`local_effort`]. 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,
|
|
)
|
|
}
|
|
|
|
pub(crate) 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())
|
|
}
|
|
}
|
|
|
|
/// The model this agent runs when the swarm sets none, and where it comes
|
|
/// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from
|
|
/// `services.hyperhive.agent.model` in agent.nix) > an existing
|
|
/// `hyperhive-model` file > [`DEFAULT_MODEL`].
|
|
pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) {
|
|
model_from(configured, load_model())
|
|
}
|
|
|
|
fn model_from(configured: Option<&str>, file: Option<String>) -> (String, Source) {
|
|
match (configured, file) {
|
|
(Some(nix), _) => (nix.to_owned(), Source::AgentNix),
|
|
(None, Some(file)) => (file, Source::LocalFile),
|
|
(None, None) => (DEFAULT_MODEL.to_owned(), Source::Default),
|
|
}
|
|
}
|
|
|
|
/// The effort this agent runs at when the swarm sets none, and where it
|
|
/// comes from. The inverse of [`local_model`]: an existing
|
|
/// `hyperhive-effort` file wins over `configured` (`HIVE_DEFAULT_EFFORT`);
|
|
/// nix is the default for an agent without one.
|
|
pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) {
|
|
effort_from(configured, load_effort())
|
|
}
|
|
|
|
fn effort_from(configured: Option<&str>, file: Option<String>) -> (String, Source) {
|
|
match (file, configured) {
|
|
(Some(file), _) => (file, Source::LocalFile),
|
|
(None, Some(nix)) => (nix.to_owned(), Source::AgentNix),
|
|
(None, None) => (DEFAULT_EFFORT.to_owned(), Source::Default),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Consolidated harness state file
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `hyperhive-harness.json` replaces the two legacy boolean sentinel files
|
|
// (`hyperhive-rate-limited`, `hyperhive-needs-login`) that grew organically
|
|
// and had no shared schema. A single JSON file is self-documenting, atomic
|
|
// to write, and cheaper for hive-c0re to read on each sweep (one fopen vs
|
|
// two stat calls). See `docs/agent-lifecycle/persistence.md::Harness state files`.
|
|
//
|
|
// Legacy sentinel files written by older harness builds are still honoured
|
|
// by `read_harness_state` so in-place upgrades don't lose state (the new
|
|
// harness re-normalises on first write). Old files are not deleted — they
|
|
// expire naturally when the state dir is purged. `hive-c0re::container_view`
|
|
// also checks the legacy paths as a fallback during the transition window.
|
|
|
|
const HARNESS_JSON: &str = "hyperhive-harness.json";
|
|
|
|
fn harness_json_path() -> PathBuf {
|
|
crate::paths::state_dir().join(HARNESS_JSON)
|
|
}
|
|
|
|
// Serialises the read-modify-write of `hyperhive-harness.json`. Every
|
|
// writer merges into the existing object rather than reconstructing it,
|
|
// so a future second writer preserves fields it doesn't own; the lock
|
|
// closes the lost-update window between a writer's read and its rename.
|
|
// (No second writer today — the forge poller that once shared this file
|
|
// now keeps its own delivery-dedupe state and runs as a separate process
|
|
// anyway, which an in-process mutex couldn't have serialised.)
|
|
static HARNESS_JSON_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
|
|
/// Read the consolidated state file as a JSON object, or an empty object
|
|
/// when it is missing / unparseable / not an object.
|
|
fn read_harness_json() -> serde_json::Value {
|
|
std::fs::read_to_string(harness_json_path())
|
|
.ok()
|
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
|
.filter(serde_json::Value::is_object)
|
|
.unwrap_or_else(|| serde_json::json!({}))
|
|
}
|
|
|
|
/// Atomically overwrite the state file (`.tmp` + rename) so hive-c0re
|
|
/// never reads a partial file.
|
|
fn write_harness_json(v: &serde_json::Value) {
|
|
let path = harness_json_path();
|
|
let tmp = path.with_extension("json.tmp");
|
|
if std::fs::write(&tmp, v.to_string()).is_ok() {
|
|
let _ = std::fs::rename(&tmp, &path);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn read_harness_state() -> (bool, bool, Option<String>) {
|
|
// Try the new consolidated file first.
|
|
if let Ok(raw) = std::fs::read_to_string(harness_json_path())
|
|
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
|
{
|
|
let rate_limited = v
|
|
.get("rate_limited")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false);
|
|
let needs_login = v
|
|
.get("needs_login")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false);
|
|
let active_model = v
|
|
.get("active_model")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_owned);
|
|
return (rate_limited, needs_login, active_model);
|
|
}
|
|
// Fall back to legacy sentinel files written by older harness builds.
|
|
let state_dir = crate::paths::state_dir();
|
|
let rate_limited = state_dir.join("hyperhive-rate-limited").exists();
|
|
let needs_login = state_dir.join("hyperhive-needs-login").exists();
|
|
(rate_limited, needs_login, None)
|
|
}
|
|
|
|
/// Write the turn-loop's harness state fields via a read-modify-write so
|
|
/// any other writer's fields in the consolidated state file survive.
|
|
/// Pass `active_model: Some(s)` to update the resolved model (surfaced in
|
|
/// the dashboard badge); `None` leaves the stored value untouched.
|
|
pub(crate) fn write_harness_state(
|
|
rate_limited: bool,
|
|
needs_login: bool,
|
|
active_model: Option<&str>,
|
|
) {
|
|
let _guard = HARNESS_JSON_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let mut v = read_harness_json();
|
|
v["rate_limited"] = rate_limited.into();
|
|
v["needs_login"] = needs_login.into();
|
|
if let Some(model) = active_model {
|
|
v["active_model"] = model.into();
|
|
}
|
|
write_harness_json(&v);
|
|
}
|
|
|
|
/// Stamp `api_key_mode` into the consolidated state file — a config fact
|
|
/// (does this agent authenticate to its backend with an API key rather
|
|
/// than a Claude OAuth session?), not runtime state, so it's written once
|
|
/// at harness startup and never toggled again for the life of the
|
|
/// container. Lives in the same file as the toggling fields rather than a
|
|
/// dedicated marker file: this codebase already consolidated two ad-hoc
|
|
/// sentinel files into one JSON for exactly the reason a new one would
|
|
/// re-create (`hive-c0re` paying a stat call per extra file per sweep) —
|
|
/// see this module's own doc comment above.
|
|
///
|
|
/// hive-c0re reads it to stop reporting `needs_login` for an agent whose
|
|
/// `~/.claude/` dir is empty by design (`container_view::api_key_mode`) —
|
|
/// without this, the host's own naive "does the credentials dir have
|
|
/// files in it" check has no way to tell "never going to log in" apart
|
|
/// from "hasn't logged in yet".
|
|
pub(crate) fn write_api_key_mode(enabled: bool) {
|
|
let _guard = HARNESS_JSON_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let mut v = read_harness_json();
|
|
v["api_key_mode"] = enabled.into();
|
|
write_harness_json(&v);
|
|
}
|
|
|
|
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
|
|
/// `hyperhive-model` file is present.
|
|
pub const DEFAULT_MODEL: &str = "haiku";
|
|
|
|
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
|
|
/// `services.hyperhive.agent.model` in `agent.nix`), or `None` if the env var is absent /
|
|
/// empty. When `Some`, this takes precedence over a `hyperhive-model` file
|
|
/// so that nix config changes always take effect on rebuild.
|
|
#[must_use]
|
|
pub fn configured_model() -> Option<&'static str> {
|
|
// Leak once at startup — acceptable for a single config value.
|
|
std::env::var("HIVE_DEFAULT_MODEL")
|
|
.ok()
|
|
.filter(|s| !s.trim().is_empty())
|
|
.map(|s| &*Box::leak(s.into_boxed_str()))
|
|
}
|
|
|
|
/// Compiled-in fallback effort level — matches the `effortLevel` baked
|
|
/// into `prompts/claude-settings.json`.
|
|
pub const DEFAULT_EFFORT: &str = "medium";
|
|
|
|
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
|
|
/// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty.
|
|
/// Mirrors [`configured_model`]'s env shape. Unlike model, a
|
|
/// `hyperhive-effort` file takes precedence over this baseline (see
|
|
/// [`local_effort`]).
|
|
#[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 per model family, keyed by a substring
|
|
/// of the model name (`"sonnet"` matches `"claude-sonnet-4-5"`).
|
|
const CONTEXT_WINDOW_DEFAULTS: [(&str, u64); 3] = [
|
|
("haiku", 200_000),
|
|
("sonnet", 1_000_000),
|
|
("opus", 1_000_000),
|
|
];
|
|
|
|
/// Context-window size in tokens for a given model name, used until a
|
|
/// completed turn reports the real window (`Bus::effective_context_window`
|
|
/// prefers that one).
|
|
///
|
|
/// Resolution order (first match wins):
|
|
/// 1. [`CONTEXT_WINDOW_DEFAULTS`] — the family key, case-insensitively,
|
|
/// is a substring of the model name.
|
|
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
|
|
/// 3. Hard fallback: `200_000`.
|
|
#[must_use]
|
|
pub fn context_window_tokens(model: &str) -> u64 {
|
|
let m = model.to_ascii_lowercase();
|
|
if let Some((_, tokens)) = CONTEXT_WINDOW_DEFAULTS
|
|
.iter()
|
|
.find(|(family, _)| m.contains(family))
|
|
{
|
|
return *tokens;
|
|
}
|
|
// Global override (single value, any model).
|
|
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
|
|
&& let Ok(v) = s.trim().parse::<u64>()
|
|
&& v > 0
|
|
{
|
|
return v;
|
|
}
|
|
200_000
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
DEFAULT_EFFORT, DEFAULT_MODEL, Source, context_window_tokens, effort_from, model_from,
|
|
};
|
|
|
|
#[test]
|
|
fn context_window_defaults_per_model_family() {
|
|
assert_eq!(context_window_tokens("claude-haiku-4-5"), 200_000);
|
|
assert_eq!(context_window_tokens("haiku"), 200_000);
|
|
assert_eq!(context_window_tokens("claude-sonnet-4-5"), 1_000_000);
|
|
assert_eq!(context_window_tokens("Claude-Sonnet-4-6"), 1_000_000);
|
|
assert_eq!(context_window_tokens("sonnet"), 1_000_000);
|
|
assert_eq!(context_window_tokens("claude-opus-4-1"), 1_000_000);
|
|
assert_eq!(context_window_tokens("opus"), 1_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn compiled_default_effort_matches_claude_settings() {
|
|
// The value baked into prompts/claude-settings.json.
|
|
assert_eq!(DEFAULT_EFFORT, "medium");
|
|
}
|
|
|
|
fn pick(value: &str, source: Source) -> (String, Source) {
|
|
(value.to_owned(), source)
|
|
}
|
|
|
|
/// agent.nix wins over the file for the model.
|
|
#[test]
|
|
fn each_model_source() {
|
|
let file = || Some("sonnet".to_owned());
|
|
assert_eq!(
|
|
model_from(Some("opus"), file()),
|
|
pick("opus", Source::AgentNix)
|
|
);
|
|
assert_eq!(model_from(None, file()), pick("sonnet", Source::LocalFile));
|
|
assert_eq!(model_from(None, None), pick(DEFAULT_MODEL, Source::Default));
|
|
}
|
|
|
|
/// The file wins over agent.nix for the effort.
|
|
#[test]
|
|
fn each_effort_source() {
|
|
let file = || Some("low".to_owned());
|
|
assert_eq!(
|
|
effort_from(Some("high"), file()),
|
|
pick("low", Source::LocalFile)
|
|
);
|
|
assert_eq!(
|
|
effort_from(Some("high"), None),
|
|
pick("high", Source::AgentNix)
|
|
);
|
|
assert_eq!(
|
|
effort_from(None, None),
|
|
pick(DEFAULT_EFFORT, Source::Default)
|
|
);
|
|
}
|
|
}
|