templates: the agent system prompt is a Jinja template, rendered through hive-template
system.md carries {{ label }}-style variables and an {% if not archetype %} block in place of the config:repo / config:archetype marker pairs, so prompt.rs drops its line filter, marker parsers and string replaces. The rendered prompt is byte for byte the old one for a custom agent, an archetype agent and an empty archetype name, with and without hive, swarm, docs and operators. hive-template now drops a block tag's own line.
This commit is contained in:
parent
4adbc939f2
commit
c0eb6780ec
3 changed files with 165 additions and 242 deletions
|
|
@ -28,6 +28,8 @@ hive-agent-sock.workspace = true
|
|||
hive-core-agent-sock.workspace = true
|
||||
hive-log.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
# The system prompt template renders through the workspace template language.
|
||||
hive-template.workspace = true
|
||||
hive-sock-client.workspace = true
|
||||
hive-types.workspace = true
|
||||
libc.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system.
|
||||
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`) in text, never as a `send_dm` recipient, so the operator + peers can disambiguate.
|
||||
Within your own hive the short form is fine.{operators}
|
||||
Within your own hive the short form is fine.{{ operators }}
|
||||
|
||||
Tools: the hyperhive MCP tools and any extra MCP servers describe themselves in their own tool schema, so this prompt does not repeat them.
|
||||
What the schema doesn't tell you:
|
||||
|
|
@ -14,26 +14,25 @@ What the schema doesn't tell you:
|
|||
- For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time.
|
||||
There's no blind range-clear, only ids you've actually looked at.
|
||||
|
||||
<!-- config:repo -->
|
||||
{% if not archetype %}
|
||||
|
||||
Need new packages, env vars, or other NixOS config for yourself?
|
||||
You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly.
|
||||
All changes flow through operator-approved commits.
|
||||
|
||||
Your config repo is `agent-configs/{label}` on the forge: `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers).
|
||||
Clone it with `hive-forge -r agent-configs/{label} clone` and read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
|
||||
Your config repo is `agent-configs/{{ label }}` on the forge: `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers).
|
||||
Clone it with `hive-forge -r agent-configs/{{ label }} clone` and read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
|
||||
To propose a change, commit in that clone and open the PR with `hive-forge pr create --agit` (see the hive-forge docs), which needs no push access to the repo.
|
||||
|
||||
_Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it.
|
||||
|
||||
<!-- /config:repo -->
|
||||
<!-- config:archetype -->
|
||||
{% else %}
|
||||
|
||||
You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/<name>` repo for you.
|
||||
You are built from the `{{ archetype }}` archetype and have no config repo of your own: there is no `agent-configs/<name>` repo for you.
|
||||
Need new packages, env vars, or other NixOS config for yourself?
|
||||
Ask the operator: what defines you is the archetype, shared with every agent built from it.
|
||||
|
||||
<!-- /config:archetype -->
|
||||
{% endif %}
|
||||
|
||||
Operator boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one.
|
||||
_Creating_ a new agent is not something you can do from here — ask the operator, who creates agents at swarm level.
|
||||
|
|
@ -48,12 +47,12 @@ Call `get_loose_ends` to see them.
|
|||
|
||||
Durable knowledge:
|
||||
|
||||
- write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`.
|
||||
- write to `/agents/{{ label }}/state/notes.md` (free-form) or any other path under `/agents/{{ label }}/state/`.
|
||||
That directory is bind-mounted from the host and persists across container destroy/recreate.
|
||||
claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever.
|
||||
claude's `--continue` session only carries short-term context, but `/agents/{{ label }}/state/` is forever.
|
||||
Read it back at the start of relevant turns to remember things across resets.
|
||||
|
||||
**Work dir**: `/agents/{label}/work/` is the place for git worktrees, clones, build trees and other bulky scratch.
|
||||
**Work dir**: `/agents/{{ label }}/work/` is the place for git worktrees, clones, build trees and other bulky scratch.
|
||||
It is bind-mounted from the host, persists across container destroy/recreate and is backed up.
|
||||
Keep `state/` for notes and small durable files.
|
||||
Don't keep work under `/home`, `/tmp` or other container paths: the container discards them every time it stops.
|
||||
|
|
@ -69,7 +68,7 @@ A file that belongs to an issue or PR is attached to that forge thread; any othe
|
|||
The README also covers how to contribute a doc.
|
||||
|
||||
**Code forge**: a private Forgejo (base URL in `$HIVE_FORGE_URL`) is available when `$HIVE_FORGE_TOKEN_FILE` is set (`hive-forge` and git read the token from there; no file in `state/` is needed, so don't look for one).
|
||||
You have your own user account (named `{label}`).
|
||||
You have your own user account (named `{{ label }}`).
|
||||
Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc.
|
||||
For git operations use plain `git` directly against `$HIVE_FORGE_URL/<org>/<repo>.git` (credentials are pre-configured).
|
||||
|
||||
|
|
@ -101,7 +100,7 @@ Don't narrate intent — act.
|
|||
The harness runs one claude turn per inbox message; when you stop, it acknowledges that message and your `--continue` session is saved to disk.
|
||||
Ending the turn is how you commit progress — both the session and the inbox acknowledgement.
|
||||
If the container restarts _while a turn is still running_, the message that drove it was never acknowledged, so it gets redelivered on the next boot, prefixed `[redelivered after harness restart — may already be handled]`.
|
||||
A long single turn that does step after step widens the window where a restart loses work and forces that redelivery, so prefer short turns: do a unit of work, write anything durable under `/agents/{label}/state/` **at every natural boundary, not just when a turn happens to end**, and stop.
|
||||
A long single turn that does step after step widens the window where a restart loses work and forces that redelivery, so prefer short turns: do a unit of work, write anything durable under `/agents/{{ label }}/state/` **at every natural boundary, not just when a turn happens to end**, and stop.
|
||||
|
||||
For multi-step work (long builds, sequential edits) that spans more than one turn: end the turn and let an external wake drive the next one — a new inbox message, a `remind` you scheduled, or a backgrounded bash task's completion.
|
||||
Ending the turn is never a same-turn continuation: it's the checkpoint itself, and the only place an in-container todo wake (bash-task completion, matrix unread, forge activity) can reach you.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! System-prompt renderer. Single `prompts/system.md` with
|
||||
//! HTML-comment markers gating role-specific and config-repo-specific
|
||||
//! blocks; this module assembles the final prompt (always "agent" role — there is only one
|
||||
//! role). Marker grammar + placeholder substitution rules in
|
||||
//! System-prompt renderer. Single `prompts/system.md`, a Jinja template rendered
|
||||
//! through `hive-template` with an if/else block for the config-repo paragraphs;
|
||||
//! this module builds its variables and assembles the final prompt. The template
|
||||
//! rules and variables are in
|
||||
//! `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -16,36 +16,45 @@ const OPERATORS_TIMEOUT: Duration = Duration::from_secs(5);
|
|||
|
||||
/// Assemble the system prompt for a given label + optional hive /
|
||||
/// 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
|
||||
/// [`write_system_prompt`] so the template rendering 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
|
||||
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). The template rules and
|
||||
/// variables are documented in
|
||||
/// `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||
#[must_use]
|
||||
pub fn render(
|
||||
template: &str,
|
||||
label: &str,
|
||||
archetype: Option<&str>,
|
||||
hive_name: Option<&str>,
|
||||
swarm_name: Option<&str>,
|
||||
docs_dir: Option<&str>,
|
||||
operators: &[OperatorProfile],
|
||||
) -> String {
|
||||
let body = filter_blocks(template, "role", "agent");
|
||||
let qualified = crate::identity::qualify(label);
|
||||
let hive_identity = hive_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map_or(String::new(), |n| format!(" on hive `{n}`"));
|
||||
let swarm_identity = swarm_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map_or(String::new(), |n| format!(" in swarm `{n}`"));
|
||||
let rendered = body
|
||||
.replace("{label}", label)
|
||||
.replace("{qualified_label}", &qualified)
|
||||
.replace("{hive_identity}", &hive_identity)
|
||||
.replace("{swarm_identity}", &swarm_identity)
|
||||
.replace("{operators}", &operators_line(operators));
|
||||
let context = serde_json::json!({
|
||||
"label": label,
|
||||
"qualified_label": crate::identity::qualify(label),
|
||||
"hive_identity": hive_identity,
|
||||
"swarm_identity": swarm_identity,
|
||||
"operators": operators_line(operators),
|
||||
// Absent for a custom agent, which has a config repo of its own.
|
||||
"archetype": archetype.filter(|a| !a.is_empty()),
|
||||
});
|
||||
let rendered = match hive_template::render(template, &context) {
|
||||
Ok(text) => text,
|
||||
Err(why) => {
|
||||
tracing::error!(%why, "prompt: the system prompt template does not render; using it as written");
|
||||
template.to_owned()
|
||||
}
|
||||
};
|
||||
// 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
|
||||
|
|
@ -118,67 +127,6 @@ async fn within_timeout<E: std::fmt::Display>(
|
|||
}
|
||||
}
|
||||
|
||||
/// Walk `template` line-by-line, stripping `<!-- KIND:X -->` /
|
||||
/// `<!-- /KIND:X -->` marker lines and suppressing the lines inside a block
|
||||
/// unless `X == target`. A close marker ends the current block. Markers of
|
||||
/// any other kind are left in place, for the pass that filters that kind.
|
||||
/// Two kinds exist: `role` (always `agent`; the grammar stays wired for a
|
||||
/// future manager / multi-role prompt) and `config` ([`config_blocks`]).
|
||||
fn filter_blocks(template: &str, kind: &str, target: &str) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
// None = outside any block; Some(value) = inside a block of this kind.
|
||||
let mut active: Option<&str> = None;
|
||||
for line in template.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(value) = parse_open_marker(trimmed, kind) {
|
||||
active = Some(value);
|
||||
continue;
|
||||
}
|
||||
if parse_close_marker(trimmed, kind).is_some() {
|
||||
active = None;
|
||||
continue;
|
||||
}
|
||||
if active.is_none_or(|value| value == target) {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `template` with its `config:repo` blocks for a custom agent, or its
|
||||
/// `config:archetype` blocks and `{archetype}` filled in for an agent built
|
||||
/// from `archetype`. An empty name is a custom agent.
|
||||
fn config_blocks(template: &str, archetype: Option<&str>) -> String {
|
||||
match archetype.filter(|a| !a.is_empty()) {
|
||||
Some(name) => filter_blocks(template, "config", "archetype").replace("{archetype}", name),
|
||||
None => filter_blocks(template, "config", "repo"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<!-- role:agent -->` with `kind = "role"` → `Some("agent")`. Anything
|
||||
/// else returns None. Whitespace inside the marker is tolerated so a future
|
||||
/// author's `<!--role:foo-->` (no spaces) still parses; the dashboard
|
||||
/// markdown renderer is equally lenient. Close tags (`/role:...`) can't
|
||||
/// accidentally match — the `strip_prefix(kind)` rejects the leading slash
|
||||
/// before we'd ever see it.
|
||||
fn parse_open_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim();
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// `<!-- /role:agent -->` with `kind = "role"` → `Some("agent")`. Mirror of
|
||||
/// [`parse_open_marker`] for the closing tag.
|
||||
fn parse_close_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
inside
|
||||
.strip_prefix('/')?
|
||||
.strip_prefix(kind)?
|
||||
.strip_prefix(':')
|
||||
.map(str::trim)
|
||||
}
|
||||
|
||||
/// Write the assembled prompt to a stable path next to the harness
|
||||
/// socket and return the path. The Rust harness passes this path to
|
||||
/// `claude --system-prompt-file` so the per-turn prompts only carry
|
||||
|
|
@ -212,10 +160,11 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf>
|
|||
.filter(|d| !d.is_empty());
|
||||
// The meta flake sets HIVE_ARCHETYPE on an agent built from an archetype,
|
||||
// which has no config repo of its own.
|
||||
let template = config_blocks(&template, std::env::var("HIVE_ARCHETYPE").ok().as_deref());
|
||||
let archetype = std::env::var("HIVE_ARCHETYPE").ok();
|
||||
let body = render(
|
||||
&template,
|
||||
label,
|
||||
archetype.as_deref(),
|
||||
hive_name.as_deref(),
|
||||
swarm_name.as_deref(),
|
||||
docs_dir.as_deref(),
|
||||
|
|
@ -261,78 +210,10 @@ mod tests {
|
|||
.unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}"))
|
||||
});
|
||||
|
||||
const SAMPLE: &str = "\
|
||||
shared opener
|
||||
<!-- role:agent -->
|
||||
agent-only line
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
manager-only line
|
||||
<!-- /role:manager -->
|
||||
shared closer
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_shared_and_target_role() {
|
||||
let agent = filter_blocks(SAMPLE, "role", "agent");
|
||||
assert!(agent.contains("shared opener"));
|
||||
assert!(agent.contains("agent-only line"));
|
||||
assert!(!agent.contains("manager-only line"));
|
||||
assert!(agent.contains("shared closer"));
|
||||
// Marker lines themselves are stripped — no `<!--` left behind.
|
||||
assert!(!agent.contains("<!--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_for_manager_picks_manager_block() {
|
||||
let manager = filter_blocks(SAMPLE, "role", "manager");
|
||||
assert!(manager.contains("shared opener"));
|
||||
assert!(!manager.contains("agent-only line"));
|
||||
assert!(manager.contains("manager-only line"));
|
||||
assert!(manager.contains("shared closer"));
|
||||
assert!(!manager.contains("<!--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_open_marker_handles_whitespace_variants() {
|
||||
assert_eq!(
|
||||
parse_open_marker("<!-- role:agent -->", "role"),
|
||||
Some("agent")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_open_marker("<!--role:agent-->", "role"),
|
||||
Some("agent")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_open_marker("<!-- role:manager -->", "role"),
|
||||
Some("manager")
|
||||
);
|
||||
// Close tags must NOT match open-tag parser.
|
||||
assert_eq!(parse_open_marker("<!-- /role:agent -->", "role"), None);
|
||||
// Non-markers pass through (return None).
|
||||
assert_eq!(parse_open_marker("just text", "role"), None);
|
||||
assert_eq!(parse_open_marker("<!-- not a role -->", "role"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_close_marker_handles_whitespace_variants() {
|
||||
assert_eq!(
|
||||
parse_close_marker("<!-- /role:agent -->", "role"),
|
||||
Some("agent")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_close_marker("<!--/role:manager-->", "role"),
|
||||
Some("manager")
|
||||
);
|
||||
// Open tags must NOT match close-tag parser.
|
||||
assert_eq!(parse_close_marker("<!-- role:agent -->", "role"), None);
|
||||
assert_eq!(parse_close_marker("just text", "role"), None);
|
||||
}
|
||||
|
||||
#[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, None, &[]);
|
||||
assert!(rendered.contains("hyperhive agent `alice`"));
|
||||
assert!(rendered.contains("say \"the operator\""));
|
||||
assert!(!rendered.contains("pronoun"));
|
||||
|
|
@ -341,11 +222,11 @@ 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, &[]);
|
||||
assert!(!rendered.contains("<!-- role:"));
|
||||
assert!(!rendered.contains("<!-- /role:"));
|
||||
fn render_leaves_no_template_syntax_in_output() {
|
||||
// No raw template tag should survive into the rendered prompt.
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]);
|
||||
assert!(!rendered.contains("{{"));
|
||||
assert!(!rendered.contains("{%"));
|
||||
// The guidance the tool schemas cannot give still appears.
|
||||
assert!(rendered.contains("mcp__matrix__send_dm"));
|
||||
assert!(rendered.contains("ending the turn"));
|
||||
|
|
@ -353,75 +234,87 @@ 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, 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, None, &[]);
|
||||
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
|
||||
}
|
||||
|
||||
const CONFIG_FIXTURE: &str = "\
|
||||
shared
|
||||
<!-- config:repo -->
|
||||
{% if not archetype %}
|
||||
open a PR on agent-configs
|
||||
<!-- /config:repo -->
|
||||
<!-- config:archetype -->
|
||||
built from `{archetype}`, no config repo
|
||||
<!-- /config:archetype -->
|
||||
{% else %}
|
||||
built from `{{ archetype }}`, no config repo
|
||||
{% endif %}
|
||||
";
|
||||
|
||||
/// A custom agent gets the config-repo lines, an archetype agent the
|
||||
/// archetype lines with its name, and an empty name is a custom agent.
|
||||
#[test]
|
||||
fn config_blocks_pick_the_block_by_archetype() {
|
||||
fn the_config_block_follows_the_archetype() {
|
||||
for custom in [None, Some("")] {
|
||||
let picked = config_blocks(CONFIG_FIXTURE, custom);
|
||||
assert!(picked.contains("open a PR on agent-configs"), "{picked}");
|
||||
assert!(!picked.contains("no config repo"), "{picked}");
|
||||
let picked = render(CONFIG_FIXTURE, "alice", custom, None, None, None, &[]);
|
||||
assert_eq!(picked, "shared\nopen a PR on agent-configs\n");
|
||||
}
|
||||
let picked = config_blocks(CONFIG_FIXTURE, Some("android-dev"));
|
||||
assert!(picked.contains("built from `android-dev`, no config repo"));
|
||||
assert!(!picked.contains("agent-configs"), "{picked}");
|
||||
assert!(picked.contains("shared"));
|
||||
assert!(!picked.contains("<!--"), "{picked}");
|
||||
let picked = render(
|
||||
CONFIG_FIXTURE,
|
||||
"alice",
|
||||
Some("android-dev"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
assert_eq!(picked, "shared\nbuilt from `android-dev`, no config repo\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_production_prompt_keeps_no_config_markers() {
|
||||
fn the_production_prompt_keeps_no_template_syntax() {
|
||||
for archetype in [None, Some("reviewer")] {
|
||||
let rendered = render(
|
||||
&config_blocks(&PRODUCTION_TEMPLATE, archetype),
|
||||
&PRODUCTION_TEMPLATE,
|
||||
"alice",
|
||||
archetype,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
assert!(!rendered.contains("<!-- config:"), "{rendered}");
|
||||
assert!(!rendered.contains("<!-- /config:"), "{rendered}");
|
||||
assert!(!rendered.contains("{archetype}"), "{rendered}");
|
||||
for left in ["{{", "{%", "<!-- config:", "<!-- /config:", "{archetype}"] {
|
||||
assert!(!rendered.contains(left), "{left} left in:\n{rendered}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline fixture for the {hive_identity} / {swarm_identity}
|
||||
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
|
||||
// Inline fixture for the {{ hive_identity }} / {{ swarm_identity }}
|
||||
// variables. 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.
|
||||
// template's config block.
|
||||
const IDENTITY_FIXTURE: &str = "\
|
||||
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system.
|
||||
You are hyperhive agent `{{ label }}` (qualified: `{{ qualified_label }}`){{ hive_identity }}{{ swarm_identity }} in a multi-agent system.
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_hive_identity_when_set() {
|
||||
let rendered = render(IDENTITY_FIXTURE, "alice", Some("pr1ma"), None, None, &[]);
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
"alice",
|
||||
None,
|
||||
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"));
|
||||
|
|
@ -436,6 +329,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
IDENTITY_FIXTURE,
|
||||
"ruth",
|
||||
None,
|
||||
None,
|
||||
Some("constellat1on"),
|
||||
None,
|
||||
&[],
|
||||
|
|
@ -449,6 +343,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
"iris",
|
||||
None,
|
||||
Some("pr1ma"),
|
||||
Some("constellat1on"),
|
||||
None,
|
||||
|
|
@ -463,7 +358,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, None, &[]);
|
||||
assert!(!rendered.contains("on hive"));
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
assert!(!rendered.contains("{hive_identity}"));
|
||||
|
|
@ -476,7 +371,15 @@ 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",
|
||||
None,
|
||||
Some(""),
|
||||
Some(""),
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
assert!(!rendered.contains("on hive"));
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
}
|
||||
|
|
@ -488,6 +391,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
"alice",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/run/hive-docs"),
|
||||
&[],
|
||||
);
|
||||
|
|
@ -502,7 +406,7 @@ 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, None, docs, &[]);
|
||||
assert!(
|
||||
!rendered.contains("mounted read-only at"),
|
||||
"docs pointer must not appear for {docs:?}:\n{rendered}"
|
||||
|
|
@ -517,17 +421,33 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
}
|
||||
}
|
||||
|
||||
const OPERATORS_FIXTURE: &str = "say \"the operator\".{operators}\n\nnext paragraph\n";
|
||||
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);
|
||||
let rendered = render(
|
||||
OPERATORS_FIXTURE,
|
||||
"alice",
|
||||
None,
|
||||
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);
|
||||
let rendered = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
"alice",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&operators,
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Operators: mara (she/her), sol."),
|
||||
"{rendered}"
|
||||
|
|
@ -538,17 +458,18 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
/// byte for byte.
|
||||
#[test]
|
||||
fn render_with_no_operators_is_the_template_without_the_placeholder() {
|
||||
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &[]);
|
||||
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, None, &[]);
|
||||
assert_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
|
||||
assert!(PRODUCTION_TEMPLATE.contains("{operators}"));
|
||||
assert!(PRODUCTION_TEMPLATE.contains("{{ operators }}"));
|
||||
assert_eq!(
|
||||
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]),
|
||||
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]),
|
||||
render(
|
||||
&PRODUCTION_TEMPLATE.replace("{operators}", ""),
|
||||
&PRODUCTION_TEMPLATE.replace("{{ operators }}", ""),
|
||||
"alice",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[]
|
||||
)
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue