Watch
0
0
Fork
You've already forked hyperhive
0

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
This commit is contained in:
atlas 2026-10-17 11:17:07 +02:00 • committed by mara
commit 6942f8fd27
16 changed files with 681 additions and 35 deletions

View file

@ -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,

View file

@ -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 (`<!-- 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_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.

View file

@ -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

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 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:

View file

@ -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<String> = 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<OperatorProfile> {
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<E: std::fmt::Display>(
timeout: Duration,
read: impl Future<Output = std::result::Result<Vec<OperatorProfile>, E>>,
) -> Vec<OperatorProfile> {
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 `<!-- role:X -->` /
/// `<!-- /role:X -->` 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<PathBuf>
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("<!-- role:"));
assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear.
@ -255,13 +313,13 @@ shared closer
#[test]
fn render_uses_agent_opener() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", 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", None, None, None, &[]);
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
}
@ -280,7 +338,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test]
fn render_substitutes_hive_identity_when_set() {
let rendered = render(IDENTITY_FIXTURE, "alice", Some("pr1ma"), None, None);
let rendered = render(IDENTITY_FIXTURE, "alice", 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 +349,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",
None,
Some("constellat1on"),
None,
&[],
);
assert!(rendered.contains("in swarm `constellat1on`"));
assert!(!rendered.contains("on hive"));
}
@ -304,6 +369,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
Some("pr1ma"),
Some("constellat1on"),
None,
&[],
);
// Order: hive then swarm, both inline before "in a multi-agent
// system" — keeps the opener grammar intact.
@ -314,7 +380,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", None, None, None, &[]);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
assert!(!rendered.contains("{hive_identity}"));
@ -327,7 +393,7 @@ 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", Some(""), Some(""), None, &[]);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
}
@ -340,6 +406,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
None,
None,
Some("/run/hive-docs"),
&[],
);
// Key on a phrase unique to the pointer sentence — "reference docs"
// alone also appears in the /knowledge blurb of the base template.
@ -352,11 +419,77 @@ 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", None, None, docs, &[]);
assert!(
!rendered.contains("mounted read-only at"),
"docs pointer must not appear for {docs:?}:\n{rendered}"
);
}
}
fn operator(name: &str, pronouns: Option<&str>) -> OperatorProfile {
OperatorProfile {
name: name.to_owned(),
pronouns: pronouns.map(str::to_owned),
}
}
const OPERATORS_FIXTURE: &str = "say \"the operator\".{operators}\n\nnext paragraph\n";
#[test]
fn render_lists_operators_with_their_pronouns() {
let operators = [operator("mara", Some("she/her")), operator("sol", None)];
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &operators);
assert_eq!(
rendered,
"say \"the operator\".\n\nOperators: mara (she/her), sol.\n\nnext paragraph\n"
);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &operators);
assert!(
rendered.contains("Operators: mara (she/her), sol."),
"{rendered}"
);
}
/// No operators renders the template as it reads without the placeholder,
/// byte for byte.
#[test]
fn render_with_no_operators_is_the_template_without_the_placeholder() {
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &[]);
assert_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
assert!(PRODUCTION_TEMPLATE.contains("{operators}"));
assert_eq!(
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]),
render(
&PRODUCTION_TEMPLATE.replace("{operators}", ""),
"alice",
None,
None,
None,
&[]
)
);
}
/// An unreachable or failing bucket reads as no operators, so the prompt
/// renders as it does with none.
#[tokio::test(start_paused = true)]
async fn an_unread_operator_list_is_empty() {
let hangs = std::future::pending::<std::result::Result<Vec<OperatorProfile>, String>>();
let start = tokio::time::Instant::now();
assert_eq!(within_timeout(OPERATORS_TIMEOUT, hangs).await, []);
assert!(
start.elapsed() < OPERATORS_TIMEOUT * 2,
"{:?}",
start.elapsed()
);
let fails = async { Err::<Vec<OperatorProfile>, _>("no bucket".to_owned()) };
assert_eq!(within_timeout(OPERATORS_TIMEOUT, fails).await, []);
// Control: a read that answers in time is what comes back.
let answers = async { Ok::<_, String>(vec![operator("mara", Some("she/her"))]) };
assert_eq!(
within_timeout(OPERATORS_TIMEOUT, answers).await,
[operator("mara", Some("she/her"))]
);
}
}

View file

@ -3,7 +3,8 @@
#
# A definition of any of them is ignored and produces a warning, so a host
# that still sets one keeps evaluating. The agents' system prompt
# (hive-agent/prompts/system.md) names no pronouns for the operator, the
# (hive-agent/prompts/system.md) takes each operator's pronouns from their
# forge profile, the
# harness (hive-agent/src/harness_state.rs) holds the per-model context
# windows it uses until a turn reports the model's own, every OTEL exporter
# exports every 60 seconds (claude at its SDK default, the rest by a fixed
@ -44,9 +45,8 @@ in
config.warnings =
removed "c0re.operatorPronouns" cfg.c0re.operatorPronouns ''
agents' system prompts name no pronouns for the operator and say "the
operator". Pronouns will come from a per-operator profile setting, which
is an open hyperhive forge issue.
agents' system prompts take each operator's pronouns from the Pronouns
field of that operator's forge profile.
''
++ removed "c0re.contextWindowTokens" cfg.c0re.contextWindowTokens ''
the agent harness uses the context window the model reports after each

View file

@ -905,6 +905,9 @@ in
# the harness and the subagent daemon read the tool permissions
# it holds.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-permissions.\$\$KV.agent-permissions.{agent}"}"
# Every agent also reads the whole `operator-profiles` bucket.
# That grant names no agent, so the responder adds it itself
# (`swarm-nats-auth`'s `Policy::agent_token_permissions`).
"--store-cert-role ${lib.escapeShellArg authCertRole}"
];
# The store identity arrives by `LoadCredential` and is named

View file

@ -71,6 +71,13 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
`review-classifier-settings` KV bucket, which this daemon alone reads and
writes (`GET`/`PUT /api/review-classifier/settings`) →
[`docs/swarm/review-classifier.md`](../docs/swarm/review-classifier.md#swarm-level-settings).
- **Operator profiles** — each operator's name and the pronouns on their
forge profile, one key per member of authelia's operator group in the
`operator-profiles` KV bucket, which this daemon alone writes, at start and
every five minutes. A forge read that fails keeps the stored value, and a
name that leaves the group loses its key. Every agent lists the bucket into
its system prompt →
[`docs/turn-loop/claude-invocation.md`](../docs/turn-loop/claude-invocation.md).
- **Relays** — each agent's terminal and turn-state header as SSE, agent
icons, the cross-repo issue report, and the UI's quick links.
- **Alerts** — the swarm Grafana's current alerts (`GET /api/alerts`), read

View file

@ -53,6 +53,7 @@ pub mod docs_grant;
pub mod hive_token;
pub mod legacy_tokens;
pub mod objects;
pub mod operator_profiles;
pub mod pull_diff;
pub mod runner_token;
pub mod site_admin;

View file

@ -0,0 +1,290 @@
//! Each operator's forge-profile pronouns, published to the `operator-profiles`
//! KV bucket (`swarm_queue_client::operator_profiles`) at start and every
//! [`RECONCILE_INTERVAL`] after. Every agent reads that bucket into its system
//! prompt.
//!
//! An operator is a member of authelia's operator group, read the way
//! `crate::matrix_account::operator_admin` reads it. A pass writes a key only
//! when its value changed and removes the key of a name no longer in the
//! group. An operator whose forge profile could not be read keeps the value
//! the bucket holds; one with no forge account yet is listed with no pronouns.
use std::collections::BTreeSet;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_nats::jetstream::kv::Store;
use futures_util::TryStreamExt as _;
use swarm_queue_client::operator_profiles::{self, OperatorProfile};
use super::Client;
use super::agent_token::is_not_found;
use crate::auth::AuthBridge;
/// How often [`spawn`] re-reads the operator group and their profiles.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// Where an operator's pronouns come from.
pub(crate) trait Profiles {
/// The pronouns on `name`'s forge profile: `None` when unset or when the
/// forge has no such account.
fn pronouns(&self, name: &str) -> impl Future<Output = Result<Option<String>>> + Send;
}
impl Profiles for Client {
async fn pronouns(&self, name: &str) -> Result<Option<String>> {
match self.api.call(|api| api.user_get(name).into_future()).await {
Ok(user) => Ok(user
.pronouns
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty())),
Err(e) if is_not_found(&e) => Ok(None),
Err(e) => Err(e).with_context(|| format!("read forge user {name}")),
}
}
}
/// What one pass did.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Outcome {
written: usize,
removed: usize,
/// Operators whose profile could not be read, left as they were.
kept: usize,
}
/// Whether `name` can be a key: one subject token of the characters NATS
/// accepts in one.
fn is_key(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Bring `store` to one key per name in `operators`, each holding that
/// operator's pronouns as `forge` reports them.
///
/// # Errors
/// When the bucket cannot be listed, read or written. A forge read that fails
/// is logged and leaves that operator's key alone.
pub(crate) async fn sync(
operators: &[String],
forge: &impl Profiles,
store: &Store,
) -> Result<Outcome> {
let mut stale: BTreeSet<String> = store
.keys()
.await
.context("listing the operator-profiles bucket")?
.try_collect()
.await
.context("listing the operator-profiles bucket")?;
let mut outcome = Outcome::default();
for name in operators {
stale.remove(name);
if !is_key(name) {
tracing::warn!(
operator = %name,
"operator profiles: the username is no legal key; skipped"
);
continue;
}
let pronouns = match forge.pronouns(name).await {
Ok(pronouns) => pronouns,
Err(e) => {
tracing::warn!(
operator = %name,
error = %format!("{e:#}"),
"operator profiles: forge profile unreadable; keeping the stored value"
);
outcome.kept += 1;
continue;
}
};
let profile = OperatorProfile {
name: name.clone(),
pronouns,
};
let stored = store
.get(name)
.await
.with_context(|| format!("reading the profile of {name}"))?
.and_then(|value| serde_json::from_slice::<OperatorProfile>(&value).ok());
if stored.as_ref() != Some(&profile) {
let value = serde_json::to_vec(&profile).context("encoding an operator profile")?;
store
.put(name, value.into())
.await
.with_context(|| format!("writing the profile of {name}"))?;
outcome.written += 1;
}
}
for name in stale {
store
.delete(&name)
.await
.with_context(|| format!("removing the profile of {name}"))?;
outcome.removed += 1;
}
Ok(outcome)
}
/// One pass: read the operator group, then [`sync`] the bucket to it. Fails
/// as a whole, changing nothing, when the group cannot be read.
async fn pass(forge: &Client, bridge: &AuthBridge, queue: &async_nats::Client) -> Result<Outcome> {
let operators = bridge
.list_operators()
.await
.context("reading authelia's operator group")?;
let store = operator_profiles::open_or_create(queue)
.await
.context("opening the operator-profiles bucket")?;
sync(&operators, forge, &store).await
}
/// Publish the operators' profiles now and every [`RECONCILE_INTERVAL`]
/// after. A pass that fails is logged and retried on the next tick; it never
/// stops the daemon.
pub fn spawn(forge: Arc<Client>, bridge: Arc<AuthBridge>, queue: async_nats::Client) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
let Some(outcome) =
super::within_forge_deadline("operator profiles", pass(&forge, &bridge, &queue))
.await
else {
continue;
};
match outcome {
Ok(outcome) if outcome.written > 0 || outcome.removed > 0 => tracing::info!(
written = outcome.written,
removed = outcome.removed,
kept = outcome.kept,
"operator profiles: bucket updated"
),
Ok(outcome) => tracing::debug!(kept = outcome.kept, "operator profiles: unchanged"),
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"operator profiles: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
/// A forge answering from a table: `Err` for a name whose read fails.
struct Fake(BTreeMap<&'static str, Result<Option<&'static str>, ()>>);
impl Profiles for Fake {
async fn pronouns(&self, name: &str) -> Result<Option<String>> {
match self.0.get(name) {
Some(Ok(p)) => Ok(p.map(str::to_owned)),
Some(Err(())) => anyhow::bail!("the forge refused the read"),
None => Ok(None),
}
}
}
fn names(names: &[&str]) -> Vec<String> {
names.iter().map(|&n| n.to_owned()).collect()
}
async fn stored(store: &Store) -> Vec<OperatorProfile> {
let keys: Vec<String> = store.keys().await.unwrap().try_collect().await.unwrap();
let mut out = Vec::new();
for key in keys {
let value = store
.get(&key)
.await
.unwrap()
.expect("a listed key has a value");
out.push(serde_json::from_slice(&value).unwrap());
}
out.sort_by(|a: &OperatorProfile, b| a.name.cmp(&b.name));
out
}
fn profile(name: &str, pronouns: Option<&str>) -> OperatorProfile {
OperatorProfile {
name: name.to_owned(),
pronouns: pronouns.map(str::to_owned),
}
}
#[tokio::test]
async fn profiles_are_written_and_a_departed_operator_is_removed() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let store = operator_profiles::open_or_create(&client).await.unwrap();
let forge = Fake([("mara", Ok(Some("she/her"))), ("sol", Ok(None))].into());
let outcome = sync(&names(&["mara", "sol"]), &forge, &store)
.await
.unwrap();
assert_eq!(outcome.written, 2);
assert_eq!(
stored(&store).await,
[profile("mara", Some("she/her")), profile("sol", None)]
);
// An unchanged profile is not rewritten.
let outcome = sync(&names(&["mara", "sol"]), &forge, &store)
.await
.unwrap();
assert_eq!(outcome, Outcome::default());
let outcome = sync(&names(&["mara"]), &forge, &store).await.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(stored(&store).await, [profile("mara", Some("she/her"))]);
}
#[tokio::test]
async fn a_failed_forge_read_keeps_the_stored_value() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let store = operator_profiles::open_or_create(&client).await.unwrap();
let reachable = Fake(
[
("mara", Ok(Some("she/her"))),
("sol", Ok(Some("they/them"))),
]
.into(),
);
sync(&names(&["mara", "sol"]), &reachable, &store)
.await
.unwrap();
// Control: the same sync does change a value the forge reads anew.
let failing = Fake([("mara", Err(())), ("sol", Ok(Some("he/him")))].into());
let outcome = sync(&names(&["mara", "sol"]), &failing, &store)
.await
.unwrap();
assert_eq!(outcome.kept, 1);
assert_eq!(outcome.removed, 0);
assert_eq!(
stored(&store).await,
[
profile("mara", Some("she/her")),
profile("sol", Some("he/him"))
]
);
}
#[test]
fn a_dotted_or_empty_name_is_no_key() {
assert!(is_key("mara"));
assert!(is_key("op_2-x"));
assert!(!is_key(""));
assert!(!is_key("ma.ra"));
assert!(!is_key("ma*"));
}
}

View file

@ -2863,14 +2863,20 @@ fn spawn_matrix_account_backfill(
/// `internal/docs` grants, and
/// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
/// once there is a forge client. Lifted out of `main` for
/// once there is a forge client, and the operators' profiles when the bridge
/// and the queue are wired up too. Lifted out of `main` for
/// `clippy::too_many_lines`.
fn spawn_forge_workers(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
client: Arc<forge::Client>,
hives: &[HiveEntry],
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) {
forge::objects::spawn(Arc::clone(&client));
if let (Some(bridge), Some(queue)) = (auth, queue) {
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
}
forge::runner_token::spawn(Arc::clone(&client));
let sched = Arc::clone(jobq);
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
@ -3417,6 +3423,8 @@ async fn start_forge(
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
hives: Vec<HiveEntry>,
webhook_secret: Option<Arc<str>>,
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) {
let url = match forge::url_from_env() {
Ok(Some(url)) => url,
@ -3427,7 +3435,7 @@ async fn start_forge(
}
};
forge::enable(url, forge_client, move |client| {
spawn_forge_workers(&jobq, Arc::clone(&client), &hives);
spawn_forge_workers(&jobq, Arc::clone(&client), &hives, auth, queue);
config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
register_swarm_webhooks(client, webhook_secret);
})
@ -3549,6 +3557,8 @@ async fn main() -> Result<()> {
Arc::clone(&jobq),
hives.clone(),
webhook_secret.clone(),
auth.clone(),
status.as_ref().map(|s| s.queue_client()),
)
.await;

View file

@ -118,6 +118,10 @@ mod tests {
"$SWARM.term.atlas".to_owned(),
"$SWARM.agent-state.atlas".to_owned(),
"$KV.agent-icons.atlas".to_owned(),
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
]
);
}

View file

@ -173,12 +173,16 @@ impl Policy {
/// must already be a single `[A-Za-z0-9_-]` segment, which the token parse
/// guarantees, so it cannot widen a subject with `.`, `*` or `>`.
pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> {
let publish: Vec<String> = self
let mut publish: Vec<String> = self
.agent_token_subjects
.iter()
.map(|s| s.replace(AGENT_PLACEHOLDER, agent))
.collect();
(!publish.is_empty()).then(|| Permissions {
if publish.is_empty() {
return None;
}
publish.extend(Self::operator_profiles_read());
Some(Permissions {
publish,
subscribe: Some(vec![
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
@ -196,6 +200,26 @@ impl Policy {
})
}
/// Reading every key of the operator-profile bucket
/// (`swarm_queue_client::operator_profiles::read_all`), which an agent
/// renders into its system prompt.
///
/// The whole bucket, since every agent reads every operator. Not an
/// `{agent}` template, so it is granted here rather than through
/// `--agent-token-publish-subject`. `STREAM.INFO` opens the bucket, both
/// `CONSUMER.CREATE` forms are the `keys()` listing (an ephemeral consumer
/// carries no name, and `>` never matches zero tokens), and `DIRECT.GET`
/// reads each key. No `$KV.` subject: the controller is the only writer.
fn operator_profiles_read() -> [String; 4] {
let stream = swarm_queue_client::operator_profiles::STREAM;
[
format!("$JS.API.STREAM.INFO.{stream}"),
format!("$JS.API.CONSUMER.CREATE.{stream}"),
format!("$JS.API.CONSUMER.CREATE.{stream}.>"),
format!("$JS.API.DIRECT.GET.{stream}.>"),
]
}
/// The permissions for `client_id`, or `None` when no rule matches.
///
/// `None` is a denial. It is not "grant nothing and let them connect":
@ -724,6 +748,18 @@ mod tests {
.expect("the default policy is valid")
}
/// An agent grant's publish list without the operator-profile read every
/// agent holds, so a test can compare the templated part exactly.
fn templated(grant: &Permissions) -> Vec<String> {
let shared = Policy::operator_profiles_read();
grant
.publish
.iter()
.filter(|s| !shared.contains(s))
.cloned()
.collect()
}
#[test]
fn the_reader_can_open_and_list_the_agent_status_bucket() {
let subjects = policy().reader_subjects();
@ -1878,6 +1914,10 @@ mod tests {
vec![
"$SWARM.term.atlas".to_owned(),
"$SWARM.agent-state.atlas".to_owned(),
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
]
);
}
@ -1891,12 +1931,12 @@ mod tests {
.with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![subject("atlas")]);
assert_eq!(templated(&atlas), vec![subject("atlas")]);
assert!(!atlas.publish.contains(&subject("argus")));
// Control: the same template does grant argus its own key, so atlas
// lacking it is atlas's grant being scoped.
let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![subject("argus")]);
assert_eq!(templated(&argus), vec![subject("argus")]);
}
/// The subagent template as `swarm-nats.nix` spells it expands to the
@ -1910,10 +1950,10 @@ mod tests {
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
.expect("a per-agent template is valid");
let g = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(g.publish, vec![stream_subjects("atlas")]);
assert_eq!(templated(&g), vec![stream_subjects("atlas")]);
assert!(subject("atlas", "scout").starts_with(g.publish[0].trim_end_matches('>')));
let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![stream_subjects("argus")]);
assert_eq!(templated(&argus), vec![stream_subjects("argus")]);
assert_ne!(g.publish, argus.publish);
let stream = stream_name("atlas");
@ -1941,11 +1981,11 @@ mod tests {
])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
// Control: argus's own expansion is a different subject, so atlas
// lacking it is the grant being scoped, not the template being empty.
let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
assert!(!atlas.publish.contains(&direct_get_subject("argus")));
for refused in [
@ -1989,9 +2029,9 @@ mod tests {
])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
for refused in [
direct_get_subject("argus"),
@ -2022,4 +2062,44 @@ mod tests {
fn with_no_agent_token_subject_configured_an_agent_token_is_refused() {
assert!(policy().agent_token_permissions("atlas").is_none());
}
/// Every agent reads every operator's profile, and only reads it: the
/// bucket opens, lists and answers a direct get, and nothing writes,
/// reshapes or deletes it.
#[test]
fn an_agent_may_list_and_read_every_operator_profile_and_write_none() {
use swarm_queue_client::operator_profiles::{BUCKET, STREAM};
let p = policy()
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
let may = |subject: &str| atlas.publish.iter().any(|g| covers(g, subject));
for allowed in [
format!("$JS.API.STREAM.INFO.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}.c1.$KV.{BUCKET}.>"),
format!("$JS.API.DIRECT.GET.{STREAM}.$KV.{BUCKET}.mara"),
] {
assert!(may(&allowed), "atlas may not publish {allowed}");
}
for refused in [
format!("$KV.{BUCKET}.mara"),
format!("$JS.API.STREAM.CREATE.{STREAM}"),
format!("$JS.API.STREAM.UPDATE.{STREAM}"),
format!("$JS.API.STREAM.DELETE.{STREAM}"),
format!("$JS.API.STREAM.PURGE.{STREAM}"),
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.argus".to_owned(),
] {
assert!(!may(&refused), "atlas may publish {refused}");
}
// The hive's own client is not the reader and gets none of it.
let hive = p.permissions("hive-alpha").expect("admitted");
assert!(
!hive
.publish
.iter()
.any(|g| covers(g, &format!("$JS.API.STREAM.INFO.{STREAM}")))
);
}
}

View file

@ -44,6 +44,10 @@ agent-schedules = ["async-nats/jetstream"]
# `agent_inference::read_own`, for the agent harness, which reads its own
# inference key the same way. Explicit for the same reason as `notices`.
agent-inference = ["async-nats/jetstream"]
# `operator_profiles::read_all`, for the agent harness, which lists every
# operator's profile when it renders its system prompt: a `keys()` listing, so
# the KV client rather than a bare direct get.
operator-profiles = ["async-nats/kv"]
# `ToSchema` on the review classifier's shared types, for swarm-controller,
# whose HTTP API serves the same shapes the queue carries.
schema = ["dep:utoipa"]

View file

@ -227,6 +227,11 @@ pub mod agent_limits;
/// and reading need `agent-permissions`.
pub mod agent_permissions;
/// The bucket swarm-controller publishes each operator's name and forge-profile
/// pronouns in — one key per operator, read by every agent. The names are
/// unconditional; writing needs `kv` and reading needs `operator-profiles`.
pub mod operator_profiles;
/// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only.
pub mod agent_login;

View file

@ -0,0 +1,96 @@
//! The operator-profile KV bucket: one key per operator, holding the name and
//! the pronouns from that operator's own forge profile.
//!
//! **swarm-controller is the only writer**, and creates the bucket on its
//! first write. An operator is a member of authelia's operator group, and the
//! controller removes the key of one who leaves it.
//!
//! **Every agent reads the whole bucket**, read-only, when it renders its
//! system prompt (`read_all`). Unlike the per-agent buckets, nothing here is
//! one agent's, so the grant covers every key and the listing a `keys()`
//! consumer needs.
/// The KV bucket operator profiles are published into, keyed by operator name.
pub const BUCKET: &str = "operator-profiles";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_operator-profiles";
/// One operator's profile: the value under `BUCKET`/`<name>`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct OperatorProfile {
/// The operator's authelia username, which is also the key.
pub name: String,
/// The pronouns set on the operator's forge profile. `None` when unset or
/// when the operator has no forge account yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pronouns: Option<String>,
}
/// Open the profile bucket for writing, creating it if nothing has yet.
///
/// **Controller-side only.** `history: 1`: an agent wants the current value.
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
let js = async_nats::jetstream::new(client.clone());
crate::kv::open_or_create(
&js,
BUCKET,
"operator-profiles",
"Each operator's name and forge-profile pronouns, set by the swarm controller".to_owned(),
)
.await
}
/// Every operator's profile, sorted by name.
///
/// # Errors
///
/// A missing bucket, a failed request or an undecodable value. A request the
/// queue refused for permissions reaches the client as a timeout, not as a
/// refusal.
#[cfg(feature = "operator-profiles")]
pub async fn read_all(
client: &async_nats::Client,
) -> Result<Vec<OperatorProfile>, Box<dyn std::error::Error + Send + Sync>> {
use futures_util::TryStreamExt as _;
let js = async_nats::jetstream::new(client.clone());
let store = js.get_key_value(BUCKET).await?;
let keys: Vec<String> = store.keys().await?.try_collect().await?;
let mut profiles = Vec::with_capacity(keys.len());
for key in keys {
if let Some(value) = store.get(&key).await? {
profiles.push(serde_json::from_slice::<OperatorProfile>(&value)?);
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the auth responder grants agents from.
#[test]
fn the_stream_is_the_bucket_s() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
}
#[test]
fn unset_pronouns_are_left_out_of_the_value() {
let profile = OperatorProfile {
name: "mara".to_owned(),
pronouns: None,
};
let json = serde_json::to_string(&profile).unwrap();
assert_eq!(json, r#"{"name":"mara"}"#);
assert_eq!(
serde_json::from_str::<OperatorProfile>(&json).unwrap(),
profile
);
}
}