diff --git a/README.md b/README.md index 3e420c60..d8e5f3ce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/scheduler/observability.md b/docs/scheduler/observability.md index 91788e96..299a16db 100644 --- a/docs/scheduler/observability.md +++ b/docs/scheduler/observability.md @@ -325,7 +325,7 @@ labels (`hive`, `swarm`, …) ride on the resource via -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 = diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index 992b6314..bdbc0742 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -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_` 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 (`...`, 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. diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 1fce9027..71042c8d 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -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: diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index 05da0568..0722a535 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -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>>, /// Resolved model id from the most recent turn's `assistant` events /// (the API-echoed `message.model`, e.g. `claude-opus-4-8`), as diff --git a/hive-agent/src/harness_state.rs b/hive-agent/src/harness_state.rs index d6dedf7e..23e447e0 100644 --- a/hive-agent/src/harness_state.rs +++ b/hive-agent/src/harness_state.rs @@ -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_` 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 (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)) - { - return *tokens; + // 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::() + && v > 0 + { + 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() { diff --git a/hive-agent/src/otel_turn_metrics.rs b/hive-agent/src/otel_turn_metrics.rs index 18ef9246..6cf96ff6 100644 --- a/hive-agent/src/otel_turn_metrics.rs +++ b/hive-agent/src/otel_turn_metrics.rs @@ -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 { 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 { 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::().ok()) + .filter(|ms| *ms > 0) + .map_or(DEFAULT_INTERVAL, Duration::from_millis) +} diff --git a/hive-agent/src/prompt.rs b/hive-agent/src/prompt.rs index 7ca58e82..0d7efb35 100644 --- a/hive-agent/src/prompt.rs +++ b/hive-agent/src/prompt.rs @@ -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 { 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 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("