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

@ -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
);
}
}