diff --git a/Cargo.lock b/Cargo.lock index 44c2964d7..634cd3c22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1693,6 +1693,7 @@ dependencies = [ "hive-runtime", "hive-sh4re", "hive-sock-client", + "hive-template", "hive-types", "http-body-util", "hyper", diff --git a/docs/2-handbook/agents/agent-roster.md b/docs/2-handbook/agents/agent-roster.md index 5facdfffb..676756742 100644 --- a/docs/2-handbook/agents/agent-roster.md +++ b/docs/2-handbook/agents/agent-roster.md @@ -105,16 +105,9 @@ state dirs work as for every other agent. `/agents//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 `` / `` 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 `` vs ``: 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 diff --git a/docs/4-implementation/agent-state/config-changes.md b/docs/4-implementation/agent-state/config-changes.md index cdaeeed2c..6a5382b17 100644 --- a/docs/4-implementation/agent-state/config-changes.md +++ b/docs/4-implementation/agent-state/config-changes.md @@ -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. diff --git a/docs/4-implementation/turn-loop/claude-invocation.md b/docs/4-implementation/turn-loop/claude-invocation.md index 8a606af24..e27ae53ff 100644 --- a/docs/4-implementation/turn-loop/claude-invocation.md +++ b/docs/4-implementation/turn-loop/claude-invocation.md @@ -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 (`...`, - same for `role:manager`) gate the role-specific blocks; both flavors - share everything else. `` and - `` 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.** `` opens a block; any - `` 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 (`` parses the - same as ``). 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\``, diff --git a/hive-agent/Cargo.toml b/hive-agent/Cargo.toml index fd50a0d1c..8d3c19519 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -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 diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index d893b8a31..3ac6aed6b 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -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. - +{% 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/` 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/` repo, which an operator reviews and merges on the forge; the merge deploys it. - - +{% else %} -You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/` repo for you. +You are built from the `{{ archetype }}` archetype and have no config repo of your own: there is no `agent-configs/` 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. - +{% 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//.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. diff --git a/hive-agent/src/prompt.rs b/hive-agent/src/prompt.rs index db3dacbe0..f000761ca 100644 --- a/hive-agent/src/prompt.rs +++ b/hive-agent/src/prompt.rs @@ -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( } } -/// Walk `template` line-by-line, stripping `` / -/// `` 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"), - } -} - -/// `` with `kind = "role"` → `Some("agent")`. Anything -/// else returns None. Whitespace inside the marker is tolerated so a future -/// author's `` (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("")?.trim(); - let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim(); - Some(value) -} - -/// `` 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("")?.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 .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 - -agent-only line - - -manager-only line - -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 `", "role"), - Some("agent") - ); - assert_eq!( - parse_open_marker("", "role"), - Some("agent") - ); - assert_eq!( - parse_open_marker("", "role"), - Some("manager") - ); - // Close tags must NOT match open-tag parser. - assert_eq!(parse_open_marker("", "role"), None); - // Non-markers pass through (return None). - assert_eq!(parse_open_marker("just text", "role"), None); - assert_eq!(parse_open_marker("", "role"), None); - } - - #[test] - fn parse_close_marker_handles_whitespace_variants() { - assert_eq!( - parse_close_marker("", "role"), - Some("agent") - ); - assert_eq!( - parse_close_marker("", "role"), - Some("manager") - ); - // Open tags must NOT match close-tag parser. - assert_eq!(parse_close_marker("", "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(" +{% if not archetype %} open a PR on agent-configs - - -built from `{archetype}`, no config repo - +{% 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("