From f44bf19707da7a174dda9c747e9dca7c6be893e0 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 29 May 2026 19:09:44 +0200 Subject: [PATCH] hive-ag3nt: identity module with hive-qualified label (#589 phase A, first PR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First chunk of #589 v0 phase A: plumbing the hive-qualified 'name@hive' form through the per-agent surfaces that the harness itself owns. Broker from/to + dashboard rendering + container_view follow in subsequent PRs once damocles ships the HYPERHIVE_HIVE_DOMAIN env var in harness-base.nix. - new hive_ag3nt::identity module: label() / hive_domain() / qualified_label() / qualify(label). Reads HYPERHIVE_HIVE_DOMAIN (set by hive-c0re.nix module from hyperhive.domain) — when unset or empty, qualified_label degrades to just the short label so existing single-hive deployments are unchanged. Six unit tests cover the set / unset / empty / arbitrary-label paths. - prompt::render gains {qualified_label} substitution alongside the existing {label}. system.md template uses both: the agent intro now reads 'You are hyperhive agent iris (qualified: iris@darkest.space) in a multi-agent system. ... When you're talking to or about a peer on a different hive, use the qualified form (name@hive) so the operator + the manager can disambiguate'. Manager flavor gets the same treatment. - /api/state gains qualified_label: String. Always present, equals label when no domain is configured. - frontend setHeader takes the qualified_label, drives the browser tab title (so two tabs from different hives are distinguishable in the tab bar) while the glyphic #title stays short for the cinematic header. Gated on env var presence — no behaviour change for single-hive deployments. Pairs with damocles's upcoming harness-base.nix HYPERHIVE_HIVE_DOMAIN ship; safe to land in either order. --- frontend/packages/agent/src/app.js | 15 ++- hive-ag3nt/prompts/system.md | 4 +- hive-ag3nt/src/identity.rs | 147 +++++++++++++++++++++++++++++ hive-ag3nt/src/lib.rs | 1 + hive-ag3nt/src/prompt.rs | 9 ++ hive-ag3nt/src/web_ui.rs | 7 ++ 6 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 hive-ag3nt/src/identity.rs diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 7889083f..edb3281e 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -139,13 +139,22 @@ window.marked = marked; })(); // ─── state rendering ──────────────────────────────────────────────────── - function setHeader(label, dashboardPort) { + function setHeader(label, qualifiedLabel, dashboardPort) { const title = $('title'); // Title is now just the glowing identity glyph — DASHB04RD, // R3BU1LD, NEW SESSION all live in the overflow `⋯` menu now // (#394). Glow + uppercase styling from h2 / .agent-header-title-row. + // The glyphic title stays short (no @hive suffix) — the hive + // qualifier lives on the second row's `qualified` chip + the + // browser tab title so the cinematic header reads cleanly at a + // glance (#589). title.textContent = `◆ ${label} ◆`; - document.title = `${label} // hyperhive`; + // Document title carries the qualified name so the browser's tab + // bar disambiguates between same-named agents on different hives + // in a federated swarm. Falls back to `${label} // hyperhive` when + // the qualified form is just the short label (single-hive deploys). + const tab = qualifiedLabel && qualifiedLabel !== label ? qualifiedLabel : label; + document.title = `${tab} // hyperhive`; const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; dashboardBase = dashUrl; populateOverflowMenu(label, dashUrl); @@ -996,7 +1005,7 @@ window.marked = marked; const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); - if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } + if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port); headerSet = true; } currentLabel = s.label; // Render server-supplied navigation links — stats, screen, the // forge profile, the agent-configs mirror, plus any diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 721feccd..43332915 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -1,8 +1,8 @@ -You are hyperhive agent `{label}` in a multi-agent system. The operator (recipient `operator` in `send`, 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). +You are hyperhive agent `{label}` (qualified: `{qualified_label}`) in a multi-agent system. The operator (recipient `operator` in `send`, 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 + the manager can disambiguate; within your own hive the short form is fine. -You are the hyperhive manager `{label}` in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. +You are the hyperhive manager `{label}` (qualified: `{qualified_label}`) in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`); within your own hive the short form is fine. Tools (hyperhive surface): diff --git a/hive-ag3nt/src/identity.rs b/hive-ag3nt/src/identity.rs new file mode 100644 index 00000000..e49b0cee --- /dev/null +++ b/hive-ag3nt/src/identity.rs @@ -0,0 +1,147 @@ +//! Agent identity helpers — short label + hive-qualified long name. +//! +//! `HIVE_LABEL` is the short, hive-local agent name (e.g. `iris`, `damocles`). +//! `HYPERHIVE_HIVE_DOMAIN` is the hive's canonical DNS domain (e.g. +//! `darkest.space`). When the hive-domain env var is set (set by the +//! `hive-c0re.nix` module from `hyperhive.domain`), the qualified label is +//! `${label}@${domain}` — e.g. `iris@darkest.space`. When unset (single-hive +//! deployments, dev/test scenarios) the qualified label degrades to just the +//! short label so existing callers see no change. +//! +//! Per #589 v0: this qualified form surfaces in the per-agent web UI title, +//! the system prompt template, and `/api/state.qualified_label`. Broker +//! `from` / `to` qualification + dashboard rendering of cross-hive +//! identities are subsequent follow-ups inside #589. +//! +//! The hive name itself IS the operator's DNS domain — `hyperhive.hiveName` +//! was deliberately dropped in #589 spec discussion (mara #6577 / iris +//! #6582) so there's one source of truth. Matrix MXIDs already use the +//! same convention (`@iris:darkest.space`), so federation lookups Just Work +//! without a separate slug. + +use std::env; + +/// Short, hive-local agent label. Read from `HIVE_LABEL`; falls back to an +/// empty string when the env var is missing, so callers downstream can decide +/// how to surface "unknown agent" rather than getting a panic from this +/// module. +#[must_use] +pub fn label() -> String { + env::var("HIVE_LABEL").unwrap_or_default() +} + +/// The hive's canonical DNS domain when set, otherwise None. Single-hive +/// deployments where `HYPERHIVE_HIVE_DOMAIN` is unset return None — callers +/// then degrade gracefully to the short label. +#[must_use] +pub fn hive_domain() -> Option { + env::var("HYPERHIVE_HIVE_DOMAIN") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Hive-qualified agent identity. When the hive domain is configured, returns +/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just +/// the short label so callers can render a single string regardless of +/// deployment shape. Callers that want to know whether the result is +/// qualified should check [`hive_domain`] directly. +#[must_use] +pub fn qualified_label() -> String { + qualify(&label()) +} + +/// Apply hive qualification to an arbitrary agent label (e.g. a peer name +/// from the broker). Mirrors [`qualified_label`] but lets a caller qualify +/// names it didn't read from the env. Returns `${label}@${domain}` when the +/// hive domain is set, else just `label`. +#[must_use] +pub fn qualify(label: &str) -> String { + match hive_domain() { + Some(domain) if !label.is_empty() => format!("{label}@{domain}"), + _ => label.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: run `f` with a clean env, restoring previous values on exit. + /// Tests run serially in this module (no `#[parallel]`) to avoid the + /// process-wide env var contention. + fn with_env(label: Option<&str>, domain: Option<&str>, f: F) { + let prev_label = env::var("HIVE_LABEL").ok(); + let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok(); + // SAFETY: tests are single-threaded; we restore in the same scope. + unsafe { + match label { + Some(v) => env::set_var("HIVE_LABEL", v), + None => env::remove_var("HIVE_LABEL"), + } + match domain { + Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v), + None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"), + } + } + f(); + unsafe { + match prev_label { + Some(v) => env::set_var("HIVE_LABEL", v), + None => env::remove_var("HIVE_LABEL"), + } + match prev_domain { + Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v), + None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"), + } + } + } + + #[test] + fn qualified_label_with_domain_set() { + with_env(Some("iris"), Some("darkest.space"), || { + assert_eq!(qualified_label(), "iris@darkest.space"); + assert_eq!(hive_domain().as_deref(), Some("darkest.space")); + }); + } + + #[test] + fn qualified_label_falls_back_to_short_when_domain_unset() { + with_env(Some("iris"), None, || { + assert_eq!(qualified_label(), "iris"); + assert!(hive_domain().is_none()); + }); + } + + #[test] + fn qualified_label_falls_back_to_short_when_domain_empty() { + // Empty string is treated the same as unset — a misconfigured + // module shouldn't surface `iris@` (no domain) to the operator. + with_env(Some("iris"), Some(""), || { + assert_eq!(qualified_label(), "iris"); + assert!(hive_domain().is_none()); + }); + } + + #[test] + fn qualify_takes_arbitrary_label() { + with_env(Some("iris"), Some("darkest.space"), || { + // Local label gets local hive applied; useful for rendering + // a peer's name when the caller knows it's hive-local. + assert_eq!(qualify("damocles"), "damocles@darkest.space"); + }); + } + + #[test] + fn qualify_empty_label_stays_empty() { + with_env(Some("iris"), Some("darkest.space"), || { + assert_eq!(qualify(""), ""); + }); + } + + #[test] + fn label_returns_empty_when_unset() { + with_env(None, None, || { + assert_eq!(label(), ""); + }); + } +} diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index d120d5cd..34c04282 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -4,6 +4,7 @@ pub mod client; pub mod events; pub mod forge_notify; +pub mod identity; pub mod login; pub mod login_session; pub mod mcp; diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index 50bac2b8..6178e015 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -40,6 +40,13 @@ use crate::mcp::Flavor; /// fixture and production reads it once at harness startup via /// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/ /// system.md`). +/// +/// `{label}` and `{operator_pronouns}` are substituted in the filtered body. +/// `{qualified_label}` (#589) is also substituted — it's `${label}@${hive}` +/// in federated deployments, or the same as `{label}` when no hive domain is +/// configured (single-hive deployments). Templates that always want the +/// fully-qualified form can use `{qualified_label}` and stay correct in +/// both shapes. #[must_use] pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &str) -> String { let target = match flavor { @@ -47,7 +54,9 @@ pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &s Flavor::Manager => "manager", }; let body = filter_role_blocks(template, target); + let qualified = crate::identity::qualify(label); body.replace("{label}", label) + .replace("{qualified_label}", &qualified) .replace("{operator_pronouns}", operator_pronouns) } diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 0c330f7f..4e117c1e 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -340,6 +340,12 @@ struct StateSnapshot { /// harness restart — clients treat reconnect as a fresh world. seq: u64, label: String, + /// Hive-qualified long name (`${label}@${hyperhive.domain}`) when + /// the host has been configured for a multi-hive swarm; falls back + /// to the short label when the hive domain env var is unset (#589). + /// The frontend uses this for the page title / agent self-introduction; + /// when it equals `label`, the page renders the short form unchanged. + qualified_label: String, dashboard_port: u16, /// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`. status: &'static str, @@ -515,6 +521,7 @@ async fn api_state(State(state): State) -> axum::Json { axum::Json(StateSnapshot { seq, label: state.label.clone(), + qualified_label: crate::identity::qualify(&state.label), dashboard_port, status, session: session_view,