hive-ag3nt: identity module with hive-qualified label (#589 phase A, first PR)
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.
This commit is contained in:
parent
977bb16678
commit
f44bf19707
6 changed files with 178 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<!-- role:agent -->
|
||||
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.
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
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.
|
||||
<!-- /role:manager -->
|
||||
|
||||
Tools (hyperhive surface):
|
||||
|
|
|
|||
147
hive-ag3nt/src/identity.rs
Normal file
147
hive-ag3nt/src/identity.rs
Normal file
|
|
@ -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<String> {
|
||||
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<F: FnOnce()>(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(), "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppState>) -> axum::Json<StateSnapshot> {
|
|||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
qualified_label: crate::identity::qualify(&state.label),
|
||||
dashboard_port,
|
||||
status,
|
||||
session: session_view,
|
||||
|
|
|
|||
Loading…
Reference in a new issue