diff --git a/Cargo.lock b/Cargo.lock index 2ef4d1f..93c09c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/harness-core/Cargo.toml b/crates/harness-core/Cargo.toml index 6f8bb52..340e2e9 100644 --- a/crates/harness-core/Cargo.toml +++ b/crates/harness-core/Cargo.toml @@ -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 } diff --git a/crates/harness-core/assets/agents/designer.md b/crates/harness-core/assets/agents/designer.md new file mode 100644 index 0000000..15d5830 --- /dev/null +++ b/crates/harness-core/assets/agents/designer.md @@ -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. diff --git a/crates/harness-core/assets/agents/explorer.md b/crates/harness-core/assets/agents/explorer.md new file mode 100644 index 0000000..434aee4 --- /dev/null +++ b/crates/harness-core/assets/agents/explorer.md @@ -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. diff --git a/crates/harness-core/assets/agents/fixer.md b/crates/harness-core/assets/agents/fixer.md new file mode 100644 index 0000000..cc1b301 --- /dev/null +++ b/crates/harness-core/assets/agents/fixer.md @@ -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. diff --git a/crates/harness-core/assets/agents/librarian.md b/crates/harness-core/assets/agents/librarian.md new file mode 100644 index 0000000..650a16a --- /dev/null +++ b/crates/harness-core/assets/agents/librarian.md @@ -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. diff --git a/crates/harness-core/assets/agents/oracle.md b/crates/harness-core/assets/agents/oracle.md new file mode 100644 index 0000000..e6ec3db --- /dev/null +++ b/crates/harness-core/assets/agents/oracle.md @@ -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. diff --git a/crates/harness-core/assets/agents/orchestrator.md b/crates/harness-core/assets/agents/orchestrator.md new file mode 100644 index 0000000..a0f500c --- /dev/null +++ b/crates/harness-core/assets/agents/orchestrator.md @@ -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}} diff --git a/crates/harness-core/src/agent/mod.rs b/crates/harness-core/src/agent/mod.rs new file mode 100644 index 0000000..9037b2f --- /dev/null +++ b/crates/harness-core/src/agent/mod.rs @@ -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 { + 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, + pub temperature: Option, + pub prompt: String, + pub permissions: Ruleset, + /// Tool enable/disable overrides (wildcard keys allowed); absent = inherit default. + pub tools: HashMap, + pub max_steps: Option, + 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, + mode: Option, + model: Option, + temperature: Option, + tools: Option>, + permission: Option>, + max_steps: Option, + disable: Option, +} + +fn parse_model_ref(s: &str) -> Option { + 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, 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, +} + +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, + 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(&mut self, files: I, source: AgentSource) + where + I: IntoIterator, + S: AsRef, + { + 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) { + 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()); + } +} diff --git a/crates/harness-core/src/lib.rs b/crates/harness-core/src/lib.rs index f224e12..253f0b4 100644 --- a/crates/harness-core/src/lib.rs +++ b/crates/harness-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod config; pub mod engine; pub mod event;