Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
29 changed files with 404 additions and 252 deletions

View file

@ -136,6 +136,8 @@ covers spreading a swarm across hosts.
swarm.domain = "hive.example"; # the swarm's DNS domain
hiveName = "pr1ma"; # this hive's label in it
swarm.hives.pr1ma = { }; # the directory: just us
# c0re.operatorPronouns = "they/them"; # default "she/her"
};
# ... the rest of your host config

View file

@ -325,7 +325,7 @@ labels (`hive`, `swarm`, …) ride on the resource via
<!-- vale write-good.Passive = NO -->
The export cadence is a fixed 60s. Transport is OTLP/HTTP
Cadence follows `metricIntervalMs` (default 60s). Transport is OTLP/HTTP
(JSON) to the hive collector's bridge address, with no auth header — that
first hop is unauthenticated for every producer on this host, and the upstream
credential stays on the swarm tier.
@ -409,8 +409,8 @@ window used by agent** panel (health tab) divides one by the other.
Resource attributes (`service.name`, `agent`, `swarm`) come from the
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this
section, and the swarm's collector adds `hive` — nothing extra to configure.
The export cadence is a fixed 60s, the same
as `hive-c0re`'s container-resource export above. That only controls how often
Cadence follows `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` (default 60s, same variable + default as
`hive-c0re`'s container-resource export above) — that only controls how often
the harness flushes the batched points to the collector, not how often it
records them (every turn, always).
@ -486,8 +486,8 @@ store either a commit or a push anywhere as a row to count.
A push with zero commits (a branch delete, or a force-push that doesn't add
new commits) still increments `push.count`; `commit.count` only advances
when the delivery actually carries commits. Same enable signal (`OTEL_EXPORTER_OTLP_ENDPOINT`), fixed 60s cadence
and `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`
when the delivery actually carries commits. Same enable signal (`OTEL_EXPORTER_OTLP_ENDPOINT`), cadence variable
(`HYPERHIVE_OTEL_METRIC_INTERVAL_MS`) and `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`
resource-attribute channel as `swarm-controller`'s other OTEL exporter (its
`hive-jobq-metrics`-backed job-graph rollup, undocumented here — see that
crate's own doc comment) — `swarm-controller` sets `service.name =

View file

@ -61,15 +61,16 @@ clearing it goes back to the local value →
context-window size per-model. Resolution order (first
match wins):
1. The harness's per-family defaults, matched when the family name
is a case-insensitive substring of the active model name:
haiku=200k, sonnet=1M, opus=1M.
1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` env var, where `KEY`
(lowercased) is a substring of the active model name. Injected
by the meta flake from `services.hyperhive.c0re.contextWindowTokens`
(host-level NixOS option, defaults: haiku=200k, sonnet=1M,
opus=1M). Override these for all agents at once without a
per-agent config change.
2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any
other model (useful in dev / test).
3. Hard fallback: `200_000`.
Once a turn completes, the window the model reports
(`modelUsage.*.contextWindow`) replaces this value.
model (useful in dev / test).
3. Hard fallback: `200_000` (conservative; only reached outside
NixOS where the env vars aren't set).
The effective window drives watermarks; the harness exposes it at
runtime via `/api/state.context_window_tokens` so the UI can show a
@ -255,13 +256,14 @@ needs to `chown` a bind mount), once at startup:
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`:
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
same for `role:manager`) gate the role-specific blocks; both flavors
share everything else. Four placeholders are then
share everything else. Five placeholders are then
substituted: `{label}` (short agent name), `{qualified_label}`
(hive-qualified `name@domain` form),
(hive-qualified `name@domain` form), `{operator_pronouns}`,
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
`HYPERHIVE_HIVE_NAME` is unset), and `{swarm_identity}` (same
shape for the swarm). The template names no pronouns for the
operator, and tells agents to call them the operator.
shape for the swarm). Pronouns come from `HIVE_OPERATOR_PRONOUNS`
env (set by the meta flake from
`services.hyperhive.c0re.operatorPronouns`, default `she/her`).
When you enable `services.hyperhive.agent.docs.enable`, `HIVE_DOCS_DIR` is present
in the environment and `render()` appends a one-sentence pointer
telling the agent where the docs live.

