Watch
0
0
Fork
You've already forked hyperhive
0

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:
damocles 2026-11-11 02:26:25 +01:00 • committed by mara
commit c0eb6780ec
10 changed files with 165 additions and 242 deletions

1
Cargo.lock generated
View file

@ -1693,6 +1693,7 @@ dependencies = [
"hive-runtime", "hive-runtime",
"hive-sh4re", "hive-sh4re",
"hive-sock-client", "hive-sock-client",
"hive-template",
"hive-types", "hive-types",
"http-body-util", "http-body-util",
"hyper", "hyper",

View file

@ -105,16 +105,9 @@ state dirs work as for every other agent.
`/agents/<name>/state` (so it can manage any agent's state dir; config `/agents/<name>/state` (so it can manage any agent's state dir; config
isn't mounted, since a real config change is a PR from a clone), plus an RO mount for isn't mounted, since a real config change is a PR from a clone), plus an RO mount for
`/meta` (the hive's topology and limits files). Any agent holding it gets the same mounts. `/meta` (the hive's topology and limits files). Any agent holding it gets the same mounts.
- **Prompt** — identical for every container but one block (below): - **Prompt** — identical for every container but one paragraph: an agent
`prompts/system.md` built from an [archetype](../swarm/archetypes.md) is told it has no config
carries `<!-- role:agent -->` / `<!-- role:manager -->` marker repo, every other agent is told how to propose changes to its own.
blocks, and `prompt::render` filters for `"agent"` unconditionally,
manager included ("always `agent` role — there is only one role",
`hive-agent/src/prompt.rs`'s own module doc). The `role:manager`
blocks are dead in production, exercised only by a unit test
(`filter_blocks(SAMPLE, "role", "manager")`). The one block that differs
is `<!-- config:repo -->` vs `<!-- config:archetype -->`: an agent built
from an [archetype](../swarm/archetypes.md) is told it has no config repo.
- **Tool allow-list** — the MCP tools claude sees come from the agent's - **Tool allow-list** — the MCP tools claude sees come from the agent's
permissions alone, the same mechanism for every agent permissions alone, the same mechanism for every agent
([`docs/4-implementation/turn-loop/mcp.md`](../../4-implementation/turn-loop/mcp.md)). Ruth's permissions ([`docs/4-implementation/turn-loop/mcp.md`](../../4-implementation/turn-loop/mcp.md)). Ruth's permissions

View file

@ -297,8 +297,7 @@ definition, then create ruth from the swarm dashboard.
The system prompt (`hive-agent/prompts/system.md`, rendered by The system prompt (`hive-agent/prompts/system.md`, rendered by
`hive-agent/src/prompt.rs`) is the **same for every agent**; what `hive-agent/src/prompt.rs`) is the **same for every agent**; what
varies is which MCP tools it surfaces (gated by the agent's permissions, varies is which MCP tools it surfaces (gated by the agent's permissions,
which the swarm sets). No `role:manager` block renders only which the swarm sets). The root agent's role-specific
for the root agent. The root agent's role-specific
behaviour comes from its AGENTS.md / agent-specific instructions, not behaviour comes from its AGENTS.md / agent-specific instructions, not
the system prompt template. the system prompt template.

View file

@ -253,26 +253,25 @@ needs to `chown` a bind mount), once at startup:
race), trading that for a hard dependency on the daemon's uptime race), trading that for a hard dependency on the daemon's uptime
(`Restart=always`, no stdio fallback). Extra servers stay stdio. (`Restart=always`, no stdio fallback). Extra servers stay stdio.
- `claude-system-prompt.md` — rendered from - `claude-system-prompt.md` — rendered from
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`: `hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`: a
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`, [Jinja template](../../../hive-template/README.md) (`{{ name }}` for a
same for `role:manager`) gate the role-specific blocks; both flavors value, `{% if %}` for a block). An
share everything else. `<!-- config:repo -->` and `{% if not archetype %}` / `{% else %}` pair gates the config-repo paragraphs: an
`<!-- config:archetype -->` blocks gate the config-repo paragraphs: an
agent with `HIVE_ARCHETYPE` set (the meta flake sets it on an agent built agent with `HIVE_ARCHETYPE` set (the meta flake sets it on an agent built
from an [archetype](../../2-handbook/swarm/archetypes.md)) gets the archetype block and from an [archetype](../../2-handbook/swarm/archetypes.md)) gets the archetype
every other agent the repo block, with `{archetype}` filled in with the block, with `{{ archetype }}` filled in with the archetype's name, and every
archetype's name. Five placeholders are then other agent the repo block. Five variables are
substituted: `{label}` (short agent name), `{qualified_label}` set: `{{ label }}` (short agent name), `{{ qualified_label }}`
(hive-qualified `name@domain` form), (hive-qualified `name@domain` form),
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when `{{ hive_identity }}` (for example `` on hive `pr1ma` ``; empty when
`HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same `HYPERHIVE_HIVE_NAME` is unset), `{{ swarm_identity }}` (same
shape for the swarm), and `{operators}`: a paragraph listing the shape for the swarm), and `{{ operators }}`: a paragraph listing the
swarm's operators with the pronouns from each one's forge profile, swarm's operators with the pronouns from each one's forge profile,
for example `Operators: mara (she/her), sol.`. The harness reads for example `Operators: mara (she/her), sol.`. The harness reads
them with one direct get on the one key of the `operator-profiles` them with one direct get on the one key of the `operator-profiles`
KV bucket, which `swarm-controller` writes. The harness waits up to five seconds for 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, them. When the bucket is empty or doesn't answer in time,
`{operators}` is empty and the prompt names no operator. Either way `{{ operators }}` is empty and the prompt names no operator. Either way
the template tells agents to say "the operator" in third person. A the template tells agents to say "the operator" in third person. A
pronoun change reaches an agent at its next harness start, since pronoun change reaches an agent at its next harness start, since
the prompt renders once per start. the prompt renders once per start.
@ -281,17 +280,11 @@ needs to `chown` a bind mount), once at startup:
telling the agent where the docs live. telling the agent where the docs live.
Passed via `--system-prompt-file`. Passed via `--system-prompt-file`.
**Marker grammar.** `<!-- KIND:X -->` opens a block; any **Template rules.** A variable the template names but the renderer doesn't
`<!-- /KIND:X -->` closes the current block of that kind. The renderer set is an error. The harness then writes the template unrendered and logs the
filters the two kinds one after the other. The renderer always uses reason, so a typo shows up in the prompt instead of as an empty string. A
role `agent`, so it elides blocks with other role tags, and config `repo` block tag on a line of its own leaves no blank line behind. A lone `{` or `}`
or `archetype` as above. Nesting **isn't** is plain text.
supported — a stray opener with no closer runs until end of file.
The renderer tolerates whitespace inside markers (`<!--role:foo-->` parses the
same as `<!-- role:foo -->`). Content outside any marker is always
included. Today's `system.md` carries no role markers (single agent
role), and the role grammar stays wired for a future manager / multi-role
prompt; it carries one pair of config blocks.
**`hive_identity` / `swarm_identity` shape.** Each carries a **`hive_identity` / `swarm_identity` shape.** Each carries a
leading space + backticked name (` on hive \`pr1ma\``, leading space + backticked name (` on hive \`pr1ma\``,

View file

@ -28,6 +28,8 @@ hive-agent-sock.workspace = true
hive-core-agent-sock.workspace = true hive-core-agent-sock.workspace = true
hive-log.workspace = true hive-log.workspace = true
hive-sh4re.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-sock-client.workspace = true
hive-types.workspace = true hive-types.workspace = true
libc.workspace = true libc.workspace = true

View file

@ -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). 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. 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. 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: 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. - 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. 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? 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. 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. 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). 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. 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. 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. _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 --> {% else %}
<!-- config:archetype -->
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? 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. 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. 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. _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: 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. 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. 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. It is bind-mounted from the host, persists across container destroy/recreate and is backed up.
Keep `state/` for notes and small durable files. 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. 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. 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). **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. 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). 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. 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. 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]`. 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. 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. 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.

View file

@ -1,7 +1,7 @@
//! System-prompt renderer. Single `prompts/system.md` with //! System-prompt renderer. Single `prompts/system.md`, a Jinja template rendered
//! HTML-comment markers gating role-specific and config-repo-specific //! through `hive-template` with an if/else block for the config-repo paragraphs;
//! blocks; this module assembles the final prompt (always "agent" role — there is only one //! this module builds its variables and assembles the final prompt. The template
//! role). Marker grammar + placeholder substitution rules in //! rules and variables are in
//! `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). //! `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
use std::path::{Path, PathBuf}; 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 / /// 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 /// 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 /// in isolation. The caller supplies the template body so tests can pass an
/// inline fixture and production reads it once at harness startup via /// inline fixture and production reads it once at harness startup via
/// [`hive_sh4re::assets::prompt_template`] /// [`hive_sh4re::assets::prompt_template`]
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders + /// (`$HIVE_ASSETS_DIR/prompts/system.md`). The template rules and
/// marker grammar documented in /// variables are documented in
/// `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). /// `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
#[must_use] #[must_use]
pub fn render( pub fn render(
template: &str, template: &str,
label: &str, label: &str,
archetype: Option<&str>,
hive_name: Option<&str>, hive_name: Option<&str>,
swarm_name: Option<&str>, swarm_name: Option<&str>,
docs_dir: Option<&str>, docs_dir: Option<&str>,
operators: &[OperatorProfile], operators: &[OperatorProfile],
) -> String { ) -> String {
let body = filter_blocks(template, "role", "agent");
let qualified = crate::identity::qualify(label);
let hive_identity = hive_name let hive_identity = hive_name
.filter(|n| !n.is_empty()) .filter(|n| !n.is_empty())
.map_or(String::new(), |n| format!(" on hive `{n}`")); .map_or(String::new(), |n| format!(" on hive `{n}`"));
let swarm_identity = swarm_name let swarm_identity = swarm_name
.filter(|n| !n.is_empty()) .filter(|n| !n.is_empty())
.map_or(String::new(), |n| format!(" in swarm `{n}`")); .map_or(String::new(), |n| format!(" in swarm `{n}`"));
let rendered = body let context = serde_json::json!({
.replace("{label}", label) "label": label,
.replace("{qualified_label}", &qualified) "qualified_label": crate::identity::qualify(label),
.replace("{hive_identity}", &hive_identity) "hive_identity": hive_identity,
.replace("{swarm_identity}", &swarm_identity) "swarm_identity": swarm_identity,
.replace("{operators}", &operators_line(operators)); "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` // When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable`
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer // wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
// sentence so the agent knows they exist. Additive: it doesn't replace the // 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 /// Write the assembled prompt to a stable path next to the harness
/// socket and return the path. The Rust harness passes this path to /// socket and return the path. The Rust harness passes this path to
/// `claude --system-prompt-file` so the per-turn prompts only carry /// `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()); .filter(|d| !d.is_empty());
// The meta flake sets HIVE_ARCHETYPE on an agent built from an archetype, // The meta flake sets HIVE_ARCHETYPE on an agent built from an archetype,
// which has no config repo of its own. // 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( let body = render(
&template, &template,
label, label,
archetype.as_deref(),
hive_name.as_deref(), hive_name.as_deref(),
swarm_name.as_deref(), swarm_name.as_deref(),
docs_dir.as_deref(), docs_dir.as_deref(),
@ -261,78 +210,10 @@ mod tests {
.unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}")) .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] #[test]
fn render_substitutes_label_and_names_no_operator_pronouns() { fn render_substitutes_label_and_names_no_operator_pronouns() {
// Real template's first agent line. // 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("hyperhive agent `alice`"));
assert!(rendered.contains("say \"the operator\"")); assert!(rendered.contains("say \"the operator\""));
assert!(!rendered.contains("pronoun")); assert!(!rendered.contains("pronoun"));
@ -341,11 +222,11 @@ shared closer
} }
#[test] #[test]
fn render_no_role_markers_in_output() { fn render_leaves_no_template_syntax_in_output() {
// No raw role markers should survive into the rendered prompt. // No raw template tag should survive into the rendered prompt.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]); let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]);
assert!(!rendered.contains("<!-- role:")); assert!(!rendered.contains("{{"));
assert!(!rendered.contains("<!-- /role:")); assert!(!rendered.contains("{%"));
// The guidance the tool schemas cannot give still appears. // The guidance the tool schemas cannot give still appears.
assert!(rendered.contains("mcp__matrix__send_dm")); assert!(rendered.contains("mcp__matrix__send_dm"));
assert!(rendered.contains("ending the turn")); assert!(rendered.contains("ending the turn"));
@ -353,75 +234,87 @@ shared closer
#[test] #[test]
fn render_uses_agent_opener() { 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")); assert!(rendered.starts_with("You are hyperhive agent"));
} }
#[test] #[test]
fn render_points_agents_at_the_work_dir() { 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}"); assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
} }
const CONFIG_FIXTURE: &str = "\ const CONFIG_FIXTURE: &str = "\
shared shared
<!-- config:repo --> {% if not archetype %}
open a PR on agent-configs open a PR on agent-configs
<!-- /config:repo --> {% else %}
<!-- config:archetype --> built from `{{ archetype }}`, no config repo
built from `{archetype}`, no config repo {% endif %}
<!-- /config:archetype -->
"; ";
/// A custom agent gets the config-repo lines, an archetype agent the /// 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. /// archetype lines with its name, and an empty name is a custom agent.
#[test] #[test]
fn config_blocks_pick_the_block_by_archetype() { fn the_config_block_follows_the_archetype() {
for custom in [None, Some("")] { for custom in [None, Some("")] {
let picked = config_blocks(CONFIG_FIXTURE, custom); let picked = render(CONFIG_FIXTURE, "alice", custom, None, None, None, &[]);
assert!(picked.contains("open a PR on agent-configs"), "{picked}"); assert_eq!(picked, "shared\nopen a PR on agent-configs\n");
assert!(!picked.contains("no config repo"), "{picked}");
} }
let picked = config_blocks(CONFIG_FIXTURE, Some("android-dev")); let picked = render(
assert!(picked.contains("built from `android-dev`, no config repo")); CONFIG_FIXTURE,
assert!(!picked.contains("agent-configs"), "{picked}"); "alice",
assert!(picked.contains("shared")); Some("android-dev"),
assert!(!picked.contains("<!--"), "{picked}"); None,
None,
None,
&[],
);
assert_eq!(picked, "shared\nbuilt from `android-dev`, no config repo\n");
} }
#[test] #[test]
fn the_production_prompt_keeps_no_config_markers() { fn the_production_prompt_keeps_no_template_syntax() {
for archetype in [None, Some("reviewer")] { for archetype in [None, Some("reviewer")] {
let rendered = render( let rendered = render(
&config_blocks(&PRODUCTION_TEMPLATE, archetype), &PRODUCTION_TEMPLATE,
"alice", "alice",
archetype,
None, None,
None, None,
None, None,
&[], &[],
); );
assert!(!rendered.contains("<!-- config:"), "{rendered}"); for left in ["{{", "{%", "<!-- config:", "<!-- /config:", "{archetype}"] {
assert!(!rendered.contains("<!-- /config:"), "{rendered}"); assert!(!rendered.contains(left), "{left} left in:\n{rendered}");
assert!(!rendered.contains("{archetype}"), "{rendered}"); }
} }
} }
// Inline fixture for the {hive_identity} / {swarm_identity} // Inline fixture for the {{ hive_identity }} / {{ swarm_identity }}
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE` // variables. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake // against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
// builds at derivation time — a fresh placeholder added on the // builds at derivation time — a fresh placeholder added on the
// source side isn't in the shipped asset until the flake rebuilds, // source side isn't in the shipped asset until the flake rebuilds,
// so PRODUCTION_TEMPLATE can't be the fixture here. The string // so PRODUCTION_TEMPLATE can't be the fixture here. The string
// below carries just enough of the opener shape to exercise the // below carries just enough of the opener shape to exercise the
// substitution logic; nothing here depends on the production // substitution logic; nothing here depends on the production
// template's flavor markers. // template's config block.
const IDENTITY_FIXTURE: &str = "\ 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] #[test]
fn render_substitutes_hive_identity_when_set() { 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}"); assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
// swarm clause stays absent when only hive is set. // swarm clause stays absent when only hive is set.
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));
@ -436,6 +329,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
IDENTITY_FIXTURE, IDENTITY_FIXTURE,
"ruth", "ruth",
None, None,
None,
Some("constellat1on"), Some("constellat1on"),
None, None,
&[], &[],
@ -449,6 +343,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
let rendered = render( let rendered = render(
IDENTITY_FIXTURE, IDENTITY_FIXTURE,
"iris", "iris",
None,
Some("pr1ma"), Some("pr1ma"),
Some("constellat1on"), Some("constellat1on"),
None, None,
@ -463,7 +358,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
fn render_omits_identity_when_unset() { fn render_omits_identity_when_unset() {
// None / None must round-trip the non-identity opener verbatim // None / None must round-trip the non-identity opener verbatim
// — single-hive deployments see zero diff. // — 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("on hive"));
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));
assert!(!rendered.contains("{hive_identity}")); 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 // through `identity::hive_name()` as None (the accessor
// filters empty), but `render` should still no-op on a // filters empty), but `render` should still no-op on a
// direct `Some("")` from a test fixture or a future caller. // 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("on hive"));
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));
} }
@ -488,6 +391,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
"alice", "alice",
None, None,
None, None,
None,
Some("/run/hive-docs"), Some("/run/hive-docs"),
&[], &[],
); );
@ -502,7 +406,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test] #[test]
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() { fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
for docs in [None, Some("")] { 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!( assert!(
!rendered.contains("mounted read-only at"), !rendered.contains("mounted read-only at"),
"docs pointer must not appear for {docs:?}:\n{rendered}" "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] #[test]
fn render_lists_operators_with_their_pronouns() { fn render_lists_operators_with_their_pronouns() {
let operators = [operator("mara", Some("she/her")), operator("sol", None)]; 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!( assert_eq!(
rendered, rendered,
"say \"the operator\".\n\nOperators: mara (she/her), sol.\n\nnext paragraph\n" "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!( assert!(
rendered.contains("Operators: mara (she/her), sol."), rendered.contains("Operators: mara (she/her), sol."),
"{rendered}" "{rendered}"
@ -538,17 +458,18 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
/// byte for byte. /// byte for byte.
#[test] #[test]
fn render_with_no_operators_is_the_template_without_the_placeholder() { 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_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
assert!(PRODUCTION_TEMPLATE.contains("{operators}")); assert!(PRODUCTION_TEMPLATE.contains("{{ operators }}"));
assert_eq!( assert_eq!(
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]), render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]),
render( render(
&PRODUCTION_TEMPLATE.replace("{operators}", ""), &PRODUCTION_TEMPLATE.replace("{{ operators }}", ""),
"alice", "alice",
None, None,
None, None,
None, None,
None,
&[] &[]
) )
); );

View file

@ -43,8 +43,8 @@ fn dir() -> PathBuf {
} }
/// `$HIVE_ASSETS_DIR/prompts/system.md` — the claude system prompt /// `$HIVE_ASSETS_DIR/prompts/system.md` — the claude system prompt
/// template. `hive-agent::prompt::render` filters the role and config markers /// template. `hive-agent::prompt::render` renders it as a Jinja template per
/// inside it per agent / manager flavor and per custom / archetype agent. /// agent, with a block for a custom or an archetype agent.
#[must_use] #[must_use]
pub fn prompt_template() -> PathBuf { pub fn prompt_template() -> PathBuf {
dir().join("prompts/system.md") dir().join("prompts/system.md")

View file

@ -7,6 +7,9 @@ block is `{% if cond %}...{% endif %}`, and a lone `{` or `}` stays plain text.
- **`render(template, &context)`**: the text, or a message when the template - **`render(template, &context)`**: the text, or a message when the template
does not parse or names a variable the context lacks. An unknown variable is does not parse or names a variable the context lacks. An unknown variable is
an error, never an empty string. an error, never an empty string.
- A block tag alone on its line leaves no line behind (`trim_blocks` and
`lstrip_blocks` are on), so a template ending in a block tag and a newline
loses that newline.
- **`variables(template)`**: the variables the template reads, so a caller can - **`variables(template)`**: the variables the template reads, so a caller can
refuse an unknown field when the template is saved instead of when it renders. refuse an unknown field when the template is saved instead of when it renders.

View file

@ -10,12 +10,15 @@ use std::collections::BTreeSet;
use minijinja::{Environment, UndefinedBehavior}; use minijinja::{Environment, UndefinedBehavior};
use serde::Serialize; use serde::Serialize;
/// An environment that refuses a name the context does not hold, and keeps the /// An environment that refuses a name the context does not hold, keeps the
/// template's trailing newline. /// template's trailing newline, and drops a block tag's own line (`{% if %}` on a
/// line by itself leaves no blank line behind).
fn environment() -> Environment<'static> { fn environment() -> Environment<'static> {
let mut env = Environment::new(); let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict); env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_keep_trailing_newline(true); env.set_keep_trailing_newline(true);
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env env
} }
@ -56,7 +59,16 @@ mod tests {
"{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n", "{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n",
&ctx, &ctx,
); );
assert_eq!(out.as_deref(), Ok("o/r (agent)\n")); assert_eq!(out.as_deref(), Ok("o/r (agent)"));
}
#[test]
fn a_block_tag_on_its_own_line_leaves_no_line_behind() {
let out = render(
"a\n{% if on %}\nb\n{% else %}\nc\n{% endif %}\nd\n",
&json!({ "on": false }),
);
assert_eq!(out.as_deref(), Ok("a\nc\nd\n"));
} }
#[test] #[test]