M4: agent registry and bundled markdown agents

Adds harness-core::agent, an agent registry loading bundled markdown agent definitions (designer, explorer, fixer, librarian, oracle, orchestrator) with override support.
This commit is contained in:
2026-07-10 16:20:11 +02:00
parent 8ed17bf091
commit f3b7666d3b
10 changed files with 594 additions and 0 deletions
Generated
+36
View File
@@ -695,6 +695,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"serde_yaml_ng",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -803,6 +804,12 @@ dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
@@ -1041,6 +1048,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "indoc"
version = "2.0.7"
@@ -1827,6 +1844,19 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml_ng"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -2357,6 +2387,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
+1
View File
@@ -11,6 +11,7 @@ futures = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
schemars = { workspace = true }
rusqlite = { workspace = true }
globset = { workspace = true }
@@ -0,0 +1,12 @@
---
description: UI and interaction design specialist for user-facing surfaces
mode: subagent
temperature: 0.4
tools: { task: false }
---
You are Designer, a UI and interaction design specialist. You handle user-facing surfaces:
layout, component structure, styling, and interaction details.
- Understand the existing design language before proposing changes; stay consistent with it.
- When implementing, make focused edits and describe the visual/interaction effect.
- Call out accessibility and responsive concerns relevant to the change.
@@ -0,0 +1,16 @@
---
description: Read-only reconnaissance specialist for mapping code and finding relevant files
mode: subagent
temperature: 0.1
tools: { write: false, edit: false, task: false }
permission:
- { permission: "edit", pattern: "*", action: deny }
- { permission: "write", pattern: "*", action: deny }
---
You are Explorer, a read-only reconnaissance specialist. You locate the code, files, and
facts the orchestrator needs and report back concisely.
- Use read, glob, grep, and bash (read-only commands) to investigate.
- Never modify files. Return a focused summary with concrete `path:line` references, not
file dumps.
- State what you found and, briefly, what you could not find.
@@ -0,0 +1,13 @@
---
description: Implementation specialist that makes focused code changes and verifies them
mode: subagent
temperature: 0.2
tools: { task: false }
---
You are Fixer, an implementation specialist. You take a well-scoped change, implement it,
and verify it compiles/tests.
- Make the smallest change that satisfies the request; match the surrounding code's style.
- Use read/grep to understand context before editing; use bash to build and run tests.
- Report exactly what you changed (files and the essence of the diff) and the result of any
verification you ran.
@@ -0,0 +1,15 @@
---
description: Documentation and knowledge lookup specialist
mode: subagent
temperature: 0.1
tools: { write: false, edit: false, task: false }
permission:
- { permission: "edit", pattern: "*", action: deny }
- { permission: "write", pattern: "*", action: deny }
---
You are Librarian. You find and summarize documentation, comments, READMEs, config, and
other in-repo knowledge on request.
- Search docs and source for the relevant material with read, glob, and grep.
- Quote the authoritative source with its `path:line`; do not invent details.
- Return a concise, well-organized summary with pointers back to the sources.
@@ -0,0 +1,15 @@
---
description: Deep-reasoning analyst for architecture, debugging, and design trade-offs
mode: subagent
temperature: 0.3
tools: { write: false, edit: false, task: false }
permission:
- { permission: "edit", pattern: "*", action: deny }
- { permission: "write", pattern: "*", action: deny }
---
You are Oracle, a deep-reasoning analyst. You are consulted for hard questions:
root-causing bugs, weighing architectural trade-offs, and reviewing designs.
- Read whatever code and context you need, but do not modify anything.
- Reason carefully and explicitly; state assumptions and the evidence behind conclusions.
- Return a decisive recommendation with the reasoning that supports it.
@@ -0,0 +1,19 @@
---
description: Primary coordinator that plans work and delegates to specialists
mode: primary
temperature: 0.2
---
You are the orchestrator. You plan the work, delegate focused pieces to specialist
subagents via the `task` tool, and synthesize their results into a final answer.
Guidelines:
- Break the request into concrete, independently-verifiable pieces.
- Prefer delegating reconnaissance and analysis to subagents so your own context stays
focused; do the integration and final write-up yourself.
- Launch background tasks for long-running independent work, then continue planning.
Do not poll running jobs — wait for completion and reconcile terminal jobs before your
final response.
- Reuse a completed specialist session (by its job alias) when following up on the same
thread of work.
{{SUBAGENTS}}
+466
View File
@@ -0,0 +1,466 @@
//! Agent definitions and registry.
//!
//! All agent *behavior* lives in markdown + config — the engine only understands `mode`, tool
//! filters, permissions, model, and depth. Definitions are layered (bundled → global → project
//! → config patch); later layers win by name. See `docs/04-multiagent.md`.
use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use crate::config::AgentPatch;
use crate::permission::{Rule, Ruleset};
use crate::types::ModelRef;
/// Marker in a primary agent's prompt replaced at load time with the routing list of enabled
/// subagents (name + description). Keeps routing text in sync with the enabled agent set.
const SUBAGENTS_MARKER: &str = "{{SUBAGENTS}}";
const BUNDLED: &[(&str, &str)] = &[
("orchestrator", include_str!("../../assets/agents/orchestrator.md")),
("explorer", include_str!("../../assets/agents/explorer.md")),
("oracle", include_str!("../../assets/agents/oracle.md")),
("librarian", include_str!("../../assets/agents/librarian.md")),
("fixer", include_str!("../../assets/agents/fixer.md")),
("designer", include_str!("../../assets/agents/designer.md")),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AgentMode {
Primary,
#[default]
Subagent,
All,
}
impl AgentMode {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"primary" => Some(Self::Primary),
"subagent" => Some(Self::Subagent),
"all" => Some(Self::All),
_ => None,
}
}
/// Whether this agent can be invoked as a subagent via the `task` tool.
pub fn is_subagent(self) -> bool {
matches!(self, Self::Subagent | Self::All)
}
/// Whether this agent can drive a top-level (primary) session.
pub fn is_primary(self) -> bool {
matches!(self, Self::Primary | Self::All)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentSource {
Bundled,
Global,
Project,
Config,
}
#[derive(Debug, Clone)]
pub struct AgentDef {
pub name: String,
pub description: String,
pub mode: AgentMode,
/// `None` = follow the session model.
pub model: Option<ModelRef>,
pub temperature: Option<f32>,
pub prompt: String,
pub permissions: Ruleset,
/// Tool enable/disable overrides (wildcard keys allowed); absent = inherit default.
pub tools: HashMap<String, bool>,
pub max_steps: Option<u32>,
pub source: AgentSource,
}
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
#[error("agent {0}: {1}")]
Frontmatter(String, String),
}
/// YAML frontmatter shape (opencode-compatible).
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct Frontmatter {
description: Option<String>,
mode: Option<String>,
model: Option<String>,
temperature: Option<f32>,
tools: Option<HashMap<String, bool>>,
permission: Option<Vec<Rule>>,
max_steps: Option<u32>,
disable: Option<bool>,
}
fn parse_model_ref(s: &str) -> Option<ModelRef> {
s.split_once('/')
.map(|(p, m)| ModelRef::new(p.trim(), m.trim()))
}
/// Splits a markdown agent file into (frontmatter, body). A file without a leading `---`
/// fence is treated as an all-body prompt with empty frontmatter.
fn split_frontmatter(content: &str) -> (&str, &str) {
let rest = match content.strip_prefix("---\n").or_else(|| content.strip_prefix("---\r\n")) {
Some(r) => r,
None => return ("", content),
};
// Find the closing fence line.
for delim in ["\n---\n", "\n---\r\n"] {
if let Some(idx) = rest.find(delim) {
let body_start = idx + delim.len();
return (&rest[..idx], &rest[body_start..]);
}
}
// Trailing fence with no body / no trailing newline.
if let Some(fm) = rest.strip_suffix("\n---").or(Some(rest)) {
if rest.ends_with("\n---") {
return (fm, "");
}
}
("", content)
}
/// Parses one markdown agent definition. Returns `Ok(None)` when the file marks itself
/// `disable: true`.
fn parse_agent(
name: &str,
source: AgentSource,
content: &str,
) -> Result<Option<AgentDef>, AgentError> {
let (fm_raw, body) = split_frontmatter(content);
let fm: Frontmatter = if fm_raw.trim().is_empty() {
Frontmatter::default()
} else {
serde_yaml_ng::from_str(fm_raw)
.map_err(|e| AgentError::Frontmatter(name.to_string(), e.to_string()))?
};
if fm.disable == Some(true) {
return Ok(None);
}
Ok(Some(AgentDef {
name: name.to_string(),
description: fm.description.unwrap_or_default(),
mode: fm.mode.as_deref().and_then(AgentMode::parse).unwrap_or_default(),
model: fm.model.as_deref().and_then(parse_model_ref),
temperature: fm.temperature,
prompt: body.trim_end().to_string(),
permissions: fm.permission.unwrap_or_default(),
tools: fm.tools.unwrap_or_default(),
max_steps: fm.max_steps,
source,
}))
}
/// Applies a config `AgentPatch` onto an existing definition (only set fields override).
fn apply_patch(def: &mut AgentDef, patch: &AgentPatch) {
if let Some(mode) = patch.mode.as_deref().and_then(AgentMode::parse) {
def.mode = mode;
}
if let Some(model) = patch.model.as_deref().and_then(parse_model_ref) {
def.model = Some(model);
}
if let Some(temp) = patch.temperature {
def.temperature = Some(temp);
}
if let Some(prompt) = &patch.prompt {
def.prompt = prompt.clone();
}
if let Some(tools) = &patch.tools {
def.tools.extend(tools.clone());
}
if let Some(permission) = &patch.permission {
def.permissions = permission.clone();
}
}
#[derive(Debug, Clone, Default)]
pub struct AgentRegistry {
agents: HashMap<String, AgentDef>,
}
impl AgentRegistry {
pub fn get(&self, name: &str) -> Option<&AgentDef> {
self.agents.get(name)
}
pub fn all(&self) -> Vec<&AgentDef> {
self.agents.values().collect()
}
pub fn len(&self) -> usize {
self.agents.len()
}
pub fn is_empty(&self) -> bool {
self.agents.is_empty()
}
/// Just the bundled agents — the default when no overrides are configured (tests, headless).
pub fn bundled() -> Self {
let mut reg = Self::default();
reg.load_markdown_layer(BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)), AgentSource::Bundled);
reg.generate_routing();
reg
}
/// Full layered load: bundled → global dir → project dir → config patches.
pub fn load(
config_agents: &HashMap<String, AgentPatch>,
global_dir: Option<&Path>,
project_dir: Option<&Path>,
) -> Self {
let mut reg = Self::default();
reg.load_markdown_layer(
BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)),
AgentSource::Bundled,
);
if let Some(dir) = global_dir {
reg.load_dir(dir, AgentSource::Global);
}
if let Some(dir) = project_dir {
reg.load_dir(dir, AgentSource::Project);
}
reg.apply_config(config_agents);
reg.generate_routing();
reg
}
fn load_markdown_layer<I, S>(&mut self, files: I, source: AgentSource)
where
I: IntoIterator<Item = (String, S)>,
S: AsRef<str>,
{
for (name, content) in files {
match parse_agent(&name, source, content.as_ref()) {
Ok(Some(def)) => {
self.agents.insert(name, def);
}
Ok(None) => {
self.agents.remove(&name); // disable: true removes an earlier layer
}
Err(e) => tracing::warn!(error = %e, "skipping malformed agent"),
}
}
}
fn load_dir(&mut self, dir: &Path, source: AgentSource) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<(String, String)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(content) = std::fs::read_to_string(&path) {
files.push((name.to_string(), content));
}
}
files.sort();
self.load_markdown_layer(files, source);
}
fn apply_config(&mut self, config_agents: &HashMap<String, AgentPatch>) {
for (name, patch) in config_agents {
if patch.disable == Some(true) {
self.agents.remove(name);
continue;
}
if let Some(def) = self.agents.get_mut(name) {
apply_patch(def, patch);
} else if patch.model.is_some() || patch.prompt.is_some() {
// Unknown name with enough to stand on its own → custom agent.
let mut def = AgentDef {
name: name.clone(),
description: String::new(),
mode: AgentMode::default(),
model: None,
temperature: None,
prompt: String::new(),
permissions: Vec::new(),
tools: HashMap::new(),
max_steps: None,
source: AgentSource::Config,
};
apply_patch(&mut def, patch);
self.agents.insert(name.clone(), def);
}
}
}
/// Replaces `{{SUBAGENTS}}` in every primary agent's prompt with a generated routing list
/// of the enabled subagents (so disabling an agent removes it from routing text).
fn generate_routing(&mut self) {
let mut subagents: Vec<(String, String)> = self
.agents
.values()
.filter(|a| a.mode.is_subagent())
.map(|a| (a.name.clone(), a.description.clone()))
.collect();
subagents.sort();
let routing = if subagents.is_empty() {
"## Agents\n\nNo specialist subagents are available.".to_string()
} else {
let mut s = String::from("## Agents\n\nDelegate to these specialists via `task`:\n");
for (name, desc) in &subagents {
s.push_str(&format!("- **{name}** — {desc}\n"));
}
s
};
for agent in self.agents.values_mut() {
if agent.prompt.contains(SUBAGENTS_MARKER) {
agent.prompt = agent.prompt.replace(SUBAGENTS_MARKER, routing.trim_end());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::Action;
#[test]
fn bundled_loads_all_six_agents() {
let reg = AgentRegistry::bundled();
for name in ["orchestrator", "explorer", "oracle", "librarian", "fixer", "designer"] {
assert!(reg.get(name).is_some(), "missing {name}");
}
assert_eq!(reg.len(), 6);
}
#[test]
fn parses_mode_model_temperature_tools_and_permissions() {
let md = "---\n\
description: test agent\n\
mode: subagent\n\
model: anthropic/claude-haiku-4-5\n\
temperature: 0.1\n\
tools: { write: false, bash: true }\n\
permission:\n\
\x20 - { permission: \"edit\", pattern: \"*\", action: deny }\n\
---\n\
You are a test agent.\n";
let def = parse_agent("tester", AgentSource::Bundled, md).unwrap().unwrap();
assert_eq!(def.description, "test agent");
assert_eq!(def.mode, AgentMode::Subagent);
assert_eq!(def.model, Some(ModelRef::new("anthropic", "claude-haiku-4-5")));
assert_eq!(def.temperature, Some(0.1));
assert_eq!(def.tools.get("write"), Some(&false));
assert_eq!(def.tools.get("bash"), Some(&true));
assert_eq!(def.permissions.len(), 1);
assert_eq!(def.permissions[0].action, Action::Deny);
assert_eq!(def.prompt, "You are a test agent.");
}
#[test]
fn body_without_frontmatter_is_all_prompt() {
let def = parse_agent("x", AgentSource::Global, "just a prompt")
.unwrap()
.unwrap();
assert_eq!(def.prompt, "just a prompt");
assert_eq!(def.mode, AgentMode::Subagent); // default
}
#[test]
fn disable_true_removes_agent() {
assert!(parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody")
.unwrap()
.is_none());
}
#[test]
fn explorer_denies_writes_and_disables_edit_tool() {
let reg = AgentRegistry::bundled();
let explorer = reg.get("explorer").unwrap();
assert_eq!(explorer.mode, AgentMode::Subagent);
assert_eq!(explorer.tools.get("edit"), Some(&false));
assert!(explorer
.permissions
.iter()
.any(|r| r.permission == "write" && r.action == Action::Deny));
}
#[test]
fn orchestrator_routing_lists_subagents_and_drops_marker() {
let reg = AgentRegistry::bundled();
let prompt = &reg.get("orchestrator").unwrap().prompt;
assert!(!prompt.contains("{{SUBAGENTS}}"));
assert!(prompt.contains("explorer"));
assert!(prompt.contains("fixer"));
// The orchestrator itself is primary and must not list itself.
assert!(!prompt.contains("- **orchestrator**"));
}
#[test]
fn config_patch_overrides_only_set_fields() {
let mut patches = HashMap::new();
patches.insert(
"explorer".to_string(),
AgentPatch {
temperature: Some(0.9),
..Default::default()
},
);
let reg = AgentRegistry::load(&patches, None, None);
let explorer = reg.get("explorer").unwrap();
assert_eq!(explorer.temperature, Some(0.9)); // overridden
assert_eq!(explorer.mode, AgentMode::Subagent); // untouched
assert_eq!(explorer.tools.get("edit"), Some(&false)); // untouched
}
#[test]
fn config_disable_removes_and_unknown_with_model_creates() {
let mut patches = HashMap::new();
patches.insert(
"designer".to_string(),
AgentPatch {
disable: Some(true),
..Default::default()
},
);
patches.insert(
"custom".to_string(),
AgentPatch {
model: Some("openai/gpt-5".into()),
prompt: Some("custom prompt".into()),
..Default::default()
},
);
let reg = AgentRegistry::load(&patches, None, None);
assert!(reg.get("designer").is_none());
let custom = reg.get("custom").unwrap();
assert_eq!(custom.source, AgentSource::Config);
assert_eq!(custom.model, Some(ModelRef::new("openai", "gpt-5")));
assert_eq!(custom.prompt, "custom prompt");
}
#[test]
fn unknown_config_agent_without_model_or_prompt_is_ignored() {
let mut patches = HashMap::new();
patches.insert(
"ghost".to_string(),
AgentPatch {
temperature: Some(0.5),
..Default::default()
},
);
let reg = AgentRegistry::load(&patches, None, None);
assert!(reg.get("ghost").is_none());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod agent;
pub mod config;
pub mod engine;
pub mod event;