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
10 changed files with 165 additions and 242 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1693,6 +1693,7 @@ dependencies = [
|
|||
"hive-runtime",
|
||||
"hive-sh4re",
|
||||
"hive-sock-client",
|
||||
"hive-template",
|
||||
"hive-types",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
- **Prompt** — identical for every container but one block (below):
|
||||
`prompts/system.md`
|
||||
carries `<!-- role:agent -->` / `<!-- role:manager -->` marker
|
||||
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.
|
||||
- **Prompt** — identical for every container but one paragraph: an agent
|
||||
built from an [archetype](../swarm/archetypes.md) is told it has no config
|
||||
repo, every other agent is told how to propose changes to its own.
|
||||
- **Tool allow-list** — the MCP tools claude sees come from the agent's
|
||||
permissions alone, the same mechanism for every agent
|
||||
([`docs/4-implementation/turn-loop/mcp.md`](../../4-implementation/turn-loop/mcp.md)). Ruth's permissions
|
||||
|
|
|
|||
|
|
@ -297,8 +297,7 @@ definition, then create ruth from the swarm dashboard.
|
|||
The system prompt (`hive-agent/prompts/system.md`, rendered by
|
||||
`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,
|
||||
which the swarm sets). No `role:manager` block renders only
|
||||
for the root agent. The root agent's role-specific
|
||||
which the swarm sets). The root agent's role-specific
|
||||
behaviour comes from its AGENTS.md / agent-specific instructions, not
|
||||
the system prompt template.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(`Restart=always`, no stdio fallback). Extra servers stay stdio.
|
||||
- `claude-system-prompt.md` — rendered from
|
||||
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`:
|
||||
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
|
||||
same for `role:manager`) gate the role-specific blocks; both flavors
|
||||
share everything else. `<!-- config:repo -->` and
|
||||
`<!-- config:archetype -->` blocks gate the config-repo paragraphs: an
|
||||
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`: a
|
||||
[Jinja template](../../../hive-template/README.md) (`{{ name }}` for a
|
||||
value, `{% if %}` for a block). An
|
||||
`{% if not archetype %}` / `{% else %}` pair gates the config-repo paragraphs: an
|
||||
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
|
||||
every other agent the repo block, with `{archetype}` filled in with the
|
||||
archetype's name. Five placeholders are then
|
||||
substituted: `{label}` (short agent name), `{qualified_label}`
|
||||
from an [archetype](../../2-handbook/swarm/archetypes.md)) gets the archetype
|
||||
block, with `{{ archetype }}` filled in with the archetype's name, and every
|
||||
other agent the repo block. Five variables are
|
||||
set: `{{ label }}` (short agent name), `{{ qualified_label }}`
|
||||
(hive-qualified `name@domain` form),
|
||||
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
|
||||
`HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same
|
||||
shape for the swarm), and `{operators}`: a paragraph listing the
|
||||
`{{ hive_identity }}` (for example `` on hive `pr1ma` ``; empty when
|
||||
`HYPERHIVE_HIVE_NAME` is unset), `{{ swarm_identity }}` (same
|
||||
shape for the swarm), and `{{ operators }}`: a paragraph listing the
|
||||
swarm's operators with the pronouns from each one's forge profile,
|
||||
for example `Operators: mara (she/her), sol.`. The harness reads
|
||||
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
|
||||
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
|
||||
pronoun change reaches an agent at its next harness start, since
|
||||
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.
|
||||
Passed via `--system-prompt-file`.
|
||||
|
||||
**Marker grammar.** `<!-- KIND:X -->` opens a block; any
|
||||
`<!-- /KIND:X -->` closes the current block of that kind. The renderer
|
||||
filters the two kinds one after the other. The renderer always uses
|
||||
role `agent`, so it elides blocks with other role tags, and config `repo`
|
||||
or `archetype` as above. Nesting **isn't**
|
||||
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.
|
||||
**Template rules.** A variable the template names but the renderer doesn't
|
||||
set is an error. The harness then writes the template unrendered and logs the
|
||||
reason, so a typo shows up in the prompt instead of as an empty string. A
|
||||
block tag on a line of its own leaves no blank line behind. A lone `{` or `}`
|
||||
is plain text.
|
||||
|
||||
**`hive_identity` / `swarm_identity` shape.** Each carries a
|
||||
leading space + backticked name (` on hive \`pr1ma\``,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
&[]
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ fn dir() -> PathBuf {
|
|||
}
|
||||
|
||||
/// `$HIVE_ASSETS_DIR/prompts/system.md` — the claude system prompt
|
||||
/// template. `hive-agent::prompt::render` filters the role and config markers
|
||||
/// inside it per agent / manager flavor and per custom / archetype agent.
|
||||
/// template. `hive-agent::prompt::render` renders it as a Jinja template per
|
||||
/// agent, with a block for a custom or an archetype agent.
|
||||
#[must_use]
|
||||
pub fn prompt_template() -> PathBuf {
|
||||
dir().join("prompts/system.md")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
does not parse or names a variable the context lacks. An unknown variable is
|
||||
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
|
||||
refuse an unknown field when the template is saved instead of when it renders.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ use std::collections::BTreeSet;
|
|||
use minijinja::{Environment, UndefinedBehavior};
|
||||
use serde::Serialize;
|
||||
|
||||
/// An environment that refuses a name the context does not hold, and keeps the
|
||||
/// template's trailing newline.
|
||||
/// An environment that refuses a name the context does not hold, keeps the
|
||||
/// 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> {
|
||||
let mut env = Environment::new();
|
||||
env.set_undefined_behavior(UndefinedBehavior::Strict);
|
||||
env.set_keep_trailing_newline(true);
|
||||
env.set_trim_blocks(true);
|
||||
env.set_lstrip_blocks(true);
|
||||
env
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +59,16 @@ mod tests {
|
|||
"{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n",
|
||||
&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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue