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:
Generated
+36
@@ -695,6 +695,7 @@ dependencies = [
|
|||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml_ng",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -803,6 +804,12 @@ dependencies = [
|
|||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1041,6 +1048,16 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indoc"
|
||||||
version = "2.0.7"
|
version = "2.0.7"
|
||||||
@@ -1827,6 +1844,19 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -2357,6 +2387,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ futures = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
serde_yaml_ng = { workspace = true }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
rusqlite = { workspace = true }
|
rusqlite = { workspace = true }
|
||||||
globset = { 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}}
|
||||||
@@ -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 config;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
|||||||
Reference in New Issue
Block a user