From 6942f8fd275f85789bf554a7c8916214a2ebb757 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 17 Oct 2026 11:17:07 +0200 Subject: [PATCH 1/2] publish operators' forge-profile pronouns to kv and list them in the agent prompt swarm-controller reads each member of authelia's operator group's forge profile every five minutes and keeps one key per operator in the new operator-profiles bucket. Every agent may list and read that bucket, and hive-agent renders it into the system prompt at harness start, falling back to today's prompt when the bucket is empty or doesn't answer within five seconds. Fixes #5383 --- docs/getting-started/setup.md | 3 + docs/turn-loop/claude-invocation.md | 16 +- hive-agent/Cargo.toml | 8 +- hive-agent/prompts/system.md | 2 +- hive-agent/src/prompt.rs | 155 +++++++++- nix/host-modules/hive-options-removed.nix | 8 +- nix/host-modules/swarm-nats.nix | 3 + swarm-controller/README.md | 7 + swarm-controller/src/forge.rs | 1 + .../src/forge/operator_profiles.rs | 290 ++++++++++++++++++ swarm-controller/src/main.rs | 14 +- swarm-nats-auth/src/agent_token.rs | 4 + swarm-nats-auth/src/policy.rs | 100 +++++- swarm-queue-client/Cargo.toml | 4 + swarm-queue-client/src/lib.rs | 5 + swarm-queue-client/src/operator_profiles.rs | 96 ++++++ 16 files changed, 681 insertions(+), 35 deletions(-) create mode 100644 swarm-controller/src/forge/operator_profiles.rs create mode 100644 swarm-queue-client/src/operator_profiles.rs diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 8d09aa3f..1258cc68 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -89,6 +89,9 @@ swarmctl forge make-admin mara If the forge already has a local account with your username, the first SSO login asks for its forge password once, to link the two. +Agents learn your pronouns from the **Pronouns** field of your forge profile +settings. Leave it empty and agents refer to you by name only. + ## 4 · Ruth hive-c0re creates no agent at startup. You create ruth, the manager agent, diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index 992b6314..70b81e2c 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -255,13 +255,21 @@ 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_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. + `HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same + shape for the swarm), and `{operators}`: a paragraph listing the + swarm's operators with the pronouns from each one's forge profile, + for example `Operators: mara (she/her), sol.`. The harness reads + them from the `operator-profiles` KV bucket, which + `swarm-controller` writes. The harness waits up to five seconds for + them. When the bucket is empty or doesn't answer in time, + `{operators}` is empty and the prompt names no operator. Either way + the template tells agents to say "the operator" in third person. A + pronoun change reaches an agent at its next harness start, since + the prompt renders once per start. 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/Cargo.toml b/hive-agent/Cargo.toml index b31612c3..e75af5d6 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -39,13 +39,15 @@ schemars.workspace = true serde.workspace = true serde_json.workspace = true # `agent-schedules` and `agent-inference`: the schedule ticker and the -# inference poller each read their own key with one direct get. `kv`/`notices` -# name buckets and streams this harness opens neither end of, and the terminal -# publisher is a plain core-subject publish. +# inference poller each read their own key with one direct get. +# `operator-profiles`: the prompt renderer lists the operator-profile bucket. +# `kv`/`notices` name buckets and streams this harness opens neither end of, +# and the terminal publisher is a plain core-subject publish. swarm-queue-client = { workspace = true, features = [ "agent-schedules", "agent-inference", "agent-permissions", + "operator-profiles", ] } # This agent's own queue secret, read from the store under its own certificate. swarm-secret-client.workspace = true diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 1fce9027..18bba8de 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 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.{operators} 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/prompt.rs b/hive-agent/src/prompt.rs index 7ca58e82..4828c216 100644 --- a/hive-agent/src/prompt.rs +++ b/hive-agent/src/prompt.rs @@ -5,11 +5,17 @@ //! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; +use swarm_queue_client::operator_profiles::{self, OperatorProfile}; + +/// How long [`write_system_prompt`] waits for the operator list before +/// rendering without it. +const OPERATORS_TIMEOUT: Duration = Duration::from_secs(5); /// Assemble the system prompt for a given label + optional hive / -/// swarm display names. Pure function — no I/O. Splits out from +/// swarm display names + the swarm's operators. 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 /// inline fixture and production reads it once at harness startup via @@ -24,6 +30,7 @@ pub fn render( hive_name: Option<&str>, swarm_name: Option<&str>, docs_dir: Option<&str>, + operators: &[OperatorProfile], ) -> String { let body = filter_role_blocks(template, "agent"); let qualified = crate::identity::qualify(label); @@ -37,7 +44,8 @@ pub fn render( .replace("{label}", label) .replace("{qualified_label}", &qualified) .replace("{hive_identity}", &hive_identity) - .replace("{swarm_identity}", &swarm_identity); + .replace("{swarm_identity}", &swarm_identity) + .replace("{operators}", &operators_line(operators)); // When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable` // wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer // sentence so the agent knows they exist. Additive: it doesn't replace the @@ -53,6 +61,55 @@ pub fn render( } } +/// The `{operators}` substitution: a paragraph naming each operator, with their +/// pronouns where set, or nothing when there are none. +fn operators_line(operators: &[OperatorProfile]) -> String { + if operators.is_empty() { + return String::new(); + } + let names: Vec = operators + .iter() + .map(|o| match &o.pronouns { + Some(pronouns) => format!("{} ({pronouns})", o.name), + None => o.name.clone(), + }) + .collect(); + format!("\n\nOperators: {}.", names.join(", ")) +} + +/// The swarm's operators, or none when the operator-profile bucket cannot be +/// read within [`OPERATORS_TIMEOUT`]: the prompt then reads as it does with no +/// operator list. +async fn operators() -> Vec { + within_timeout(OPERATORS_TIMEOUT, async { + let client = crate::swarm_queue::client().await; + swarm_queue_client::ensure_connected(&client)?; + operator_profiles::read_all(&client).await + }) + .await +} + +/// `read`'s profiles, or none when it fails or outlasts `timeout`. +async fn within_timeout( + timeout: Duration, + read: impl Future, E>>, +) -> Vec { + match tokio::time::timeout(timeout, read).await { + Ok(Ok(operators)) => operators, + Ok(Err(e)) => { + tracing::warn!(error = %e, "prompt: operator profiles unreadable; rendering without them"); + Vec::new() + } + Err(_) => { + tracing::warn!( + timeout_s = timeout.as_secs(), + "prompt: no operator profiles within the timeout; rendering without them" + ); + Vec::new() + } + } +} + /// Walk `template` line-by-line, stripping `` / /// `` marker lines and suppressing the lines inside a block /// unless `X == target`. A close marker ends the current block. Production @@ -137,6 +194,7 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result hive_name.as_deref(), swarm_name.as_deref(), docs_dir.as_deref(), + &operators().await, ); let path = parent.join("claude-system-prompt.md"); tokio::fs::write(&path, body).await?; @@ -234,7 +292,7 @@ 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); + let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("say \"the operator\"")); assert!(!rendered.contains("pronoun")); @@ -245,7 +303,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", None, None, None, &[]); assert!(!rendered.contains("