View file

@ -1,4 +1,4 @@
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator is the human at the dashboard; say "the operator" when you mention the operator in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.
Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look:

View file

@ -367,10 +367,10 @@ pub struct Bus {
/// Per-inference context-window size as reported by the Anthropic API
/// in the stream-json `result` event (`modelUsage.*.contextWindow`).
/// Set by the stdout pump on every completed turn. Takes precedence
/// over the per-model defaults in `harness_state::context_window_tokens`
/// over the Nix-configured `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars
/// for compaction watermark calculations — it reflects the actual
/// limit the model enforces, which may differ from the default
/// (e.g. 200 k active window on a 1 M cache-enabled model).
/// limit the model enforces, which may differ from what the operator
/// configured (e.g. 200 k active window on a 1 M cache-enabled model).
api_context_window: Arc<Mutex<Option<u64>>>,
/// Resolved model id from the most recent turn's `assistant` events
/// (the API-echoed `message.model`, e.g. `claude-opus-4-8`), as

View file

@ -237,31 +237,33 @@ pub fn configured_effort() -> Option<&'static str> {
.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).
/// Context-window size in tokens for a given model name.
///
/// Canonical per-model sizes are declared in the harness nix modules as
/// `hyperhive.contextWindowTokens` and injected as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars — so this function
/// normally just reads them. The Rust code carries no model knowledge;
/// updating model families only requires a Nix change.
///
/// Resolution order (first match wins):
/// 1. [`CONTEXT_WINDOW_DEFAULTS`] — the family key, case-insensitively,
/// is a substring of the model name.
/// 1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` — key (lowercased) is a
/// substring of the active model name. Populated by the Nix default
/// map for all known families; add/override in `agent.nix`.
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
/// 3. Hard fallback: `200_000`.
/// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS).
#[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))
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
for (key, val) in std::env::vars() {
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
&& !suffix.is_empty()
&& m.contains(&suffix.to_ascii_lowercase())
&& let Ok(v) = val.trim().parse::<u64>()
&& v > 0
{
return *tokens;
return v;
}
}
// Global override (single value, any model).
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
@ -270,25 +272,13 @@ pub fn context_window_tokens(model: &str) -> u64 {
{
return v;
}
// Hard fallback for dev/test outside NixOS where env vars aren't set.
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);
}
use super::{DEFAULT_EFFORT, DEFAULT_MODEL, Source, effort_from, model_from};
#[test]
fn compiled_default_effort_matches_claude_settings() {

View file

@ -39,9 +39,10 @@ use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use crate::turn_stats::TurnStatRow;
/// Export cadence. Matches `hive-c0re`'s container-metrics exporter so the
/// two batch on the same rhythm.
const EXPORT_INTERVAL: Duration = Duration::from_mins(1);
/// Default export cadence when `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` is unset.
/// Matches `hive-c0re`'s container-metrics default so the two exporters
/// batch on the same rhythm by default.
const DEFAULT_INTERVAL: Duration = Duration::from_mins(1);
struct Instruments {
/// Kept alive for the process lifetime — the `PeriodicReader` only
@ -176,7 +177,7 @@ fn build() -> Option<Instruments> {
tracing::debug!("otel turn-metrics: no endpoint configured, exporter disabled");
return None;
}
let interval = EXPORT_INTERVAL;
let interval = interval();
match build_provider(interval) {
Ok(provider) => {
tracing::info!(?interval, "otel turn-metrics: exporter enabled");
@ -253,3 +254,12 @@ fn build_provider(interval: Duration) -> anyhow::Result<SdkMeterProvider> {
pub fn enabled() -> bool {
std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok_and(|s| !s.trim().is_empty())
}
/// Export cadence from `HYPERHIVE_OTEL_METRIC_INTERVAL_MS`, else the default.
fn interval() -> Duration {
std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|ms| *ms > 0)
.map_or(DEFAULT_INTERVAL, Duration::from_millis)
}

View file

@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// Assemble the system prompt for a given label + optional hive /
/// Assemble the system prompt for a given label + pronouns + optional hive /
/// swarm display names. Pure function — no I/O. Splits out from
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable
/// in isolation. The caller supplies the template body so tests can pass an
@ -21,6 +21,7 @@ use anyhow::{Context, Result};
pub fn render(
template: &str,
label: &str,
operator_pronouns: &str,
hive_name: Option<&str>,
swarm_name: Option<&str>,
docs_dir: Option<&str>,
@ -36,6 +37,7 @@ pub fn render(
let rendered = body
.replace("{label}", label)
.replace("{qualified_label}", &qualified)
.replace("{operator_pronouns}", operator_pronouns)
.replace("{hive_identity}", &hive_identity)
.replace("{swarm_identity}", &swarm_identity);
// When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable`
@ -112,6 +114,7 @@ fn parse_close_marker(line: &str) -> Option<&str> {
pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf> {
let parent = crate::paths::config_dir();
tokio::fs::create_dir_all(&parent).await.ok();
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
let template_path = hive_sh4re::assets::prompt_template();
let template = tokio::fs::read_to_string(&template_path)
.await
@ -134,6 +137,7 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf>
let body = render(
&template,
label,
&pronouns,
hive_name.as_deref(),
swarm_name.as_deref(),
docs_dir.as_deref(),
@ -232,12 +236,13 @@ shared closer
}
#[test]
fn render_substitutes_label_and_names_no_operator_pronouns() {
// Real template's first agent line.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None);
fn render_substitutes_label_and_pronouns() {
// Real template's first agent line — keeps the renderer
// honest about the {label} / {operator_pronouns} pair the
// harness already relied on.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "they/them", None, None, None);
assert!(rendered.contains("hyperhive agent `alice`"));
assert!(rendered.contains("say \"the operator\""));
assert!(!rendered.contains("pronoun"));
assert!(rendered.contains("**they/them** pronouns"));
assert!(!rendered.contains("{label}"));
assert!(!rendered.contains("{operator_pronouns}"));
}
@ -245,7 +250,7 @@ shared closer
#[test]
fn render_no_role_markers_in_output() {
// No raw role markers should survive into the rendered prompt.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
assert!(!rendered.contains("<!-- role:"));
assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear.
@ -255,13 +260,13 @@ shared closer
#[test]
fn render_uses_agent_opener() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
assert!(rendered.starts_with("You are hyperhive agent"));
}
#[test]
fn render_points_agents_at_the_work_dir() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
}
@ -275,12 +280,19 @@ shared closer
// substitution logic; nothing here depends on the production
// template's flavor markers.
const IDENTITY_FIXTURE: &str = "\
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system.
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. Pronouns: **{operator_pronouns}**.
";
#[test]
fn render_substitutes_hive_identity_when_set() {
let rendered = render(IDENTITY_FIXTURE, "alice", Some("pr1ma"), None, None);
let rendered = render(
IDENTITY_FIXTURE,
"alice",
"she/her",
Some("pr1ma"),
None,
None,
);
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
// swarm clause stays absent when only hive is set.
assert!(!rendered.contains("in swarm"));
@ -291,7 +303,14 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test]
fn render_substitutes_swarm_identity_when_set() {
let rendered = render(IDENTITY_FIXTURE, "ruth", None, Some("constellat1on"), None);
let rendered = render(
IDENTITY_FIXTURE,
"ruth",
"she/her",
None,
Some("constellat1on"),
None,
);
assert!(rendered.contains("in swarm `constellat1on`"));
assert!(!rendered.contains("on hive"));
}
@ -301,6 +320,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
let rendered = render(
IDENTITY_FIXTURE,
"iris",
"she/her",
Some("pr1ma"),
Some("constellat1on"),
None,
@ -314,7 +334,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
fn render_omits_identity_when_unset() {
// None / None must round-trip the non-identity opener verbatim
// — single-hive deployments see zero diff.
let rendered = render(IDENTITY_FIXTURE, "alice", None, None, None);
let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", None, None, None);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
assert!(!rendered.contains("{hive_identity}"));
@ -327,7 +347,14 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
// through `identity::hive_name()` as None (the accessor
// filters empty), but `render` should still no-op on a
// direct `Some("")` from a test fixture or a future caller.
let rendered = render(IDENTITY_FIXTURE, "alice", Some(""), Some(""), None);
let rendered = render(
IDENTITY_FIXTURE,
"alice",
"she/her",
Some(""),
Some(""),
None,
);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
}
@ -337,6 +364,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
let rendered = render(
&PRODUCTION_TEMPLATE,
"alice",
"she/her",
None,
None,
Some("/run/hive-docs"),
@ -352,7 +380,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test]
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
for docs in [None, Some("")] {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, docs);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, docs);
assert!(
!rendered.contains("mounted read-only at"),
"docs pointer must not appear for {docs:?}:\n{rendered}"

View file

@ -264,8 +264,8 @@ pub fn stall_sleep_secs() -> u64 {
/// Resolve the effective context-window size for watermark calculations.
/// Priority order (first wins):
/// 1. API-reported window from the last `result` event's `modelUsage.*.contextWindow`.
/// 2. `harness_state::context_window_tokens` (per-model defaults, then the
/// `HIVE_CONTEXT_WINDOW_TOKENS` override, then 200 000).
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars (Nix-configured per-model defaults).
/// 3. Hard fallback: 200 000.
///
/// The API-reported window is the authoritative per-inference active
/// context limit. It reflects what the model actually enforces — which

View file

@ -151,7 +151,7 @@ pub(super) struct StateSnapshot {
/// Effective context-window token budget for the current model.
/// Primary source: API-reported `modelUsage.*.contextWindow` from
/// the last result event (authoritative per-inference active window).
/// Falls back to `harness_state::context_window_tokens(model)`.
/// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000.
/// Consumers (e.g. dashboard badge) use this to render ctx-usage %.
context_window_tokens: u64,
/// Last-inference token usage from the most recent completed

View file

@ -67,6 +67,20 @@ pub struct Coordinator {
/// each per-agent flake so the agent's web UI can build the right
/// rebuild-button URL pointing back at the dashboard.
pub dashboard_port: u16,
/// Operator pronouns (free text) — `she/her` by default, set via
/// the NixOS module option `services.hive-c0re.operatorPronouns`.
/// Reaches each container as the `HIVE_OPERATOR_PRONOUNS` env var
/// (injected into `systemd.services.<harness>.environment` by the
/// meta flake); the harness substitutes it into the agent /
/// manager system prompt at boot.
pub operator_pronouns: String,
/// Per-model context-window sizes in tokens. Set via the host-level
/// `services.hive-c0re.contextWindowTokens` NixOS option; injected
/// into each container as `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`
/// by the meta flake renderer. The harness uses these to derive
/// compaction / auto-reset watermarks and exposes the active value
/// on `/api/state` as `context_window_tokens`.
pub context_window_tokens: std::collections::HashMap<String, u64>,
/// Per-agent systemd `CPUQuota=` value (e.g. `"200%"`). Written into
/// the `container@h-<name>.service.d/` drop-in on every spawn/rebuild.
pub agent_cpu_quota: String,
@ -86,8 +100,9 @@ pub struct Coordinator {
/// estimate on the ST4TS tab. Set via `services.hyperhive.modelPrices`
/// and passed to `hive-c0re serve --model-prices <json>`. Models not
/// in the table fall back to the built-in estimate in `hive_stats`.
/// hive-c0re-local (read only by the `/api/stats-hive` handler), so it
/// is *not* part of `HiveEnv` and is never injected into containers.
/// hive-c0re-local (read only by the `/api/stats-hive` handler), so —
/// unlike `context_window_tokens` — it is *not* part of `HiveEnv` and
/// is never injected into containers.
pub model_prices: crate::hive_stats::PriceTable,
agents: Mutex<HashMap<String, AgentSocket>>,
/// Tombstone for transients that have JUST been cleared. The
@ -209,6 +224,8 @@ pub struct HiveEnv {
/// alive host-side by the module that resolved it.
pub claude_code_path: Option<String>,
pub dashboard_port: u16,
pub operator_pronouns: String,
pub context_window_tokens: std::collections::HashMap<String, u64>,
/// Per-agent systemd `CPUQuota=` value (e.g. `"200%"`).
pub agent_cpu_quota: String,
/// Per-agent systemd `MemoryMax=` value (e.g. `"4G"`).
@ -230,6 +247,12 @@ impl Default for HiveEnv {
nixpkgs_flake: String::new(),
claude_code_path: None,
dashboard_port: 7000,
operator_pronouns: "she/her".to_string(),
context_window_tokens: std::collections::HashMap::from([
("haiku".to_string(), 200_000),
("sonnet".to_string(), 1_000_000),
("opus".to_string(), 1_000_000),
]),
agent_cpu_quota: "200%".to_string(),
agent_memory_max: "4G".to_string(),
// Slightly below the kernel default of 100, so agent
@ -294,7 +317,9 @@ mod serve_config_tests {
assert_eq!(sc.env.hyperhive_flake, "/x");
assert_eq!(sc.env.dashboard_port, 1234);
// omitted → HiveEnv::default()
assert_eq!(sc.env.operator_pronouns, "she/her");
assert_eq!(sc.env.agent_cpu_quota, "200%");
assert_eq!(sc.env.context_window_tokens.get("sonnet"), Some(&1_000_000));
assert!(sc.model_prices.contains_key("opus"));
}
@ -407,6 +432,8 @@ impl Coordinator {
nixpkgs_flake,
claude_code_path,
dashboard_port,
operator_pronouns,
context_window_tokens,
agent_cpu_quota,
agent_memory_max,
agent_cpu_weight,
@ -446,6 +473,8 @@ impl Coordinator {
nixpkgs_flake,
claude_code_path,
dashboard_port,
operator_pronouns,
context_window_tokens,
agent_cpu_quota,
agent_memory_max,
agent_cpu_weight,
@ -477,6 +506,8 @@ impl Coordinator {
nixpkgs_flake: self.nixpkgs_flake.clone(),
claude_code_path: self.claude_code_path.clone(),
dashboard_port: self.dashboard_port,
operator_pronouns: self.operator_pronouns.clone(),
context_window_tokens: self.context_window_tokens.clone(),
agent_cpu_quota: self.agent_cpu_quota.clone(),
agent_memory_max: self.agent_memory_max.clone(),
agent_cpu_weight: self.agent_cpu_weight,

View file

@ -90,6 +90,17 @@ enum Cmd {
/// Override: dashboard HTTP port.
#[arg(long)]
dashboard_port: Option<u16>,
/// Override: operator pronouns (free text). Threaded into each
/// container's harness via `HIVE_OPERATOR_PRONOUNS`.
#[arg(long)]
operator_pronouns: Option<String>,
/// Override: per-model context-window sizes, as a JSON object
/// mapping model-family short name to token count. Threaded into
/// each container as `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env
/// vars. Set via the `services.hive-c0re.contextWindowTokens`
/// NixOS option.
#[arg(long)]
context_window_tokens: Option<String>,
/// Override: systemd `CPUQuota=` applied to every agent container
/// via a drop-in (e.g. `"200%"` = 2 cores). Set via
/// `services.hyperhive.agentCpuQuota`.
@ -139,6 +150,8 @@ async fn main() -> Result<()> {
hyperhive_flake,
nixpkgs_flake,
dashboard_port,
operator_pronouns,
context_window_tokens,
agent_cpu_quota,
agent_memory_max,
agent_cpu_weight,
@ -167,6 +180,13 @@ async fn main() -> Result<()> {
if let Some(v) = dashboard_port {
sc.env.dashboard_port = v;
}
if let Some(v) = operator_pronouns {
sc.env.operator_pronouns = v;
}
if let Some(v) = context_window_tokens {
sc.env.context_window_tokens =
serde_json::from_str(&v).context("--context-window-tokens: invalid JSON")?;
}
if let Some(v) = agent_cpu_quota {
sc.env.agent_cpu_quota = v;
}

View file

@ -334,16 +334,25 @@ fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> String {
&hive.nixpkgs_flake,
hive.claude_code_path.as_deref(),
hive.dashboard_port,
&hive.operator_pronouns,
&hive.context_window_tokens,
agents,
)
}
#[allow(
clippy::too_many_arguments,
reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \
agents, forge base); a params struct would just move the same fields"
)]
fn render_flake(
hyperhive_flake: &str,
docs_flake: &str,
nixpkgs_flake: &str,
claude_code_path: Option<&str>,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> String {
render_flake_with_lookup(
@ -352,6 +361,8 @@ fn render_flake(
nixpkgs_flake,
claude_code_path,
dashboard_port,
operator_pronouns,
context_window_tokens,
agents,
agent_canonical_inputs,
)
@ -555,6 +566,10 @@ struct OtelConfig {
endpoint: String,
protocol: String,
extra_resource_attributes: Option<String>,
metric_interval_ms: Option<u64>,
/// `HYPERHIVE_OTEL_DEBUG=1` → `services.hyperhive.agent.otel.debug = true` →
/// `CLAUDE_CODE_OTEL_DIAG_STDERR=1` in every agent's env.
debug: bool,
}
/// Read the hive-wide OTEL config from env, or `None` when OTEL is off.
@ -574,10 +589,19 @@ fn otel_config() -> Option<OtelConfig> {
let extra_resource_attributes = std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES")
.ok()
.filter(|v| !v.is_empty());
let metric_interval_ms = std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|v| *v > 0);
let debug = std::env::var("HYPERHIVE_OTEL_DEBUG")
.ok()
.is_some_and(|v| v == "1");
Some(OtelConfig {
endpoint,
protocol,
extra_resource_attributes,
metric_interval_ms,
debug,
})
}
@ -718,8 +742,10 @@ fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
/// agent flake-lock introspection.
#[allow(
clippy::too_many_lines,
clippy::too_many_arguments,
reason = "templated string-builder for the meta flake — the length is one \
contiguous fmt block, splitting it would just hide the shape"
contiguous fmt block, splitting it would just hide the shape; the \
args mirror the host-level config the flake is rendered from"
)]
fn render_flake_with_lookup<F>(
hyperhive_flake: &str,
@ -727,6 +753,8 @@ fn render_flake_with_lookup<F>(
nixpkgs_flake: &str,
claude_code_path: Option<&str>,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
lookup: F,
) -> String
@ -802,9 +830,13 @@ where
}
}
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
// Free-text operator string — escape backslash + double-quote so a
// pronouns value like `he/him \ "rare"` round-trips into a valid
// nix string literal without breaking the flake.
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n mkAgent = {{ name, isManager, port }}:"
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
);
out.push_str(
r#" let
@ -889,6 +921,17 @@ where
esc(attrs)
);
}
if let Some(ms) = otel.metric_interval_ms {
// Int option — emit a bare numeric literal (no quotes). `ms` is a
// parsed u64, so it can't inject anything into the rendered nix.
let _ = writeln!(
out,
" services.hyperhive.agent.otel.metricIntervalMs = {ms};"
);
}
if otel.debug {
out.push_str(" services.hyperhive.agent.otel.debug = true;\n");
}
}
// Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`):
// emit the host's real values as build-time agent config, the same
@ -982,8 +1025,20 @@ where
HIVE_PORT = toString port;
HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort;
",
HIVE_OPERATOR_PRONOUNS = operatorPronouns;",
);
// Per-model context-window env vars declared in the host-level
// `services.hive-c0re.contextWindowTokens` option. Use a sorted
// iterator for deterministic flake output (no spurious git diffs).
let mut sorted_tokens: Vec<(&String, &u64)> = context_window_tokens.iter().collect();
sorted_tokens.sort_by_key(|(k, _)| k.as_str());
for (key, val) in &sorted_tokens {
let upper_key = key.to_ascii_uppercase();
let _ = writeln!(
out,
" HIVE_CONTEXT_WINDOW_TOKENS_{upper_key} = \"{val}\";"
);
}
// Forwarded env vars — picked up from hive-c0re's own systemd unit
// env (`services.hyperhive.*` options flow through the hyperhive
// NixOS module into the host process). We copy whatever's set into
@ -1259,6 +1314,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
// nixpkgs is a top-level input with an explicit URL; hyperhive
@ -1303,6 +1360,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1319,6 +1378,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1357,6 +1418,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
Some("/nix/store/cccc-claude-code-2.1.220"),
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1383,6 +1446,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1401,6 +1466,8 @@ mod tests {
"",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1429,6 +1496,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[
sample_spec("argus", false, 9001),
sample_spec("bitburner", false, 9002),
@ -1460,6 +1529,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
);
@ -1496,6 +1567,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
unsafe {
@ -1539,6 +1612,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
};
@ -1717,6 +1792,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
unsafe {
@ -1751,6 +1828,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
let want = format!(
@ -1793,6 +1872,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
};
@ -1883,6 +1964,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
};
@ -1955,6 +2038,8 @@ mod tests {
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
};

View file

@ -40,8 +40,8 @@ use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use super::container_stats::{self, ContainerResource};
use super::warnings;
/// Export cadence, and the snapshot refresher's.
const EXPORT_INTERVAL: Duration = Duration::from_mins(1);
/// Default export cadence when `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` is unset.
const DEFAULT_INTERVAL: Duration = Duration::from_mins(1);
/// Latest container-stats snapshot: written by the async refresher, read by
/// the sync observable-instrument callbacks.
@ -69,7 +69,7 @@ pub fn spawn_exporter(hyperhive_flake: &str) {
let snapshot = SNAPSHOT
.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
.clone();
let interval = EXPORT_INTERVAL;
let interval = interval();
// Async refresher: keep the shared snapshot current for the sync callbacks.
let refresh = snapshot.clone();
@ -124,9 +124,10 @@ fn build_provider(
.with_protocol(Protocol::HttpJson)
.build()
.context("build OTLP metric exporter")?;
// Drive the reader's export at `interval`, the same cadence as the
// refresher. The refresher gathers first, so the snapshot is populated
// before the first export.
// Drive the reader's export at `interval` so `HYPERHIVE_OTEL_METRIC_INTERVAL_MS`
// is the real export cadence (not just the snapshot-refresh cadence). The
// refresher runs at the same interval, gather-first, so the snapshot is
// populated before the first export.
let reader = PeriodicReader::builder(exporter)
.with_interval(interval)
.build();
@ -399,6 +400,15 @@ fn endpoint() -> Option<String> {
.filter(|s| !s.is_empty())
}
/// Export cadence from `HYPERHIVE_OTEL_METRIC_INTERVAL_MS`, else the default.
fn interval() -> Duration {
std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|ms| *ms > 0)
.map_or(DEFAULT_INTERVAL, Duration::from_millis)
}
/// Extra resource attributes from `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`
/// (`key=value,key=value`), same env the agent-side export reads.
fn resource_attributes() -> Vec<(String, String)> {

View file

@ -55,10 +55,11 @@ impl HttpClient for BoxedHttpClient {
}
}
/// Export cadence, and the snapshot refresher's. Matches
/// `hive-c0re::stats::otel_metrics` — no reason for the exporters across
/// this tree to disagree on how fresh "current" means.
const EXPORT_INTERVAL: Duration = Duration::from_mins(1);
/// Default export cadence when `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` is unset.
/// Matches `hive-c0re::stats::otel_metrics`'s default — no reason for the
/// exporters across this tree to disagree on how fresh "current" means by
/// default.
const DEFAULT_INTERVAL: Duration = Duration::from_mins(1);
/// Spawn a jobq-rollup OTEL exporter for `jobq` if OTEL is configured
/// (`OTEL_EXPORTER_OTLP_ENDPOINT` non-empty) — `None` otherwise, same
@ -96,7 +97,7 @@ where
{
let endpoint = endpoint()?;
let snapshot: Arc<Mutex<Vec<StateCount>>> = Arc::new(Mutex::new(Vec::new()));
let interval = EXPORT_INTERVAL;
let interval = interval();
let refresh = snapshot.clone();
tokio::spawn(async move {
@ -224,6 +225,18 @@ fn endpoint() -> Option<String> {
.filter(|s| !s.is_empty())
}
/// Export cadence from `HYPERHIVE_OTEL_METRIC_INTERVAL_MS`, else the
/// default. Same variable `hive-c0re::stats::otel_metrics` reads — one
/// cadence knob for every OTEL exporter in the tree, not a new one per
/// module.
fn interval() -> Duration {
std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|ms| *ms > 0)
.map_or(DEFAULT_INTERVAL, Duration::from_millis)
}
/// Extra resource attributes from `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`
/// (`key=value,key=value`), same env `hive-c0re::stats::otel_metrics` reads.
fn resource_attributes() -> Vec<(String, String)> {

View file

@ -70,9 +70,11 @@ in
`swarmctl agent inference set`) wins over this option.
Valid values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). The
harness takes the context window from the model's own report after
each turn, and from a per-family default before the first.
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
window sizes are looked up at runtime from the
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars injected by the
meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens`
on the host.
'';
};

View file

@ -219,7 +219,7 @@ in
baseClaudeEnv
// lib.optionalAttrs otelCfg.enable otelSettingsEnv
// lib.optionalAttrs (otelCfg.enable && otelCfg.debug) {
# SDK diagnostics — noisy; only on when services.hyperhive.agent.otel.debug = true.
# SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true.
CLAUDE_CODE_OTEL_DIAG_STDERR = "1";
};
in

View file

@ -51,7 +51,6 @@ in
./memory-cap.nix
./network.nix
./otel.nix
./otel-removed.nix
./packages.nix
./queue.nix
./queue-identity.nix
@ -281,6 +280,9 @@ in
# here so both the harness binary and any user-shell `cargo run`
# inside the container resolve them from the same path.
# SHELL must be set so claude's Bash tool finds a POSIX shell.
# HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the
# host-level `services.hyperhive.c0re.contextWindowTokens` option — not
# set here.
environment.variables = {
HIVE_ASSETS_DIR = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive";
SHELL = "${pkgs.bashInteractive}/bin/bash";

View file

@ -1,27 +0,0 @@
# Removal shim for `services.hyperhive.agent.otel.metricIntervalMs`.
#
# A definition is ignored and produces a warning, so an agent config, or a
# meta flake rendered before the option went, keeps evaluating. claude runs
# at the OTEL SDK's default export cadence.
#
# Self-contained and deletable: one file to remove when the deprecation
# window closes.
{ lib, config, ... }:
let
value = config.services.hyperhive.agent.otel.metricIntervalMs;
in
{
options.services.hyperhive.agent.otel.metricIntervalMs = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
visible = false;
internal = true;
description = "Removed and ignored. `null` is \"not set\".";
};
config.warnings = lib.optional (value != null) ''
services.hyperhive.agent.otel.metricIntervalMs is removed and ignored:
claude exports metrics at the SDK default of 60 seconds. Remove this
setting.
'';
}

View file

@ -1,6 +1,5 @@
# OpenTelemetry wiring for an agent container: the per-agent options the
# meta flake injects (host-driven from `services.hyperhive.otel.*`) plus
# `debug`, which the agent's own config sets, the
# meta flake injects (host-driven from `services.hyperhive.otel.*`), the
# environment every OTLP producer inside the container reads, and the
# collector that forwards this container's journal to the hive.
#
@ -63,6 +62,9 @@ let
# silently drop without a deltatocumulative processor.
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
OTEL_RESOURCE_ATTRIBUTES = resourceAttributes;
}
// lib.optionalAttrs (cfg.metricIntervalMs != null) {
OTEL_METRIC_EXPORT_INTERVAL = toString cfg.metricIntervalMs;
};
# The exporter's NAME picks the wire protocol, so `protocol` has to be
@ -129,14 +131,25 @@ in
'';
};
metricIntervalMs = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
internal = true;
description = ''
Metric export interval in milliseconds, set as
`OTEL_METRIC_EXPORT_INTERVAL`. Null leaves the SDK default (60s
for Claude Code). Host-driven via
`services.hyperhive.otel.metricIntervalMs`.
'';
};
debug = lib.mkOption {
type = lib.types.bool;
default = false;
internal = true;
description = ''
Emit claude's OTEL SDK diagnostics to stderr
(`CLAUDE_CODE_OTEL_DIAG_STDERR=1`). Set it in this agent's own
config while troubleshooting its collector connection; it is noisy,
so leave it off otherwise. Only takes effect when `enable` is true.
Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`).
Host-driven via `services.hyperhive.otel.debug`.
'';
};
};

Some files were not shown because too many files have changed in this diff Show more