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:
parent
a2b4320cc4
commit
6942f8fd27
16 changed files with 681 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue