hive-ag3nt + docs: extract prompt-rendering prose (#716 batch 3)

This commit is contained in:
iris 2026-05-31 16:44:17 +02:00 committed by mara
commit 84f3fe5f69
3 changed files with 57 additions and 80 deletions

View file

@ -187,6 +187,26 @@ socket at `/run/hive/` once at startup:
`services.hyperhive.c0re.operatorPronouns`, default `she/her`).
Passed via `--system-prompt-file`.
**Marker grammar.** `<!-- role:X -->` opens a block; matching
`<!-- /role:X -->` closes it. Nesting is NOT supported — a stray
opener overrides until its closing tag (or end of file). A
mismatched closer (`<!-- /role:manager -->` inside a `role:agent`
block) is elided from the output but does NOT pop the active
role: suppression stays conservative so a typo can't dump
wrong-flavor content. Whitespace inside markers is tolerated
(`<!--role:foo-->` parses the same as `<!-- role:foo -->`).
Content outside any marker is always shared.
**`hive_identity` / `swarm_identity` shape.** Each carries a
leading space + backticked name (` on hive \`pr1ma\``,
` in swarm \`constellat1on\``) when the corresponding env var
is set, otherwise empty string. The independence lets the
template drop one or both into the opener prose without
breaking single-hive deployments that never set the option;
the renderer also treats `Some("")` from a caller as `None` so
empty-string env vars and missing env vars round-trip the
same way.
The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config,
write_settings, write_system_prompt, run_turn, drive_turn,
emit_turn_end, wait_for_login, compact_session}` so the two binaries

View file

@ -100,12 +100,9 @@ async fn update_marketplaces() {
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
/// Returns a list of human-readable failure messages so the caller can
/// route them through their own per-role surface (turn-failure-style
/// notification, see `Surface::send_to_parent`). Pre-#692 this function
/// hardcoded `"manager"` as the failure-notification recipient via a
/// `notify_recipient: Option<&str>` arg; mara on #778 wanted the
/// manager-name special case gone. Now plugins.rs is wire-agnostic and
/// the caller picks the recipient via the same `<parent>` sentinel
/// failure-notify uses everywhere else (#703).
/// notification, see `Surface::send_to_parent`). Wire-agnostic: the
/// caller picks the recipient via the same `<parent>` sentinel that
/// failure-notify uses everywhere else.
pub async fn install_configured(socket: &Path) -> Vec<String> {
let _ = socket; // Reserved for future telemetry; currently unused.
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {

View file

@ -1,31 +1,8 @@
//! System-prompt renderer (closes #519).
//!
//! Both flavors (agent / manager) used to live in separate files
//! (`prompts/agent.md`, `prompts/manager.md`) that drifted in lockstep
//! whenever someone updated only one. Now there's a single
//! `prompts/system.md` with HTML-comment markers gating role-specific
//! blocks; this module assembles the final prompt for a given flavor.
//!
//! Marker syntax (HTML comments — invisible in rendered markdown,
//! distinct from `{label}` / `{operator_pronouns}` placeholders):
//!
//! ```text
//! <!-- role:agent -->
//! sub-agent-only paragraph
//! <!-- /role:agent -->
//!
//! shared paragraph
//!
//! <!-- role:manager -->
//! manager-only paragraph
//! <!-- /role:manager -->
//! ```
//!
//! Content outside any marker is shared. Nesting is NOT supported;
//! a stray opener overrides until its closing tag (or end of file).
//! When #513 lands the marker grammar can grow `cap:<group>` blocks
//! the same way without touching the renderer surface (the role
//! distinction folds into a per-cap-set lookup).
//! System-prompt renderer. Single `prompts/system.md` with
//! HTML-comment markers gating role-specific blocks; this module
//! assembles the final prompt for a given flavor. Marker grammar +
//! placeholder substitution rules in
//! `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`).
use std::path::{Path, PathBuf};
@ -34,31 +11,14 @@ use anyhow::{Context, Result};
use crate::mcp::Flavor;
/// Assemble the system prompt for a given flavor + label + pronouns +
/// optional hive / swarm display names.
/// 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
/// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/
/// system.md`).
///
/// Substitutions:
///
/// - `{label}` — short hive-local name (e.g. `iris`).
/// - `{qualified_label}` (#589) — `${label}@${domain}` in federated
/// deployments, same as `{label}` when no hive domain is configured.
/// - `{operator_pronouns}` — `"she/her"` / `"they/them"` / etc.
/// - `{hive_identity}` (#701 / #709) — ` on hive \`pr1ma\`` (with
/// leading space + backticks) when `hive_name` is `Some`, **empty
/// string** when `None`. Lets the template drop the names into the
/// opener prose without breaking single-hive deployments that
/// never set the option.
/// - `{swarm_identity}` (#701 / #709) — same shape for the swarm:
/// ` in swarm \`constellat1on\`` when `Some`, empty when `None`.
///
/// Both `hive_name` / `swarm_name` are independent: setting just one
/// surfaces only that clause; setting both yields the full prose
/// (`… on hive \`pr1ma\` in swarm \`constellat1on\` …`).
/// optional hive / swarm display names. 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 [`hive_sh4re::assets::prompt_template`]
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders +
/// marker grammar documented in
/// `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`).
#[must_use]
pub fn render(
template: &str,
@ -94,7 +54,7 @@ pub fn render(
/// closer (`<!-- /role:manager -->` inside a `role:agent` block) is
/// elided from the output but does NOT reset the active role — keeps
/// the suppression conservative so a typo can't dump wrong-flavor
/// content (per argus #527 review nit).
/// content.
fn filter_role_blocks(template: &str, target: &str) -> String {
let mut out = String::with_capacity(template.len());
// None = outside any block; Some(role) = inside role-tagged block.
@ -166,9 +126,9 @@ pub async fn write_system_prompt(_socket: &Path, label: &str, flavor: Flavor) ->
template_path.display()
)
})?;
// #701 / #709: surface hive + swarm display names in the prompt
// opener when configured. Both `None` falls back to the pre-#709
// wording verbatim (single-hive deployments see no diff).
// Surface hive + swarm display names in the prompt opener when
// configured. Both `None` falls back to the non-identity wording
// verbatim (single-hive deployments see no diff).
let hive_name = crate::identity::hive_name();
let swarm_name = crate::identity::swarm_name();
let body = render(
@ -190,11 +150,11 @@ mod tests {
use super::*;
use std::sync::LazyLock;
// #555: the production template lives at
// `$HIVE_ASSETS_DIR/prompts/system.md` and is loaded at runtime.
// The unit tests below want to assert against the actual production
// wording (so the renderer + tool surface stay honest), so they
// resolve the same path at test runtime via two fallbacks:
// The production template lives at `$HIVE_ASSETS_DIR/prompts/system.md`
// and is loaded at runtime. The unit tests below want to assert
// against the actual production wording (so the renderer + tool
// surface stay honest), so they resolve the same path at test
// runtime via two fallbacks:
// 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract
// production uses. The flake's `checks.cargo-test` derivation
// sets this to the `hyperhive-assets` output so `cargo test`
@ -278,7 +238,6 @@ shared closer
// closer inside: the manager-tagged close must NOT pop the agent
// gate, else manager-target output would leak the agent block's
// text (or vice-versa). Stray marker line itself is still elided.
// Per argus #527 review nit.
let template = "shared\n\
<!-- role:agent -->\n\
agent line 1\n\
@ -337,7 +296,7 @@ shared closer
// Spot-check: the manager-only tool block (request_init_config,
// kill, schedule_*) MUST NOT appear in the agent's rendered
// prompt. Drift between flavor and tool surface bites every
// time it happens (cf. #511 missing-allow-list bug).
// time it happens.
let rendered = render(
&PRODUCTION_TEMPLATE,
Flavor::Agent,
@ -395,14 +354,15 @@ shared closer
assert!(manager.starts_with("You are the hyperhive manager"));
}
// Inline fixture for the #709 placeholders. Cargo's `cargo test`
// resolves `PRODUCTION_TEMPLATE` against `$HIVE_ASSETS_DIR/prompts/
// system.md`, which the flake builds at derivation time — a fresh
// placeholder added on the source side isn't in the shipped asset
// until the flake rebuilds, so PRODUCTION_TEMPLATE can't be the
// fixture here. The string below carries just enough of the
// opener shape to exercise the substitution logic; nothing here
// depends on the production template's flavor markers.
// Inline fixture for the {hive_identity} / {swarm_identity}
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
// builds at derivation time — a fresh placeholder added on the
// source side isn't in the shipped asset until the flake rebuilds,
// so PRODUCTION_TEMPLATE can't be the fixture here. The string
// below carries just enough of the opener shape to exercise the
// substitution logic; nothing here depends on the production
// template's flavor markers.
const IDENTITY_FIXTURE: &str = "\
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. Pronouns: **{operator_pronouns}**.
";
@ -456,8 +416,8 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test]
fn render_omits_identity_when_unset() {
// None / None must round-trip the pre-#709 opener verbatim —
// single-hive deployments see zero diff.
// None / None must round-trip the non-identity opener verbatim
// single-hive deployments see zero diff.
let rendered = render(
IDENTITY_FIXTURE,
Flavor::Agent,