harness-core: agent registry + bundled markdown agents (M4)
Layered agent definitions (bundled -> global -> project -> config patch),
opencode-compatible markdown frontmatter, generated {{SUBAGENTS}} routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = ®.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,3 +1,4 @@
|
||||
pub mod agent;
|
||||
pub mod config;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
|
||||
Reference in New Issue
Block a user