From f3b7666d3bf9b9086b7110a5b9f0e9f9e58bd544 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:05:53 +0200 Subject: [PATCH 1/6] 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. --- Cargo.lock | 36 ++ crates/harness-core/Cargo.toml | 1 + crates/harness-core/assets/agents/designer.md | 12 + crates/harness-core/assets/agents/explorer.md | 16 + crates/harness-core/assets/agents/fixer.md | 13 + .../harness-core/assets/agents/librarian.md | 15 + crates/harness-core/assets/agents/oracle.md | 15 + .../assets/agents/orchestrator.md | 19 + crates/harness-core/src/agent/mod.rs | 466 ++++++++++++++++++ crates/harness-core/src/lib.rs | 1 + 10 files changed, 594 insertions(+) create mode 100644 crates/harness-core/assets/agents/designer.md create mode 100644 crates/harness-core/assets/agents/explorer.md create mode 100644 crates/harness-core/assets/agents/fixer.md create mode 100644 crates/harness-core/assets/agents/librarian.md create mode 100644 crates/harness-core/assets/agents/oracle.md create mode 100644 crates/harness-core/assets/agents/orchestrator.md create mode 100644 crates/harness-core/src/agent/mod.rs 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; -- 2.54.0 From 07476d131873f9c9e50f44c71c0ac612e01636a7 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:11:45 +0200 Subject: [PATCH 2/6] M4: permission intersection and job board Adds harness-core::engine::jobs, the JobBoard tracking foreground/background subagent jobs with persistence, plus permission-rule intersection so a spawned subagent's effective permissions are the parent's rules narrowed by its own. --- crates/harness-core/src/engine/jobs.rs | 561 +++++++++++++++++++++ crates/harness-core/src/engine/mod.rs | 2 + crates/harness-core/src/permission/mod.rs | 2 +- crates/harness-core/src/permission/rule.rs | 76 +++ crates/harness-core/src/store/actor.rs | 41 ++ crates/harness-core/src/store/api.rs | 17 + 6 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 crates/harness-core/src/engine/jobs.rs diff --git a/crates/harness-core/src/engine/jobs.rs b/crates/harness-core/src/engine/jobs.rs new file mode 100644 index 0000000..f2efa67 --- /dev/null +++ b/crates/harness-core/src/engine/jobs.rs @@ -0,0 +1,561 @@ +//! Background job board — tracks subagent tasks spawned via the `task` tool so the +//! orchestrator can see running work, reconcile terminal results, and reuse completed +//! child sessions by alias. Simplified native port of oh-my-opencode-slim's +//! `background-job-board.ts`. See `docs/04-multiagent.md`. +//! +//! The board is an in-memory `RwLock` mirrored to the `job` table so it survives a +//! resume. All mutations persist through the passed-in `Store`. + +use std::collections::HashMap; +use std::sync::RwLock; + +use serde::{Deserialize, Serialize}; + +use crate::event::{AppEvent, EventBus, JobRecordEvent}; +use crate::store::{Store, StoreError}; +use crate::types::SessionId; + +/// A file a child session read, surfaced on the board so the orchestrator knows what a +/// completed specialist already looked at. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContextFile { + pub path: String, + pub lines: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobState { + Running, + Completed, + Error, + Cancelled, +} + +impl JobState { + /// Terminal jobs are candidates for reconciliation; a completed one is reusable. + pub fn is_terminal(self) -> bool { + !matches!(self, JobState::Running) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobRecord { + pub task_id: String, + /// Human-friendly handle: first 3 chars of the agent name + a per-agent counter (`exp-1`). + pub alias: String, + pub parent_session: SessionId, + pub child_session: SessionId, + pub agent: String, + pub description: String, + pub objective: Option, + pub state: JobState, + /// Whether the orchestrator has already seen this job's terminal result. + pub reconciled: bool, + pub result_summary: Option, + pub context_files: Vec, + pub launched_at: i64, + pub updated_at: i64, + pub last_used_at: i64, +} + +/// Parameters for registering a newly launched task on the board. +pub struct LaunchSpec { + pub task_id: String, + pub parent_session: SessionId, + pub child_session: SessionId, + pub agent: String, + pub description: String, + pub objective: Option, +} + +/// Result summaries are truncated to this many chars on the board. +const SUMMARY_MAX: usize = 2000; +/// Context files shown per job in the prompt injection. +const CONTEXT_FILES_SHOWN: usize = 8; +/// A read must cover at least this many lines to be worth reporting to the board. +pub const MIN_REPORTED_LINES: u32 = 10; + +pub struct JobBoard { + store: Store, + bus: EventBus, + jobs: RwLock>, + max_reusable_per_agent: u32, +} + +impl JobBoard { + /// Builds a board and loads any persisted jobs for `parent_session`'s tree from the store. + pub async fn load( + store: Store, + bus: EventBus, + parent_session: &SessionId, + max_reusable_per_agent: u32, + ) -> Result { + let existing = store.jobs_for_parent(parent_session.clone()).await?; + let mut jobs = HashMap::new(); + for job in existing { + jobs.insert(job.task_id.clone(), job); + } + Ok(Self { + store, + bus, + jobs: RwLock::new(jobs), + max_reusable_per_agent, + }) + } + + /// Assigns the next alias for `agent` under this board: `<3-char-prefix>-`. + fn next_alias(&self, agent: &str) -> String { + let prefix: String = agent.chars().take(3).collect(); + let prefix = if prefix.is_empty() { + "job".to_string() + } else { + prefix.to_ascii_lowercase() + }; + let n = self + .jobs + .read() + .unwrap() + .values() + .filter(|j| j.agent == agent) + .count() + + 1; + format!("{prefix}-{n}") + } + + /// Registers a freshly launched task and returns its assigned alias. + pub async fn register_launch(&self, spec: LaunchSpec, now: i64) -> Result { + let alias = self.next_alias(&spec.agent); + let record = JobRecord { + task_id: spec.task_id, + alias: alias.clone(), + parent_session: spec.parent_session, + child_session: spec.child_session, + agent: spec.agent, + description: spec.description, + objective: spec.objective, + state: JobState::Running, + reconciled: false, + result_summary: None, + context_files: Vec::new(), + launched_at: now, + updated_at: now, + last_used_at: now, + }; + self.upsert(record).await?; + Ok(alias) + } + + /// Marks a job terminal with an optional result summary (truncated). + pub async fn finish( + &self, + task_id: &str, + state: JobState, + result_summary: Option, + now: i64, + ) -> Result<(), StoreError> { + let Some(mut record) = self.get(task_id) else { + return Ok(()); + }; + record.state = state; + record.result_summary = result_summary.map(|s| truncate_summary(&s)); + record.updated_at = now; + record.last_used_at = now; + self.upsert(record).await?; + if state == JobState::Completed { + self.trim_reusable(now).await?; + } + Ok(()) + } + + /// Records a file a child session read (deduping by path, keeping the largest read). + pub async fn report_context_file( + &self, + task_id: &str, + path: String, + lines: u32, + now: i64, + ) -> Result<(), StoreError> { + if lines < MIN_REPORTED_LINES { + return Ok(()); + } + let Some(mut record) = self.get(task_id) else { + return Ok(()); + }; + match record.context_files.iter_mut().find(|f| f.path == path) { + Some(existing) => existing.lines = existing.lines.max(lines), + None => record.context_files.push(ContextFile { path, lines }), + } + record.updated_at = now; + self.upsert(record).await + } + + /// Resolves an alias or task id to a job for the given parent (reuse lookup). Only + /// completed (reusable) jobs match. + pub fn resolve_reusable(&self, parent: &SessionId, alias_or_id: &str) -> Option { + self.jobs + .read() + .unwrap() + .values() + .find(|j| { + &j.parent_session == parent + && j.state == JobState::Completed + && (j.alias == alias_or_id || j.task_id == alias_or_id) + }) + .cloned() + } + + /// Bumps `last_used_at` when a completed session is reused, keeping it fresh in the LRU. + pub async fn touch(&self, task_id: &str, now: i64) -> Result<(), StoreError> { + let Some(mut record) = self.get(task_id) else { + return Ok(()); + }; + record.last_used_at = now; + self.upsert(record).await + } + + /// Marks all terminal jobs `reconciled` — the orchestrator has now seen them. + pub async fn reconcile_terminal(&self, now: i64) -> Result<(), StoreError> { + let to_update: Vec = { + let jobs = self.jobs.read().unwrap(); + jobs.values() + .filter(|j| j.state.is_terminal() && !j.reconciled) + .cloned() + .collect() + }; + for mut record in to_update { + record.reconciled = true; + record.updated_at = now; + self.upsert(record).await?; + } + Ok(()) + } + + pub fn is_empty(&self) -> bool { + self.jobs.read().unwrap().is_empty() + } + + pub fn snapshot(&self) -> Vec { + self.jobs.read().unwrap().values().cloned().collect() + } + + /// Renders the board as a synthetic prompt block, or `None` when there is nothing to show. + /// Mirrors slim's `formatForPrompt`. + pub fn format_for_prompt(&self) -> Option { + let jobs = self.jobs.read().unwrap(); + if jobs.is_empty() { + return None; + } + + let mut active: Vec<&JobRecord> = jobs + .values() + .filter(|j| !j.state.is_terminal() || !j.reconciled) + .collect(); + let mut reusable: Vec<&JobRecord> = jobs + .values() + .filter(|j| j.state == JobState::Completed && j.reconciled) + .collect(); + active.sort_by(|a, b| a.alias.cmp(&b.alias)); + reusable.sort_by(|a, b| a.alias.cmp(&b.alias)); + + if active.is_empty() && reusable.is_empty() { + return None; + } + + let mut out = String::from( + "### Background Job Board\n\ + Do not poll running jobs; wait for completion. Reconcile terminal jobs before your \ + final response.\nCompleted sessions are reusable by alias for the same specialist.\n", + ); + + if !active.is_empty() { + out.push_str("\n#### Active / Unreconciled\n"); + for job in &active { + let state = state_label(job.state); + out.push_str(&format!( + "- {} / {} / {} / {state}", + job.alias, job.child_session, job.agent + )); + if let Some(obj) = &job.objective { + out.push_str(&format!(" — Objective: {obj}")); + } + out.push('\n'); + if let Some(summary) = &job.result_summary { + out.push_str(&format!(" Result: {summary}\n")); + } + } + } + + if !reusable.is_empty() { + out.push_str("\n#### Reusable Sessions\n"); + for job in &reusable { + out.push_str(&format!( + "- {} / {} / {} / completed\n", + job.alias, job.child_session, job.agent + )); + if let Some(obj) = &job.objective { + out.push_str(&format!(" Objective: {obj}\n")); + } + if !job.context_files.is_empty() { + let files: Vec<&str> = job + .context_files + .iter() + .take(CONTEXT_FILES_SHOWN) + .map(|f| f.path.as_str()) + .collect(); + out.push_str(&format!(" Context read: {}\n", files.join(", "))); + } + } + } + + Some(out) + } + + fn get(&self, task_id: &str) -> Option { + self.jobs.read().unwrap().get(task_id).cloned() + } + + async fn upsert(&self, record: JobRecord) -> Result<(), StoreError> { + self.store.upsert_job(record.clone()).await?; + self.jobs + .write() + .unwrap() + .insert(record.task_id.clone(), record.clone()); + self.bus.publish(AppEvent::JobUpdated { + job: JobRecordEvent(serde_json::to_value(&record).unwrap_or(serde_json::Value::Null)), + }); + Ok(()) + } + + /// Keeps at most `max_reusable_per_agent` completed jobs per agent (LRU by `last_used_at`); + /// older completed jobs are dropped from the board and the store. + async fn trim_reusable(&self, _now: i64) -> Result<(), StoreError> { + let to_remove: Vec = { + let jobs = self.jobs.read().unwrap(); + let mut by_agent: HashMap<&str, Vec<&JobRecord>> = HashMap::new(); + for job in jobs.values().filter(|j| j.state == JobState::Completed) { + by_agent.entry(job.agent.as_str()).or_default().push(job); + } + let mut remove = Vec::new(); + for group in by_agent.values_mut() { + if group.len() as u32 <= self.max_reusable_per_agent { + continue; + } + // Oldest last_used_at first; drop the excess from the front. + group.sort_by_key(|j| j.last_used_at); + let excess = group.len() - self.max_reusable_per_agent as usize; + for job in group.iter().take(excess) { + remove.push(job.task_id.clone()); + } + } + remove + }; + for task_id in to_remove { + self.store.delete_job(task_id.clone()).await?; + self.jobs.write().unwrap().remove(&task_id); + } + Ok(()) + } +} + +fn state_label(state: JobState) -> &'static str { + match state { + JobState::Running => "running", + JobState::Completed => "completed", + JobState::Error => "error", + JobState::Cancelled => "cancelled", + } +} + +fn truncate_summary(s: &str) -> String { + if s.len() <= SUMMARY_MAX { + return s.to_string(); + } + let mut end = SUMMARY_MAX; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn board(max_reusable: u32) -> (Store, JobBoard, SessionId) { + let store = Store::open_in_memory().unwrap(); + let bus = EventBus::new(); + let parent = SessionId::new(); + let board = JobBoard::load(store.clone(), bus, &parent, max_reusable) + .await + .unwrap(); + (store, board, parent) + } + + fn spec( + task_id: &str, + parent: SessionId, + child: SessionId, + agent: &str, + objective: Option<&str>, + ) -> LaunchSpec { + LaunchSpec { + task_id: task_id.into(), + parent_session: parent, + child_session: child, + agent: agent.into(), + description: "d".into(), + objective: objective.map(Into::into), + } + } + + #[tokio::test] + async fn alias_increments_per_agent() { + let (_store, board, parent) = board(2).await; + let a1 = board + .register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1) + .await + .unwrap(); + let a2 = board + .register_launch(spec("t2", parent.clone(), SessionId::new(), "explorer", None), 2) + .await + .unwrap(); + let f1 = board + .register_launch(spec("t3", parent.clone(), SessionId::new(), "fixer", None), 3) + .await + .unwrap(); + assert_eq!(a1, "exp-1"); + assert_eq!(a2, "exp-2"); + assert_eq!(f1, "fix-1"); + } + + #[tokio::test] + async fn finish_makes_job_reusable_and_resolvable_by_alias() { + let (_store, board, parent) = board(2).await; + let child = SessionId::new(); + let alias = board + .register_launch( + spec( + "t1", + parent.clone(), + child.clone(), + "explorer", + Some("map the auth flow"), + ), + 1, + ) + .await + .unwrap(); + // Running jobs are not reusable. + assert!(board.resolve_reusable(&parent, &alias).is_none()); + board + .finish("t1", JobState::Completed, Some("done".into()), 2) + .await + .unwrap(); + let resolved = board.resolve_reusable(&parent, &alias).unwrap(); + assert_eq!(resolved.child_session, child); + assert_eq!(resolved.result_summary.as_deref(), Some("done")); + // Also resolvable by task id. + assert!(board.resolve_reusable(&parent, "t1").is_some()); + } + + #[tokio::test] + async fn context_files_dedupe_and_respect_min_lines() { + let (_store, board, parent) = board(2).await; + board + .register_launch(spec("t1", parent, SessionId::new(), "explorer", None), 1) + .await + .unwrap(); + // Below threshold — ignored. + board + .report_context_file("t1", "small.rs".into(), 3, 2) + .await + .unwrap(); + board + .report_context_file("t1", "a.rs".into(), 20, 2) + .await + .unwrap(); + // Same file again with a larger read keeps the max. + board + .report_context_file("t1", "a.rs".into(), 50, 3) + .await + .unwrap(); + let job = board.snapshot().into_iter().next().unwrap(); + assert_eq!(job.context_files.len(), 1); + assert_eq!(job.context_files[0].path, "a.rs"); + assert_eq!(job.context_files[0].lines, 50); + } + + #[tokio::test] + async fn trim_reusable_keeps_lru_within_limit() { + let (_store, board, parent) = board(2).await; + for (i, ts) in [(1, 10), (2, 20), (3, 30)] { + board + .register_launch( + spec(&format!("t{i}"), parent.clone(), SessionId::new(), "explorer", None), + ts, + ) + .await + .unwrap(); + board + .finish(&format!("t{i}"), JobState::Completed, None, ts) + .await + .unwrap(); + } + // max_reusable = 2, so the oldest (t1, last_used 10) is dropped. + let ids: Vec = board.snapshot().into_iter().map(|j| j.task_id).collect(); + assert_eq!(ids.len(), 2); + assert!(!ids.contains(&"t1".to_string())); + assert!(ids.contains(&"t2".to_string())); + assert!(ids.contains(&"t3".to_string())); + } + + #[tokio::test] + async fn reconcile_flips_terminal_jobs_and_moves_them_to_reusable_section() { + let (_store, board, parent) = board(2).await; + board + .register_launch( + spec("t1", parent, SessionId::new(), "explorer", Some("obj")), + 1, + ) + .await + .unwrap(); + board + .finish("t1", JobState::Completed, Some("res".into()), 2) + .await + .unwrap(); + // Before reconcile: appears under Active/Unreconciled. + let prompt = board.format_for_prompt().unwrap(); + assert!(prompt.contains("Active / Unreconciled")); + board.reconcile_terminal(3).await.unwrap(); + let prompt = board.format_for_prompt().unwrap(); + assert!(prompt.contains("Reusable Sessions")); + assert!(prompt.contains("exp-1")); + } + + #[tokio::test] + async fn board_reloads_persisted_jobs() { + let store = Store::open_in_memory().unwrap(); + let bus = EventBus::new(); + let parent = SessionId::new(); + { + let board = JobBoard::load(store.clone(), bus.clone(), &parent, 2) + .await + .unwrap(); + board + .register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1) + .await + .unwrap(); + } + // Fresh board over the same store sees the persisted job. + let board = JobBoard::load(store, bus, &parent, 2).await.unwrap(); + assert!(!board.is_empty()); + assert_eq!(board.snapshot().len(), 1); + } + + #[tokio::test] + async fn empty_board_formats_to_none() { + let (_store, board, _parent) = board(2).await; + assert!(board.format_for_prompt().is_none()); + } +} diff --git a/crates/harness-core/src/engine/mod.rs b/crates/harness-core/src/engine/mod.rs index c3d6c91..742fffa 100644 --- a/crates/harness-core/src/engine/mod.rs +++ b/crates/harness-core/src/engine/mod.rs @@ -1,4 +1,5 @@ pub mod doomloop; +pub mod jobs; pub mod processor; pub mod retry; #[path = "loop.rs"] @@ -6,5 +7,6 @@ pub mod session_loop; pub mod system; pub use doomloop::DoomLoopGuard; +pub use jobs::{ContextFile, JobBoard, JobRecord, JobState}; pub use processor::{process_step, StepContext, StepError, StepOutcome, StepResult}; pub use session_loop::{run_session, RunConfig}; diff --git a/crates/harness-core/src/permission/mod.rs b/crates/harness-core/src/permission/mod.rs index 17f4b2d..236dee0 100644 --- a/crates/harness-core/src/permission/mod.rs +++ b/crates/harness-core/src/permission/mod.rs @@ -1,7 +1,7 @@ pub mod rule; pub mod service; -pub use rule::{evaluate, Action, Rule, Ruleset}; +pub use rule::{evaluate, evaluate_intersected, Action, Rule, Ruleset}; pub use service::{ spawn_auto_approve, AskDecision, AskError, AskInput, PermissionReply, PermissionService, }; diff --git a/crates/harness-core/src/permission/rule.rs b/crates/harness-core/src/permission/rule.rs index 995b8b7..cfa075e 100644 --- a/crates/harness-core/src/permission/rule.rs +++ b/crates/harness-core/src/permission/rule.rs @@ -44,6 +44,37 @@ pub fn evaluate(stack: &[&Ruleset], permission: &str, pattern: &str) -> Action { result } +impl Action { + /// How restrictive this verdict is: `Deny` > `Ask` > `Allow`. Used to intersect a + /// parent and child verdict when a subagent runs (docs/04-multiagent.md). + fn restrictiveness(self) -> u8 { + match self { + Action::Allow => 0, + Action::Ask => 1, + Action::Deny => 2, + } + } +} + +/// Evaluate `permission`/`pattern` against a parent-effective stack and a child stack +/// independently, returning the **more restrictive** of the two verdicts +/// (`deny > ask > allow`). A subagent's tool call must satisfy both the rules it inherits +/// from its spawning chain and its own agent ruleset. +pub fn evaluate_intersected( + parent_stack: &[&Ruleset], + child_stack: &[&Ruleset], + permission: &str, + pattern: &str, +) -> Action { + let parent = evaluate(parent_stack, permission, pattern); + let child = evaluate(child_stack, permission, pattern); + if child.restrictiveness() >= parent.restrictiveness() { + child + } else { + parent + } +} + #[cfg(test)] mod tests { use super::*; @@ -112,4 +143,49 @@ mod tests { fn empty_stack_defaults_to_ask() { assert_eq!(evaluate(&[], "bash", "ls"), Action::Ask); } + + #[test] + fn intersected_takes_the_more_restrictive_verdict() { + let parent_allow: Ruleset = vec![rule("edit", "*", Action::Allow)]; + let child_deny: Ruleset = vec![rule("edit", "*", Action::Deny)]; + // Child denies what the parent would allow → deny wins. + assert_eq!( + evaluate_intersected(&[&parent_allow], &[&child_deny], "edit", "main.rs"), + Action::Deny + ); + // Symmetric: parent denies what the child would allow → deny still wins. + assert_eq!( + evaluate_intersected(&[&child_deny], &[&parent_allow], "edit", "main.rs"), + Action::Deny + ); + } + + #[test] + fn intersected_ask_beats_allow_but_loses_to_deny() { + let allow: Ruleset = vec![rule("bash", "*", Action::Allow)]; + let ask: Ruleset = vec![rule("bash", "*", Action::Ask)]; + let deny: Ruleset = vec![rule("bash", "*", Action::Deny)]; + assert_eq!( + evaluate_intersected(&[&allow], &[&ask], "bash", "ls"), + Action::Ask + ); + assert_eq!( + evaluate_intersected(&[&ask], &[&deny], "bash", "ls"), + Action::Deny + ); + } + + #[test] + fn intersected_allows_only_when_both_allow() { + let allow: Ruleset = vec![rule("read", "*", Action::Allow)]; + assert_eq!( + evaluate_intersected(&[&allow], &[&allow], "read", "src/a.rs"), + Action::Allow + ); + // Empty child stack defaults to Ask, which is more restrictive than parent Allow. + assert_eq!( + evaluate_intersected(&[&allow], &[], "read", "src/a.rs"), + Action::Ask + ); + } } diff --git a/crates/harness-core/src/store/actor.rs b/crates/harness-core/src/store/actor.rs index 05671de..b273fca 100644 --- a/crates/harness-core/src/store/actor.rs +++ b/crates/harness-core/src/store/actor.rs @@ -1,6 +1,7 @@ use rusqlite::{params, Connection}; use tokio::sync::oneshot; +use crate::engine::jobs::JobRecord; use crate::types::{Message, MessageId, Part, Session, SessionId}; use super::api::StoreError; @@ -17,6 +18,9 @@ pub enum StoreCmd { Sessions(Reply>), Messages(SessionId, Reply>), Parts(MessageId, Reply>), + UpsertJob(JobRecord, Reply<()>), + DeleteJob(String, Reply<()>), + JobsForParent(SessionId, Reply>), } fn init_schema(conn: &Connection) -> rusqlite::Result<()> { @@ -109,6 +113,34 @@ fn list_parts(conn: &Connection, message_id: &MessageId) -> Result, St .collect() } +fn upsert_job(conn: &Connection, job: &JobRecord) -> Result<(), StoreError> { + let data = serde_json::to_string(job)?; + conn.execute( + "INSERT INTO job (task_id, parent_session_id, data) VALUES (?1, ?2, ?3) + ON CONFLICT(task_id) DO UPDATE SET data = ?3", + params![job.task_id, job.parent_session.as_ref(), data], + )?; + Ok(()) +} + +fn delete_job(conn: &Connection, task_id: &str) -> Result<(), StoreError> { + conn.execute("DELETE FROM job WHERE task_id = ?1", params![task_id])?; + Ok(()) +} + +fn list_jobs_for_parent( + conn: &Connection, + parent: &SessionId, +) -> Result, StoreError> { + let mut stmt = conn.prepare("SELECT data FROM job WHERE parent_session_id = ?1")?; + let rows = stmt + .query_map(params![parent.as_ref()], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + rows.iter() + .map(|data| serde_json::from_str(data).map_err(StoreError::from)) + .collect() +} + /// Runs on a dedicated OS thread; the async facade in `api.rs` talks to it over `mpsc`. pub fn run(conn: Connection, mut rx: tokio::sync::mpsc::Receiver) { if let Err(e) = init_schema(&conn) { @@ -138,6 +170,15 @@ pub fn run(conn: Connection, mut rx: tokio::sync::mpsc::Receiver) { StoreCmd::Parts(message_id, reply) => { let _ = reply.send(list_parts(&conn, &message_id)); } + StoreCmd::UpsertJob(job, reply) => { + let _ = reply.send(upsert_job(&conn, &job)); + } + StoreCmd::DeleteJob(task_id, reply) => { + let _ = reply.send(delete_job(&conn, &task_id)); + } + StoreCmd::JobsForParent(parent, reply) => { + let _ = reply.send(list_jobs_for_parent(&conn, &parent)); + } } } } diff --git a/crates/harness-core/src/store/api.rs b/crates/harness-core/src/store/api.rs index 3b00129..a1694c1 100644 --- a/crates/harness-core/src/store/api.rs +++ b/crates/harness-core/src/store/api.rs @@ -3,6 +3,7 @@ use std::path::Path; use rusqlite::Connection; use tokio::sync::{mpsc, oneshot}; +use crate::engine::jobs::JobRecord; use crate::types::{Message, MessageId, Part, Session, SessionId}; use super::actor::{self, StoreCmd}; @@ -94,6 +95,22 @@ impl Store { pub async fn parts(&self, message_id: MessageId) -> Result, StoreError> { self.call(|reply| StoreCmd::Parts(message_id, reply)).await } + + pub async fn upsert_job(&self, job: JobRecord) -> Result<(), StoreError> { + self.call(|reply| StoreCmd::UpsertJob(job, reply)).await + } + + pub async fn delete_job(&self, task_id: String) -> Result<(), StoreError> { + self.call(|reply| StoreCmd::DeleteJob(task_id, reply)).await + } + + pub async fn jobs_for_parent( + &self, + parent: SessionId, + ) -> Result, StoreError> { + self.call(|reply| StoreCmd::JobsForParent(parent, reply)) + .await + } } #[cfg(test)] -- 2.54.0 From 8976b5dc0936fd9084a671a1f512020df88eb191 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:29:12 +0200 Subject: [PATCH 3/6] M4: task tool, subagent spawner, board wiring Adds the task tool and subagent spawner in harness-tools, and wires spawning/aliasing/reuse and depth limits through harness-app and the job board, so an orchestrator session can launch and track child sessions. --- Cargo.lock | 2 + crates/harness-app/Cargo.toml | 2 + crates/harness-app/src/lib.rs | 672 +++++++++++++++++- crates/harness-core/src/agent/mod.rs | 52 +- crates/harness-core/src/engine/jobs.rs | 28 +- crates/harness-core/src/engine/loop.rs | 156 ++++ crates/harness-core/src/engine/processor.rs | 17 +- crates/harness-core/src/permission/service.rs | 20 +- crates/harness-core/src/store/api.rs | 5 +- crates/harness-core/src/tool/mod.rs | 95 ++- crates/harness-tools/src/bash.rs | 1 + crates/harness-tools/src/edit/mod.rs | 1 + crates/harness-tools/src/glob.rs | 1 + crates/harness-tools/src/grep.rs | 1 + crates/harness-tools/src/lib.rs | 9 + crates/harness-tools/src/read.rs | 1 + crates/harness-tools/src/task.rs | 140 ++++ crates/harness-tools/src/write.rs | 1 + 18 files changed, 1137 insertions(+), 67 deletions(-) create mode 100644 crates/harness-tools/src/task.rs diff --git a/Cargo.lock b/Cargo.lock index 93c09c1..ce42c86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,7 +669,9 @@ dependencies = [ name = "harness-app" version = "0.1.0" dependencies = [ + "async-trait", "dirs", + "futures", "harness-core", "harness-lsp", "harness-mcp", diff --git a/crates/harness-app/Cargo.toml b/crates/harness-app/Cargo.toml index 2eb3ea2..0fd5f60 100644 --- a/crates/harness-app/Cargo.toml +++ b/crates/harness-app/Cargo.toml @@ -12,12 +12,14 @@ harness-mcp = { workspace = true } harness-lsp = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +async-trait = { workspace = true } dirs = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +futures = { workspace = true } [lints] workspace = true diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index 44073c2..7db5393 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -7,15 +7,18 @@ use std::collections::HashMap; use std::hash::Hasher; use std::path::Path; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::time::{SystemTime, UNIX_EPOCH}; +use async_trait::async_trait; +use harness_core::agent::{AgentDef, AgentRegistry}; use harness_core::config::{self, Config, ConfigError}; +use harness_core::engine::jobs::{JobBoard, JobState, LaunchSpec}; use harness_core::engine::{run_session, RunConfig, StepContext}; use harness_core::event::{AppEvent, EventBus, RunOutcome}; -use harness_core::permission::{PermissionReply, PermissionService}; +use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset}; use harness_core::store::{Store, StoreError}; -use harness_core::tool::ToolRegistry; +use harness_core::tool::{SpawnError, SpawnOutcome, SpawnRequest, SubagentSpawner, ToolRegistry}; use harness_core::types::{ Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId, }; @@ -84,9 +87,16 @@ struct EngineInner { tools: ToolRegistry, providers: ProviderRegistry, catalog: ModelCatalog, + agents: AgentRegistry, cwd: PathBuf, data_dir: PathBuf, runs: Mutex>, + /// One job board per session (scoped to that session as a parent). Lazily created and + /// shared between a session's run loop (board injection) and the spawner (registration). + boards: Mutex>>, + /// Self-reference so the spawner can hand an `Arc` to child contexts (for + /// nested delegation) and to detached background tasks. Set once in `new`. + me: OnceLock>, } struct RunHandle { @@ -111,11 +121,6 @@ impl EngineHandle { fn new(cwd: PathBuf, store: Store) -> Result { let config = config::load(&cwd)?; - let bus = EventBus::new(); - let permissions = Arc::new(PermissionService::new(bus.clone())); - - let mut tools = ToolRegistry::new(); - harness_tools::register_builtins(&mut tools); let mut providers = ProviderRegistry::new(); if let Some(key) = config @@ -141,6 +146,32 @@ impl EngineHandle { providers.register(Arc::new(provider)); } + Self::build(cwd, store, config, providers) + } + + /// Shared construction over an explicit provider set — the seam tests use to inject a mock. + fn build( + cwd: PathBuf, + store: Store, + config: Config, + providers: ProviderRegistry, + ) -> Result { + let bus = EventBus::new(); + let permissions = Arc::new(PermissionService::new(bus.clone())); + + let mut tools = ToolRegistry::new(); + harness_tools::register_builtins(&mut tools); + harness_tools::register_task_tool(&mut tools); + + // Layered agent registry: bundled markdown → global dir → project dir → config patches. + let global_agent_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("agent")); + let project_agent_dir = cwd.join(".harness").join("agent"); + let agents = AgentRegistry::load( + &config.agents, + global_agent_dir.as_deref(), + Some(project_agent_dir.as_path()), + ); + let data_dir = dirs::data_dir() .unwrap_or_else(std::env::temp_dir) .join("ai-harness") @@ -150,20 +181,23 @@ impl EngineHandle { // `init` warms the cache in the background for the next launch. let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path()); - Ok(Self { - inner: Arc::new(EngineInner { - config, - store, - bus, - permissions, - tools, - providers, - catalog, - cwd, - data_dir, - runs: Mutex::new(HashMap::new()), - }), - }) + let inner = Arc::new(EngineInner { + config, + store, + bus, + permissions, + tools, + providers, + catalog, + agents, + cwd, + data_dir, + runs: Mutex::new(HashMap::new()), + boards: Mutex::new(HashMap::new()), + me: OnceLock::new(), + }); + let _ = inner.me.set(Arc::downgrade(&inner)); + Ok(Self { inner }) } /// Fire-and-forget refresh of the models.dev cache so the next launch has current pricing. @@ -251,27 +285,43 @@ impl EngineHandle { .bus .publish(AppEvent::PartUpdated { part: user_part }); + // Resolve the session's agent from the registry (falls back to a generic assistant). + let session_agent = self + .inner + .store + .session(session_id.clone()) + .await? + .map(|s| s.agent) + .unwrap_or_else(|| "orchestrator".to_string()); + let agent = self.inner.agent_def(&session_agent); + let inject_job_board = self.inner.config.orchestration.job_board && agent.mode.is_primary(); + let board = self.inner.board_for(&session_id).await?; + let ctx = StepContext { store: self.inner.store.clone(), bus: self.inner.bus.clone(), - tools: self.inner.tools.clone(), + tools: self.inner.filtered_tools(&agent), permissions: self.inner.permissions.clone(), - static_rules: self.inner.config.permissions.clone(), + static_rules: self.inner.effective_static_rules(&agent, 0), extra_rules: Arc::new(Mutex::new(Vec::new())), + parent_rules: Vec::new(), // root session: no intersection session_id: session_id.clone(), cwd: self.inner.cwd.clone(), data_dir: self.inner.data_dir.join(session_id.to_string()), cancel: CancellationToken::new(), now, + spawner: Some(self.inner.clone()), + job_board: Some(board), }; let run_config = RunConfig { - agent_name: "orchestrator".to_string(), - agent_prompt: DEFAULT_AGENT_PROMPT.to_string(), + agent_name: agent.name.clone(), + agent_prompt: self.inner.agent_prompt(&agent), model, - temperature: None, - max_steps: DEFAULT_MAX_STEPS, + temperature: agent.temperature, + max_steps: agent.max_steps.unwrap_or(DEFAULT_MAX_STEPS), instructions: self.inner.config.instructions.clone(), cost: Some(self.inner.catalog.cost(provider_id, model_id)), + inject_job_board, }; // Reserve the run slot *before* spawning. If we inserted after spawning, a run that @@ -356,6 +406,388 @@ impl EngineHandle { } } +/// Deny rules injected as low-priority defaults for any session at depth > 0: a subagent may +/// not itself delegate (`task`) or manage todos unless config/agent rules re-enable it. +fn depth_guard_rules() -> Ruleset { + ["task", "todo", "todo*"] + .into_iter() + .map(|perm| Rule { + permission: perm.to_string(), + pattern: "*".to_string(), + action: harness_core::permission::Action::Deny, + }) + .collect() +} + +impl EngineInner { + fn arc(&self) -> Arc { + self.me + .get() + .and_then(Weak::upgrade) + .expect("EngineInner self-reference set in new()") + } + + /// Resolves an agent definition by name, falling back to a generic assistant so an + /// unknown/misconfigured agent still runs rather than failing the session. + fn agent_def(&self, name: &str) -> AgentDef { + self.agents.get(name).cloned().unwrap_or_else(|| AgentDef { + name: name.to_string(), + description: String::new(), + mode: harness_core::agent::AgentMode::Primary, + model: None, + temperature: None, + prompt: String::new(), + permissions: Vec::new(), + tools: std::collections::HashMap::new(), + max_steps: None, + source: harness_core::agent::AgentSource::Config, + }) + } + + /// A tool registry filtered to what `agent` enables. Exact tool names override a `*` + /// wildcard; absent = enabled. Honors the `tools:` frontmatter map (docs/04-multiagent.md). + fn filtered_tools(&self, agent: &AgentDef) -> ToolRegistry { + if agent.tools.is_empty() { + return self.tools.clone(); + } + let mut reg = ToolRegistry::new(); + for tool in self.tools.all() { + let name = tool.name(); + let enabled = agent + .tools + .get(name) + .or_else(|| agent.tools.get("*")) + .copied() + .unwrap_or(true); + if enabled { + reg.register(tool); + } + } + reg + } + + fn agent_prompt(&self, agent: &AgentDef) -> String { + if agent.prompt.trim().is_empty() { + DEFAULT_AGENT_PROMPT.to_string() + } else { + agent.prompt.clone() + } + } + + /// The ruleset a session evaluates its own tool calls against: low-priority depth guards + /// (depth > 0), then global config permissions, then the agent's own rules (highest). + fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset { + let mut rules = Ruleset::new(); + if depth > 0 { + rules.extend(depth_guard_rules()); + } + rules.extend(self.config.permissions.clone()); + rules.extend(agent.permissions.clone()); + rules + } + + /// The parent-effective ruleset a *child* intersects against: global config, the parent + /// agent's rules, then the parent session's `Always` grants. Frozen at spawn time. + fn parent_effective_rules(&self, session: &Session, agent: &AgentDef) -> Ruleset { + let mut rules = self.config.permissions.clone(); + rules.extend(agent.permissions.clone()); + rules.extend(session.extra_rules.clone()); + rules + } + + /// Gets (or lazily loads) the job board scoped to `session_id` as a parent. + async fn board_for(&self, session_id: &SessionId) -> Result, StoreError> { + if let Some(board) = self.boards.lock().unwrap().get(session_id).cloned() { + return Ok(board); + } + let board = Arc::new( + JobBoard::load( + self.store.clone(), + self.bus.clone(), + session_id, + self.config.orchestration.max_reusable_per_agent, + ) + .await?, + ); + // Double-check under lock in case a concurrent caller inserted first. + let mut boards = self.boards.lock().unwrap(); + Ok(boards.entry(session_id.clone()).or_insert(board).clone()) + } + + /// Appends `text` as a user message + text part to `session_id` (mirrors the root prompt + /// path so a child session's first turn has an instruction to act on). + async fn append_user_message( + &self, + session_id: &SessionId, + text: String, + now: i64, + ) -> Result<(), StoreError> { + let message = Message::new_user(session_id.clone(), now); + self.store.upsert_message(message.clone()).await?; + self.bus.publish(AppEvent::MessageCreated { + message: message.clone(), + }); + let part = Part { + id: PartId::new(), + message_id: message.id.clone(), + session_id: session_id.clone(), + idx: 0, + body: PartBody::Text { + text, + synthetic: false, + }, + }; + self.store.upsert_part(part.clone()).await?; + self.bus.publish(AppEvent::PartUpdated { part }); + Ok(()) + } + + /// Concatenates the `Text` parts of a session's last message — a child run's final answer. + async fn session_final_text(&self, session_id: &SessionId) -> String { + let Ok(messages) = self.store.messages(session_id.clone()).await else { + return String::new(); + }; + let Some(last) = messages.last() else { + return String::new(); + }; + let Ok(parts) = self.store.parts(last.id.clone()).await else { + return String::new(); + }; + parts + .iter() + .filter_map(|p| match &p.body { + PartBody::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + } + + /// Builds a child session's step context (permission intersection + its own board). + #[allow(clippy::too_many_arguments)] + fn child_ctx( + &self, + session_id: SessionId, + tools: ToolRegistry, + static_rules: Ruleset, + parent_rules: Ruleset, + extra_rules: Ruleset, + board: Arc, + cancel: CancellationToken, + now: i64, + ) -> StepContext { + StepContext { + store: self.store.clone(), + bus: self.bus.clone(), + tools, + permissions: self.permissions.clone(), + static_rules, + extra_rules: Arc::new(Mutex::new(extra_rules)), + parent_rules, + session_id: session_id.clone(), + cwd: self.cwd.clone(), + data_dir: self.data_dir.join(session_id.to_string()), + cancel, + now, + spawner: Some(self.arc()), + job_board: Some(board), + } + } +} + +#[async_trait] +impl SubagentSpawner for EngineInner { + async fn spawn(&self, req: SpawnRequest) -> Result { + let other = |e: StoreError| SpawnError::Other(e.to_string()); + + // 1. Load the parent and enforce the depth limit. + let parent = self + .store + .session(req.parent_session_id.clone()) + .await + .map_err(other)? + .ok_or_else(|| SpawnError::Other("parent session not found".into()))?; + let child_depth = parent.depth + 1; + if child_depth as u32 > self.config.orchestration.depth_limit { + return Err(SpawnError::DepthExceeded); + } + + // 2. Resolve the agent — must be usable as a subagent. + let agent = self + .agents + .get(&req.agent) + .filter(|a| a.mode.is_subagent()) + .cloned() + .ok_or_else(|| SpawnError::InvalidAgent(req.agent.clone()))?; + + // Parent-effective rules for permission intersection (frozen snapshot). + let parent_agent = self.agent_def(&parent.agent); + let parent_rules = self.parent_effective_rules(&parent, &parent_agent); + + let now = now_ms(); + let board = self + .board_for(&req.parent_session_id) + .await + .map_err(other)?; + + // 3. Reuse a completed child session by alias/id, or create a fresh one. + let (child_session, task_id, reused) = if let Some(alias) = &req.reuse_task_id { + let job = board + .resolve_reusable(&req.parent_session_id, alias) + .ok_or_else(|| SpawnError::ReuseNotFound(alias.clone()))?; + let child = self + .store + .session(job.child_session.clone()) + .await + .map_err(other)? + .ok_or_else(|| SpawnError::ReuseNotFound(alias.clone()))?; + let _ = board.touch(&job.task_id, now).await; + (child, job.task_id, true) + } else { + let mut child = Session::new_child(&parent, req.agent.clone(), now); + if let Some(model) = &agent.model { + child.model = model.clone(); + } + self.store + .upsert_session(child.clone()) + .await + .map_err(other)?; + self.bus.publish(AppEvent::SessionCreated { + session: child.clone(), + }); + let task_id = child.id.to_string(); + (child, task_id, false) + }; + let child_id = child_session.id.clone(); + + // 4. Append the instruction as the child's next user turn. + self.append_user_message(&child_id, req.prompt.clone(), now) + .await + .map_err(other)?; + + // 5. Resolve the provider for the child model. + let provider = self + .providers + .get(&child_session.model.provider_id) + .ok_or_else(|| { + SpawnError::Other(format!( + "no provider registered for {:?}", + child_session.model.provider_id + )) + })?; + + let child_static = self.effective_static_rules(&agent, child_depth); + let child_tools = self.filtered_tools(&agent); + let child_board = self.board_for(&child_id).await.map_err(other)?; + let run_config = RunConfig { + agent_name: agent.name.clone(), + agent_prompt: self.agent_prompt(&agent), + model: child_session.model.clone(), + temperature: agent.temperature, + max_steps: agent.max_steps.unwrap_or(DEFAULT_MAX_STEPS), + instructions: self.config.instructions.clone(), + cost: Some(self.catalog.cost( + &child_session.model.provider_id, + &child_session.model.model_id, + )), + inject_job_board: agent.mode.is_primary(), + }; + + // Register the launch on the parent board (also makes foreground results reusable). + if !reused { + let _ = board + .register_launch( + LaunchSpec { + task_id: task_id.clone(), + parent_session: req.parent_session_id.clone(), + child_session: child_id.clone(), + agent: req.agent.clone(), + description: req.description.clone(), + objective: Some(req.description.clone()), + }, + now, + ) + .await; + } + + if req.background { + // Child of the parent session's *run* token so the job dies with the session, not + // when the (already-returned) tool call completes. + let cancel = { + let runs = self.runs.lock().unwrap(); + runs.get(&req.parent_session_id) + .map(|r| r.cancel.child_token()) + } + .unwrap_or_default(); + + let ctx = self.child_ctx( + child_id.clone(), + child_tools, + child_static, + parent_rules, + child_session.extra_rules.clone(), + child_board, + cancel, + now, + ); + let this = self.arc(); + let board_bg = board.clone(); + let task_id_bg = task_id.clone(); + let child_id_bg = child_id.clone(); + tokio::spawn(async move { + let outcome = run_session(provider, ctx, &run_config, now_ms).await; + let (state, summary) = match &outcome { + RunOutcome::Stopped => ( + JobState::Completed, + Some(this.session_final_text(&child_id_bg).await), + ), + RunOutcome::Aborted => (JobState::Cancelled, None), + RunOutcome::Errored { message } => (JobState::Error, Some(message.clone())), + }; + let _ = board_bg.finish(&task_id_bg, state, summary, now_ms()).await; + }); + + let alias = board + .snapshot() + .into_iter() + .find(|j| j.task_id == task_id) + .map(|j| j.alias); + Ok(SpawnOutcome { + child_session_id: child_id, + background: true, + alias, + final_text: None, + }) + } else { + // Foreground: run to completion under the tool call's token and return the text. + let ctx = self.child_ctx( + child_id.clone(), + child_tools, + child_static, + parent_rules, + child_session.extra_rules.clone(), + child_board, + req.cancel.clone(), + now, + ); + let outcome = run_session(provider, ctx, &run_config, now_ms).await; + let final_text = self.session_final_text(&child_id).await; + let (state, summary) = match &outcome { + RunOutcome::Stopped => (JobState::Completed, Some(final_text.clone())), + RunOutcome::Aborted => (JobState::Cancelled, None), + RunOutcome::Errored { message } => (JobState::Error, Some(message.clone())), + }; + let _ = board.finish(&task_id, state, summary, now_ms()).await; + Ok(SpawnOutcome { + child_session_id: child_id, + background: false, + alias: None, + final_text: Some(final_text), + }) + } + } +} + pub struct App { engine: EngineHandle, // Keeps the auto-approve task alive for the lifetime of the App. @@ -448,13 +880,195 @@ fn spawn_auto_approve_task(engine: &EngineHandle) -> JoinHandle<()> { mod tests { use super::*; + use async_trait::async_trait; + use harness_core::llm::{ + FinishReason, LlmEvent, LlmEventStream, LlmRequest, Provider, ProviderError, + }; + use harness_core::types::{ModelInfo, TokenUsage}; + + /// Emits a single final-text turn for every stream call — enough to drive a child + /// subagent session to completion deterministically. + struct MockProvider; + + #[async_trait] + impl Provider for MockProvider { + fn id(&self) -> &str { + "mock" + } + async fn list_models(&self) -> Result, ProviderError> { + Ok(vec![]) + } + async fn stream( + &self, + _req: LlmRequest, + _cancel: CancellationToken, + ) -> Result { + let events = vec![ + Ok(LlmEvent::TextStart { id: "t".into() }), + Ok(LlmEvent::TextDelta { + id: "t".into(), + text: "explored the code".into(), + }), + Ok(LlmEvent::TextEnd { id: "t".into() }), + Ok(LlmEvent::Finish { + reason: FinishReason::Stop, + usage: TokenUsage::default(), + }), + ]; + Ok(Box::pin(futures::stream::iter(events))) + } + } + + fn mock_engine(cwd: PathBuf) -> EngineHandle { + let mut providers = ProviderRegistry::new(); + providers.register(Arc::new(MockProvider)); + EngineHandle::build( + cwd, + Store::open_in_memory().unwrap(), + Config::default(), + providers, + ) + .unwrap() + } + + #[tokio::test] + async fn foreground_task_runs_a_subagent_and_alias_reuse_continues_the_same_session() { + let dir = tempfile::tempdir().unwrap(); + let engine = mock_engine(dir.path().to_path_buf()); + let root = engine + .new_session("orchestrator", "mock/mock-model") + .await + .unwrap(); + + // Foreground spawn of the explorer subagent. + let out = engine + .inner + .spawn(SpawnRequest { + parent_session_id: root.clone(), + parent_message_id: MessageId::new(), + agent: "explorer".into(), + description: "map the auth flow".into(), + prompt: "find the login handler".into(), + reuse_task_id: None, + background: false, + cancel: CancellationToken::new(), + }) + .await + .unwrap(); + assert!(!out.background); + assert_eq!(out.final_text.as_deref(), Some("explored the code")); + let child_id = out.child_session_id.clone(); + + // The completed job is on the parent board, reusable by alias. + let board = engine.inner.board_for(&root).await.unwrap(); + let job = board + .snapshot() + .into_iter() + .find(|j| j.child_session == child_id) + .expect("job registered"); + assert_eq!(job.alias, "exp-1"); + assert_eq!(job.state, JobState::Completed); + + let child_messages_before = engine + .inner + .store + .messages(child_id.clone()) + .await + .unwrap() + .len(); + + // Reuse by alias → same child session, with the new prompt appended. + let reused = engine + .inner + .spawn(SpawnRequest { + parent_session_id: root.clone(), + parent_message_id: MessageId::new(), + agent: "explorer".into(), + description: "follow up".into(), + prompt: "now find the logout handler".into(), + reuse_task_id: Some("exp-1".into()), + background: false, + cancel: CancellationToken::new(), + }) + .await + .unwrap(); + assert_eq!( + reused.child_session_id, child_id, + "reuse must continue the same child session" + ); + + let child_messages_after = engine.inner.store.messages(child_id).await.unwrap().len(); + assert!( + child_messages_after > child_messages_before, + "reused session should have the follow-up turn appended ({child_messages_before} -> {child_messages_after})" + ); + } + + #[tokio::test] + async fn depth_limit_is_enforced() { + let dir = tempfile::tempdir().unwrap(); + let engine = mock_engine(dir.path().to_path_buf()); + // Build a session already at the depth limit so any child would exceed it. + let now = now_ms(); + let mut deep = Session::new_root("explorer", ModelRef::new("mock", "mock-model"), now); + deep.depth = engine.inner.config.orchestration.depth_limit as u8; + engine + .inner + .store + .upsert_session(deep.clone()) + .await + .unwrap(); + + let err = engine + .inner + .spawn(SpawnRequest { + parent_session_id: deep.id.clone(), + parent_message_id: MessageId::new(), + agent: "explorer".into(), + description: "too deep".into(), + prompt: "go deeper".into(), + reuse_task_id: None, + background: false, + cancel: CancellationToken::new(), + }) + .await + .unwrap_err(); + assert!(matches!(err, SpawnError::DepthExceeded)); + } + + #[tokio::test] + async fn unknown_subagent_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let engine = mock_engine(dir.path().to_path_buf()); + let root = engine + .new_session("orchestrator", "mock/mock-model") + .await + .unwrap(); + let err = engine + .inner + .spawn(SpawnRequest { + parent_session_id: root, + parent_message_id: MessageId::new(), + agent: "nonexistent".into(), + description: "x".into(), + prompt: "y".into(), + reuse_task_id: None, + background: false, + cancel: CancellationToken::new(), + }) + .await + .unwrap_err(); + assert!(matches!(err, SpawnError::InvalidAgent(_))); + } + #[tokio::test] async fn init_loads_defaults_with_no_providers_configured() { let dir = tempfile::tempdir().unwrap(); std::env::remove_var("ANTHROPIC_API_KEY"); let app = App::init_in_memory(dir.path().to_path_buf()).unwrap(); assert!(app.engine.inner.providers.get("anthropic").is_none()); - assert_eq!(app.engine.inner.tools.all().len(), 6); + // 6 built-ins + the multiagent `task` tool. + assert_eq!(app.engine.inner.tools.all().len(), 7); } #[tokio::test] diff --git a/crates/harness-core/src/agent/mod.rs b/crates/harness-core/src/agent/mod.rs index 9037b2f..c8f7196 100644 --- a/crates/harness-core/src/agent/mod.rs +++ b/crates/harness-core/src/agent/mod.rs @@ -18,10 +18,16 @@ use crate::types::ModelRef; const SUBAGENTS_MARKER: &str = "{{SUBAGENTS}}"; const BUNDLED: &[(&str, &str)] = &[ - ("orchestrator", include_str!("../../assets/agents/orchestrator.md")), + ( + "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")), + ( + "librarian", + include_str!("../../assets/agents/librarian.md"), + ), ("fixer", include_str!("../../assets/agents/fixer.md")), ("designer", include_str!("../../assets/agents/designer.md")), ]; @@ -107,7 +113,10 @@ fn parse_model_ref(s: &str) -> Option { /// 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")) { + let rest = match content + .strip_prefix("---\n") + .or_else(|| content.strip_prefix("---\r\n")) + { Some(r) => r, None => return ("", content), }; @@ -149,7 +158,11 @@ fn parse_agent( 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(), + 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(), @@ -207,7 +220,10 @@ impl AgentRegistry { /// 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.load_markdown_layer( + BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)), + AgentSource::Bundled, + ); reg.generate_routing(); reg } @@ -338,7 +354,14 @@ mod tests { #[test] fn bundled_loads_all_six_agents() { let reg = AgentRegistry::bundled(); - for name in ["orchestrator", "explorer", "oracle", "librarian", "fixer", "designer"] { + for name in [ + "orchestrator", + "explorer", + "oracle", + "librarian", + "fixer", + "designer", + ] { assert!(reg.get(name).is_some(), "missing {name}"); } assert_eq!(reg.len(), 6); @@ -356,10 +379,15 @@ mod tests { \x20 - { permission: \"edit\", pattern: \"*\", action: deny }\n\ ---\n\ You are a test agent.\n"; - let def = parse_agent("tester", AgentSource::Bundled, md).unwrap().unwrap(); + 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.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)); @@ -379,9 +407,11 @@ mod tests { #[test] fn disable_true_removes_agent() { - assert!(parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody") - .unwrap() - .is_none()); + assert!( + parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody") + .unwrap() + .is_none() + ); } #[test] diff --git a/crates/harness-core/src/engine/jobs.rs b/crates/harness-core/src/engine/jobs.rs index f2efa67..1f49a02 100644 --- a/crates/harness-core/src/engine/jobs.rs +++ b/crates/harness-core/src/engine/jobs.rs @@ -413,15 +413,24 @@ mod tests { async fn alias_increments_per_agent() { let (_store, board, parent) = board(2).await; let a1 = board - .register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1) + .register_launch( + spec("t1", parent.clone(), SessionId::new(), "explorer", None), + 1, + ) .await .unwrap(); let a2 = board - .register_launch(spec("t2", parent.clone(), SessionId::new(), "explorer", None), 2) + .register_launch( + spec("t2", parent.clone(), SessionId::new(), "explorer", None), + 2, + ) .await .unwrap(); let f1 = board - .register_launch(spec("t3", parent.clone(), SessionId::new(), "fixer", None), 3) + .register_launch( + spec("t3", parent.clone(), SessionId::new(), "fixer", None), + 3, + ) .await .unwrap(); assert_eq!(a1, "exp-1"); @@ -492,7 +501,13 @@ mod tests { for (i, ts) in [(1, 10), (2, 20), (3, 30)] { board .register_launch( - spec(&format!("t{i}"), parent.clone(), SessionId::new(), "explorer", None), + spec( + &format!("t{i}"), + parent.clone(), + SessionId::new(), + "explorer", + None, + ), ts, ) .await @@ -543,7 +558,10 @@ mod tests { .await .unwrap(); board - .register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1) + .register_launch( + spec("t1", parent.clone(), SessionId::new(), "explorer", None), + 1, + ) .await .unwrap(); } diff --git a/crates/harness-core/src/engine/loop.rs b/crates/harness-core/src/engine/loop.rs index 2b8423a..200358a 100644 --- a/crates/harness-core/src/engine/loop.rs +++ b/crates/harness-core/src/engine/loop.rs @@ -22,6 +22,8 @@ pub struct RunConfig { pub instructions: Vec, /// Pricing for `model`, from models.dev metadata. `None` leaves cost at 0. pub cost: Option, + /// Whether to append the background job board to requests (primary/delegating agents). + pub inject_job_board: bool, } /// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is @@ -198,6 +200,22 @@ pub async fn run_session( wire_messages.extend(convert_message(message, &parts)); } + // Append the (synthetic, non-persisted) job board to the last user message so the + // orchestrator sees running/reusable subtasks. docs/04-multiagent.md. + if run_config.inject_job_board { + if let Some(board) = &ctx.job_board { + if let Some(block) = board.format_for_prompt() { + if let Some(last_user) = wire_messages + .iter_mut() + .rev() + .find(|m| m.role == WireRole::User) + { + last_user.content.push(WireContent::Text { text: block }); + } + } + } + } + let system_blocks = system::assemble( system::env_header(&ctx.cwd), &run_config.agent_prompt, @@ -260,6 +278,15 @@ pub async fn run_session( } Ok(outcome) => { accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await; + // A completed step means the orchestrator has now seen any terminal jobs + // that were on the board this turn; mark them reconciled. + if run_config.inject_job_board { + if let Some(board) = &ctx.job_board { + if let Err(e) = board.reconcile_terminal(now_fn()).await { + tracing::warn!(error = %e, "failed to reconcile job board"); + } + } + } match outcome.result { StepResult::Continue => continue, StepResult::Stop => return RunOutcome::Stopped, @@ -376,11 +403,14 @@ mod tests { permissions, static_rules: Vec::new(), extra_rules: Arc::new(std::sync::Mutex::new(Vec::new())), + parent_rules: Vec::new(), session_id, cwd: cwd.clone(), data_dir: cwd.join("tool-output"), cancel: CancellationToken::new(), now: 1, + spawner: None, + job_board: None, } } @@ -462,6 +492,7 @@ mod tests { output: 15.0, ..Default::default() }), + inject_job_board: false, }; let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await; @@ -569,6 +600,7 @@ mod tests { max_steps: 10, instructions: Vec::new(), cost: None, + inject_job_board: false, }; let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await; @@ -578,4 +610,128 @@ mod tests { let messages = store.messages(session_id).await.unwrap(); assert_eq!(messages.len(), 1); } + + /// Records the last request it was asked to stream so tests can assert on prompt content. + struct CapturingProvider { + last: StdMutex>, + } + + #[async_trait] + impl Provider for CapturingProvider { + fn id(&self) -> &str { + "mock" + } + async fn list_models(&self) -> Result, ProviderError> { + Ok(vec![]) + } + async fn stream( + &self, + req: LlmRequest, + _cancel: CancellationToken, + ) -> Result { + *self.last.lock().unwrap() = Some(req); + let events = vec![ + Ok(LlmEvent::TextStart { id: "t".into() }), + Ok(LlmEvent::TextDelta { + id: "t".into(), + text: "ok".into(), + }), + Ok(LlmEvent::TextEnd { id: "t".into() }), + Ok(LlmEvent::Finish { + reason: FinishReason::Stop, + usage: usage(1, 1), + }), + ]; + Ok(Box::pin(futures::stream::iter(events))) + } + } + + #[tokio::test] + async fn job_board_is_injected_into_the_last_user_message() { + use crate::engine::jobs::{JobBoard, LaunchSpec}; + + let store = Store::open_in_memory().unwrap(); + let bus = EventBus::new(); + let model = ModelRef::new("mock", "mock-model"); + let session = Session::new_root("orchestrator", model.clone(), 1); + let session_id = session.id.clone(); + store.upsert_session(session).await.unwrap(); + + let user_message = Message::new_user(session_id.clone(), 1); + store.upsert_message(user_message.clone()).await.unwrap(); + store + .upsert_part(Part { + id: crate::types::PartId::new(), + message_id: user_message.id.clone(), + session_id: session_id.clone(), + idx: 0, + body: PartBody::Text { + text: "carry on".into(), + synthetic: false, + }, + }) + .await + .unwrap(); + + // A board with one running job for this session. + let board = std::sync::Arc::new( + JobBoard::load(store.clone(), bus.clone(), &session_id, 2) + .await + .unwrap(), + ); + board + .register_launch( + LaunchSpec { + task_id: "t1".into(), + parent_session: session_id.clone(), + child_session: SessionId::new(), + agent: "explorer".into(), + description: "map auth".into(), + objective: Some("map the auth flow".into()), + }, + 1, + ) + .await + .unwrap(); + + let cwd = tempfile::tempdir().unwrap(); + let mut ctx = make_ctx(store, bus, session_id, cwd.path().to_path_buf()).await; + ctx.job_board = Some(board); + let run_config = RunConfig { + agent_name: "orchestrator".into(), + agent_prompt: "You orchestrate.".into(), + model, + temperature: None, + max_steps: 1, + instructions: Vec::new(), + cost: None, + inject_job_board: true, + }; + + let provider = std::sync::Arc::new(CapturingProvider { + last: StdMutex::new(None), + }); + let outcome = run_session(provider.clone(), ctx, &run_config, || 2).await; + assert!(matches!(outcome, RunOutcome::Stopped)); + + let req = provider.last.lock().unwrap().clone().expect("a request"); + let last_user = req + .messages + .iter() + .rev() + .find(|m| m.role == WireRole::User) + .expect("a user message"); + let text: String = last_user + .content + .iter() + .filter_map(|c| match c { + WireContent::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + assert!(text.contains("Background Job Board"), "got: {text}"); + assert!(text.contains("exp-1"), "got: {text}"); + assert!(text.contains("map the auth flow"), "got: {text}"); + } } diff --git a/crates/harness-core/src/engine/processor.rs b/crates/harness-core/src/engine/processor.rs index bdc2839..89befed 100644 --- a/crates/harness-core/src/engine/processor.rs +++ b/crates/harness-core/src/engine/processor.rs @@ -10,10 +10,13 @@ use crate::event::{AppEvent, EventBus}; use crate::llm::{FinishReason, LlmEvent, LlmEventStream, ProviderError}; use crate::permission::{PermissionService, Ruleset}; use crate::store::Store; -use crate::tool::{MetadataSink, PermissionHandle, Tool, ToolCtx, ToolError, ToolRegistry}; +use crate::tool::{ + MetadataSink, PermissionHandle, SubagentSpawner, Tool, ToolCtx, ToolError, ToolRegistry, +}; use crate::types::{Message, MessageId, Part, PartBody, PartId, SessionId, TokenUsage, ToolState}; use super::doomloop::DoomLoopGuard; +use super::jobs::JobBoard; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StepResult { @@ -47,6 +50,9 @@ pub struct StepContext { pub permissions: Arc, pub static_rules: Ruleset, pub extra_rules: Arc>, + /// Parent-effective ruleset for a subagent session; empty for a root session. Enables + /// permission intersection on this session's tool calls. + pub parent_rules: Ruleset, pub session_id: SessionId, pub cwd: PathBuf, /// Session's `tool-output` spill directory (see `tool::truncate`). @@ -54,6 +60,11 @@ pub struct StepContext { pub cancel: CancellationToken, /// Wall-clock for `created_at` stamps — passed in so tests stay deterministic. pub now: i64, + /// Lets the `task` tool spawn subagents. `None` disables delegation (headless/tests). + pub spawner: Option>, + /// This session's background job board (as a parent). Injected into requests when the + /// running agent can delegate; `None` disables the board. + pub job_board: Option>, } struct FlushTracker { @@ -483,7 +494,8 @@ impl<'a> Run<'a> { self.ctx.static_rules.clone(), self.ctx.extra_rules.clone(), call_cancel.clone(), - ); + ) + .with_parent_rules(self.ctx.parent_rules.clone()); let tool_ctx = ToolCtx { session_id: self.ctx.session_id.clone(), message_id: self.message_id(), @@ -493,6 +505,7 @@ impl<'a> Run<'a> { cancel: call_cancel.clone(), ask, metadata: metadata_sink, + spawner: self.ctx.spawner.clone(), }; let result = tokio::select! { diff --git a/crates/harness-core/src/permission/service.rs b/crates/harness-core/src/permission/service.rs index f4dc957..2cb48fa 100644 --- a/crates/harness-core/src/permission/service.rs +++ b/crates/harness-core/src/permission/service.rs @@ -8,7 +8,7 @@ use ulid::Ulid; use crate::event::{AppEvent, EventBus, PermissionRequest}; use crate::types::SessionId; -use super::rule::{evaluate, Action, Rule, Ruleset}; +use super::rule::{evaluate, evaluate_intersected, Action, Rule, Ruleset}; pub struct AskInput { pub permission: String, @@ -73,6 +73,24 @@ impl PermissionService { } } + /// Like [`ask`](Self::ask), but for a subagent: the verdict is the more restrictive of + /// the `parent_stack` (rules inherited from the spawning chain) and `child_stack` (the + /// subagent's own rules). Used so a child can never widen what its parent forbids. + pub async fn ask_intersected( + &self, + session_id: &SessionId, + parent_stack: &[&Ruleset], + child_stack: &[&Ruleset], + input: AskInput, + cancel: &CancellationToken, + ) -> Result { + match evaluate_intersected(parent_stack, child_stack, &input.permission, &input.pattern) { + Action::Allow => Ok(AskDecision::Allowed), + Action::Deny => Err(AskError::Denied), + Action::Ask => self.ask_user(session_id, input, cancel).await, + } + } + /// Bypasses ruleset evaluation entirely — used by the doom-loop guard, which must ask /// regardless of any `Allow` rule. pub async fn force_ask( diff --git a/crates/harness-core/src/store/api.rs b/crates/harness-core/src/store/api.rs index a1694c1..db6abf8 100644 --- a/crates/harness-core/src/store/api.rs +++ b/crates/harness-core/src/store/api.rs @@ -104,10 +104,7 @@ impl Store { self.call(|reply| StoreCmd::DeleteJob(task_id, reply)).await } - pub async fn jobs_for_parent( - &self, - parent: SessionId, - ) -> Result, StoreError> { + pub async fn jobs_for_parent(&self, parent: SessionId) -> Result, StoreError> { self.call(|reply| StoreCmd::JobsForParent(parent, reply)) .await } diff --git a/crates/harness-core/src/tool/mod.rs b/crates/harness-core/src/tool/mod.rs index 92a9433..685624e 100644 --- a/crates/harness-core/src/tool/mod.rs +++ b/crates/harness-core/src/tool/mod.rs @@ -10,6 +10,50 @@ use tokio_util::sync::CancellationToken; use crate::permission::{AskDecision, AskError, AskInput, PermissionService, Ruleset}; use crate::types::{MessageId, SessionId}; +/// A request from the `task` tool to run a subagent. The spawner (owned by the composition +/// root) resolves the agent, enforces the depth limit, applies permission intersection, and +/// runs the child session foreground or background. See `docs/04-multiagent.md`. +pub struct SpawnRequest { + pub parent_session_id: SessionId, + pub parent_message_id: MessageId, + pub agent: String, + pub description: String, + pub prompt: String, + /// Alias or task id of a completed job to reuse (continue its child session). + pub reuse_task_id: Option, + pub background: bool, + /// The tool call's cancellation token — used for foreground child runs. Background runs + /// are childed from the parent session's run token by the spawner instead. + pub cancel: CancellationToken, +} + +#[derive(Debug)] +pub struct SpawnOutcome { + pub child_session_id: SessionId, + pub background: bool, + /// Board alias assigned to a background launch. + pub alias: Option, + /// Final assistant text of a foreground run. + pub final_text: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum SpawnError { + #[error("unknown subagent {0:?}, or it is not usable as a subagent")] + InvalidAgent(String), + #[error("subagent depth limit reached — do this work yourself instead of delegating further")] + DepthExceeded, + #[error("cannot reuse {0:?}: no completed job with that alias for this session")] + ReuseNotFound(String), + #[error("{0}")] + Other(String), +} + +#[async_trait] +pub trait SubagentSpawner: Send + Sync { + async fn spawn(&self, req: SpawnRequest) -> Result; +} + #[derive(Debug, thiserror::Error)] pub enum ToolError { #[error("permission denied")] @@ -60,6 +104,9 @@ pub struct PermissionHandle { session_id: SessionId, static_rules: Ruleset, extra_rules: Arc>, + /// Parent-effective ruleset for a subagent session; empty for a root session. When + /// non-empty, verdicts are intersected so a child can only ever be *more* restricted. + parent_rules: Ruleset, cancel: CancellationToken, } @@ -76,10 +123,17 @@ impl PermissionHandle { session_id, static_rules, extra_rules, + parent_rules: Vec::new(), cancel, } } + /// Sets the parent-effective ruleset so this handle intersects verdicts (subagent runs). + pub fn with_parent_rules(mut self, parent_rules: Ruleset) -> Self { + self.parent_rules = parent_rules; + self + } + pub async fn ask( &self, permission: impl Into, @@ -88,21 +142,29 @@ impl PermissionHandle { metadata: serde_json::Value, ) -> Result<(), ToolError> { let extra_snapshot = self.extra_rules.lock().unwrap().clone(); - let stack: [&Ruleset; 2] = [&self.static_rules, &extra_snapshot]; - let decision = self - .service - .ask( - &self.session_id, - &stack, - AskInput { - permission: permission.into(), - pattern: pattern.into(), - always_pattern: always_pattern.into(), - metadata, - }, - &self.cancel, - ) - .await?; + let child_stack: [&Ruleset; 2] = [&self.static_rules, &extra_snapshot]; + let input = AskInput { + permission: permission.into(), + pattern: pattern.into(), + always_pattern: always_pattern.into(), + metadata, + }; + let decision = if self.parent_rules.is_empty() { + self.service + .ask(&self.session_id, &child_stack, input, &self.cancel) + .await? + } else { + let parent_stack: [&Ruleset; 1] = [&self.parent_rules]; + self.service + .ask_intersected( + &self.session_id, + &parent_stack, + &child_stack, + input, + &self.cancel, + ) + .await? + }; if let AskDecision::AllowedAlways(rule) = decision { self.extra_rules.lock().unwrap().push(rule); } @@ -120,6 +182,9 @@ pub struct ToolCtx { pub cancel: CancellationToken, pub ask: PermissionHandle, pub metadata: MetadataSink, + /// Present when the engine can spawn subagents (the `task` tool's capability). `None` + /// in headless/test contexts with no orchestration wired in. + pub spawner: Option>, } #[derive(Debug)] diff --git a/crates/harness-tools/src/bash.rs b/crates/harness-tools/src/bash.rs index 257c39d..0c3f831 100644 --- a/crates/harness-tools/src/bash.rs +++ b/crates/harness-tools/src/bash.rs @@ -149,6 +149,7 @@ mod tests { CancellationToken::new(), ), metadata, + spawner: None, } } diff --git a/crates/harness-tools/src/edit/mod.rs b/crates/harness-tools/src/edit/mod.rs index 5e53fb8..6cd140a 100644 --- a/crates/harness-tools/src/edit/mod.rs +++ b/crates/harness-tools/src/edit/mod.rs @@ -232,6 +232,7 @@ mod tests { CancellationToken::new(), ), metadata, + spawner: None, } } diff --git a/crates/harness-tools/src/glob.rs b/crates/harness-tools/src/glob.rs index 2e6bdcb..44b59db 100644 --- a/crates/harness-tools/src/glob.rs +++ b/crates/harness-tools/src/glob.rs @@ -130,6 +130,7 @@ mod tests { CancellationToken::new(), ), metadata, + spawner: None, } } diff --git a/crates/harness-tools/src/grep.rs b/crates/harness-tools/src/grep.rs index f9556ae..012f33c 100644 --- a/crates/harness-tools/src/grep.rs +++ b/crates/harness-tools/src/grep.rs @@ -159,6 +159,7 @@ mod tests { CancellationToken::new(), ), metadata, + spawner: None, } } diff --git a/crates/harness-tools/src/lib.rs b/crates/harness-tools/src/lib.rs index 211e25b..175d96a 100644 --- a/crates/harness-tools/src/lib.rs +++ b/crates/harness-tools/src/lib.rs @@ -4,6 +4,7 @@ mod glob; mod grep; mod paths; mod read; +mod task; mod write; pub use bash::BashTool; @@ -11,6 +12,7 @@ pub use edit::EditTool; pub use glob::GlobTool; pub use grep::GrepTool; pub use read::ReadTool; +pub use task::TaskTool; pub use write::WriteTool; use std::sync::Arc; @@ -26,3 +28,10 @@ pub fn register_builtins(registry: &mut ToolRegistry) { registry.register(Arc::new(GlobTool)); registry.register(Arc::new(GrepTool)); } + +/// Registers the multiagent `task` tool (M4). Kept separate from [`register_builtins`] so +/// non-orchestrating contexts can omit delegation; the tool no-ops with an error if the +/// session has no spawner wired in. +pub fn register_task_tool(registry: &mut ToolRegistry) { + registry.register(Arc::new(TaskTool)); +} diff --git a/crates/harness-tools/src/read.rs b/crates/harness-tools/src/read.rs index 5e20a6c..4ba2aa3 100644 --- a/crates/harness-tools/src/read.rs +++ b/crates/harness-tools/src/read.rs @@ -139,6 +139,7 @@ mod tests { CancellationToken::new(), ), metadata, + spawner: None, } } diff --git a/crates/harness-tools/src/task.rs b/crates/harness-tools/src/task.rs new file mode 100644 index 0000000..e934cc0 --- /dev/null +++ b/crates/harness-tools/src/task.rs @@ -0,0 +1,140 @@ +//! The `task` tool: delegate work to a specialist subagent, foreground or background. +//! +//! This tool is deliberately thin — it validates input, gates on a `task/` permission, +//! and hands off to the engine's `SubagentSpawner` (owned by the composition root), which +//! resolves the agent, enforces the depth limit, applies permission intersection, and runs +//! the child session. See `docs/04-multiagent.md`. + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; + +use harness_core::tool::{ + invalid_input, SpawnError, SpawnRequest, Tool, ToolCtx, ToolError, ToolOutput, +}; + +#[derive(Debug, Deserialize)] +struct TaskInput { + /// Short human-facing label for the subtask (shown on the job board). + description: String, + /// The full instruction handed to the subagent. + prompt: String, + /// Which specialist to run (must be a subagent-capable agent). + subagent_type: String, + /// Alias or task id of a completed job to continue instead of starting fresh. + #[serde(default)] + task_id: Option, + /// Run in the background and return immediately (tracked on the job board). + #[serde(default)] + background: bool, +} + +pub struct TaskTool; + +#[async_trait] +impl Tool for TaskTool { + fn name(&self) -> &str { + "task" + } + + fn description(&self) -> &str { + "Delegate a self-contained unit of work to a specialist subagent. Set `background: \ + true` to launch it without blocking (track progress on the job board); reuse a \ + completed subagent by passing its `task_id`/alias to continue the same session." + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "description": { "type": "string", "description": "Short label for the subtask." }, + "prompt": { "type": "string", "description": "Full instruction for the subagent." }, + "subagent_type": { "type": "string", "description": "Specialist to run." }, + "task_id": { "type": "string", "description": "Alias/id of a completed job to reuse." }, + "background": { "type": "boolean", "description": "Launch without blocking." } + }, + "required": ["description", "prompt", "subagent_type"] + }) + } + + async fn execute( + &self, + input: serde_json::Value, + ctx: ToolCtx, + ) -> Result { + let args: TaskInput = serde_json::from_value(input).map_err(|e| invalid_input(self, e))?; + + let Some(spawner) = ctx.spawner.clone() else { + return Err(ToolError::Other( + "subagent delegation is not available in this session".into(), + )); + }; + + // Gate on task/. `Always` grants blanket delegation to this specialist. + ctx.ask + .ask( + "task", + &args.subagent_type, + &args.subagent_type, + json!({ + "agent": args.subagent_type, + "description": args.description, + "background": args.background, + }), + ) + .await?; + + let req = SpawnRequest { + parent_session_id: ctx.session_id.clone(), + parent_message_id: ctx.message_id.clone(), + agent: args.subagent_type.clone(), + description: args.description.clone(), + prompt: args.prompt, + reuse_task_id: args.task_id, + background: args.background, + cancel: ctx.cancel.clone(), + }; + + let outcome = spawner.spawn(req).await.map_err(map_spawn_error)?; + + if outcome.background { + let alias = outcome.alias.unwrap_or_default(); + Ok(ToolOutput { + title: format!("launched {} ({alias})", args.subagent_type), + output: format!( + "Launched background task {alias} ({}). Check the job board; do not poll — \ + wait for completion.", + outcome.child_session_id + ), + metadata: json!({ + "child_session": outcome.child_session_id, + "agent": args.subagent_type, + "alias": alias, + "background": true, + }), + }) + } else { + let text = outcome.final_text.unwrap_or_default(); + Ok(ToolOutput { + title: format!("{} — {}", args.subagent_type, args.description), + output: text, + metadata: json!({ + "child_session": outcome.child_session_id, + "agent": args.subagent_type, + "background": false, + }), + }) + } + } +} + +fn map_spawn_error(err: SpawnError) -> ToolError { + match err { + // Depth/agent problems are the model's to fix — surface as tool errors it can read + // and act on, not hard failures. + SpawnError::DepthExceeded => ToolError::Other(err.to_string()), + SpawnError::InvalidAgent(_) => ToolError::Invalid(err.to_string()), + SpawnError::ReuseNotFound(_) => ToolError::Invalid(err.to_string()), + SpawnError::Other(msg) => ToolError::Other(msg), + } +} diff --git a/crates/harness-tools/src/write.rs b/crates/harness-tools/src/write.rs index a3268db..7011030 100644 --- a/crates/harness-tools/src/write.rs +++ b/crates/harness-tools/src/write.rs @@ -114,6 +114,7 @@ mod tests { CancellationToken::new(), ), metadata, + spawner: None, } } -- 2.54.0 From ac516a7e2331cc88ef2ef2e7980e2ba132e651d1 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:34:04 +0200 Subject: [PATCH 4/6] M4: subagent context-file reporting to the job board Lets subagents report context files back to the job board (surfaced from the read tool) so the parent session's next-turn context includes what a background child has been looking at. --- Cargo.lock | 1 + crates/harness-app/Cargo.toml | 1 + crates/harness-app/src/lib.rs | 157 +++++++++++++++++++- crates/harness-core/src/engine/loop.rs | 1 + crates/harness-core/src/engine/processor.rs | 7 +- crates/harness-core/src/tool/mod.rs | 10 ++ crates/harness-tools/src/bash.rs | 1 + crates/harness-tools/src/edit/mod.rs | 1 + crates/harness-tools/src/glob.rs | 1 + crates/harness-tools/src/grep.rs | 1 + crates/harness-tools/src/read.rs | 8 + crates/harness-tools/src/write.rs | 1 + 12 files changed, 187 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce42c86..84b9211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -677,6 +677,7 @@ dependencies = [ "harness-mcp", "harness-providers", "harness-tools", + "serde_json", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/crates/harness-app/Cargo.toml b/crates/harness-app/Cargo.toml index 0fd5f60..a452203 100644 --- a/crates/harness-app/Cargo.toml +++ b/crates/harness-app/Cargo.toml @@ -20,6 +20,7 @@ tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } futures = { workspace = true } +serde_json = { workspace = true } [lints] workspace = true diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index 7db5393..d9822d3 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -18,7 +18,9 @@ use harness_core::engine::{run_session, RunConfig, StepContext}; use harness_core::event::{AppEvent, EventBus, RunOutcome}; use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset}; use harness_core::store::{Store, StoreError}; -use harness_core::tool::{SpawnError, SpawnOutcome, SpawnRequest, SubagentSpawner, ToolRegistry}; +use harness_core::tool::{ + ContextReporter, SpawnError, SpawnOutcome, SpawnRequest, SubagentSpawner, ToolRegistry, +}; use harness_core::types::{ Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId, }; @@ -26,7 +28,7 @@ use harness_providers::{AnthropicProvider, ModelCatalog, OpenAiProvider, Provide use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -/// Placeholder until M4's markdown agent registry lands (`assets/agents/orchestrator.md`). +/// Fallback prompt for an agent whose markdown body is empty or that isn't in the registry. pub const DEFAULT_AGENT_PROMPT: &str = "You are a helpful coding assistant with access to tools for reading, editing, running \ commands, and searching code. Use them as needed to satisfy the user's request, then \ @@ -312,6 +314,7 @@ impl EngineHandle { now, spawner: Some(self.inner.clone()), job_board: Some(board), + context_reporter: None, // root session has no parent board to report to }; let run_config = RunConfig { agent_name: agent.name.clone(), @@ -573,6 +576,7 @@ impl EngineInner { parent_rules: Ruleset, extra_rules: Ruleset, board: Arc, + reporter: Option>, cancel: CancellationToken, now: i64, ) -> StepContext { @@ -591,10 +595,27 @@ impl EngineInner { now, spawner: Some(self.arc()), job_board: Some(board), + context_reporter: reporter, } } } +/// Reports a child session's file reads to its job on the parent board. +struct BoardReporter { + board: Arc, + task_id: String, +} + +#[async_trait] +impl ContextReporter for BoardReporter { + async fn report_file(&self, path: String, lines: u32) { + let _ = self + .board + .report_context_file(&self.task_id, path, lines, now_ms()) + .await; + } +} + #[async_trait] impl SubagentSpawner for EngineInner { async fn spawn(&self, req: SpawnRequest) -> Result { @@ -720,6 +741,10 @@ impl SubagentSpawner for EngineInner { } .unwrap_or_default(); + let reporter: Arc = Arc::new(BoardReporter { + board: board.clone(), + task_id: task_id.clone(), + }); let ctx = self.child_ctx( child_id.clone(), child_tools, @@ -727,6 +752,7 @@ impl SubagentSpawner for EngineInner { parent_rules, child_session.extra_rules.clone(), child_board, + Some(reporter), cancel, now, ); @@ -760,6 +786,10 @@ impl SubagentSpawner for EngineInner { }) } else { // Foreground: run to completion under the tool call's token and return the text. + let reporter: Arc = Arc::new(BoardReporter { + board: board.clone(), + task_id: task_id.clone(), + }); let ctx = self.child_ctx( child_id.clone(), child_tools, @@ -767,6 +797,7 @@ impl SubagentSpawner for EngineInner { parent_rules, child_session.extra_rules.clone(), child_board, + Some(reporter), req.cancel.clone(), now, ); @@ -931,6 +962,128 @@ mod tests { .unwrap() } + /// Drives a subagent through one `read` tool call, then a final answer. + struct ReadThenAnswerProvider { + steps: std::sync::Mutex>>>, + } + + #[async_trait] + impl Provider for ReadThenAnswerProvider { + fn id(&self) -> &str { + "mock" + } + async fn list_models(&self) -> Result, ProviderError> { + Ok(vec![]) + } + async fn stream( + &self, + _req: LlmRequest, + _cancel: CancellationToken, + ) -> Result { + let events = self.steps.lock().unwrap().pop_front().unwrap_or_else(|| { + vec![ + Ok(LlmEvent::TextStart { id: "d".into() }), + Ok(LlmEvent::TextDelta { + id: "d".into(), + text: "done".into(), + }), + Ok(LlmEvent::TextEnd { id: "d".into() }), + Ok(LlmEvent::Finish { + reason: FinishReason::Stop, + usage: TokenUsage::default(), + }), + ] + }); + Ok(Box::pin(futures::stream::iter(events))) + } + } + + #[tokio::test] + async fn subagent_file_reads_land_on_the_job_board() { + let dir = tempfile::tempdir().unwrap(); + // A file with ≥10 lines so the read clears the board's reporting threshold. + let body = (1..=15) + .map(|n| format!("line {n}")) + .collect::>() + .join("\n"); + std::fs::write(dir.path().join("auth.rs"), body).unwrap(); + + let provider = Arc::new(ReadThenAnswerProvider { + steps: std::sync::Mutex::new( + vec![ + // Turn 1: call the read tool. + vec![ + Ok(LlmEvent::ToolCall { + call_id: "c1".into(), + name: "read".into(), + input: serde_json::json!({"file_path": "auth.rs"}), + }), + Ok(LlmEvent::Finish { + reason: FinishReason::ToolCalls, + usage: TokenUsage::default(), + }), + ], + // Turn 2: final answer. + vec![ + Ok(LlmEvent::TextStart { id: "t".into() }), + Ok(LlmEvent::TextDelta { + id: "t".into(), + text: "found it".into(), + }), + Ok(LlmEvent::TextEnd { id: "t".into() }), + Ok(LlmEvent::Finish { + reason: FinishReason::Stop, + usage: TokenUsage::default(), + }), + ], + ] + .into(), + ), + }); + let mut providers = ProviderRegistry::new(); + providers.register(provider); + let engine = EngineHandle::build( + dir.path().to_path_buf(), + Store::open_in_memory().unwrap(), + Config::default(), + providers, + ) + .unwrap(); + // Auto-approve permission asks (the child's read tool gates on `read`). + spawn_auto_approve_task(&engine); + + let root = engine + .new_session("orchestrator", "mock/mock-model") + .await + .unwrap(); + let out = engine + .inner + .spawn(SpawnRequest { + parent_session_id: root.clone(), + parent_message_id: MessageId::new(), + agent: "explorer".into(), + description: "read auth".into(), + prompt: "read auth.rs".into(), + reuse_task_id: None, + background: false, + cancel: CancellationToken::new(), + }) + .await + .unwrap(); + + let board = engine.inner.board_for(&root).await.unwrap(); + let job = board + .snapshot() + .into_iter() + .find(|j| j.child_session == out.child_session_id) + .expect("job on board"); + assert!( + job.context_files.iter().any(|f| f.path == "auth.rs"), + "expected auth.rs on the board, got {:?}", + job.context_files + ); + } + #[tokio::test] async fn foreground_task_runs_a_subagent_and_alias_reuse_continues_the_same_session() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/harness-core/src/engine/loop.rs b/crates/harness-core/src/engine/loop.rs index 200358a..c0f7619 100644 --- a/crates/harness-core/src/engine/loop.rs +++ b/crates/harness-core/src/engine/loop.rs @@ -411,6 +411,7 @@ mod tests { now: 1, spawner: None, job_board: None, + context_reporter: None, } } diff --git a/crates/harness-core/src/engine/processor.rs b/crates/harness-core/src/engine/processor.rs index 89befed..8d0e472 100644 --- a/crates/harness-core/src/engine/processor.rs +++ b/crates/harness-core/src/engine/processor.rs @@ -11,7 +11,8 @@ use crate::llm::{FinishReason, LlmEvent, LlmEventStream, ProviderError}; use crate::permission::{PermissionService, Ruleset}; use crate::store::Store; use crate::tool::{ - MetadataSink, PermissionHandle, SubagentSpawner, Tool, ToolCtx, ToolError, ToolRegistry, + ContextReporter, MetadataSink, PermissionHandle, SubagentSpawner, Tool, ToolCtx, ToolError, + ToolRegistry, }; use crate::types::{Message, MessageId, Part, PartBody, PartId, SessionId, TokenUsage, ToolState}; @@ -65,6 +66,9 @@ pub struct StepContext { /// This session's background job board (as a parent). Injected into requests when the /// running agent can delegate; `None` disables the board. pub job_board: Option>, + /// Present in subagent sessions: reports read files to this session's job on the parent + /// board. `None` for root sessions (nothing to report to). + pub context_reporter: Option>, } struct FlushTracker { @@ -506,6 +510,7 @@ impl<'a> Run<'a> { ask, metadata: metadata_sink, spawner: self.ctx.spawner.clone(), + context_reporter: self.ctx.context_reporter.clone(), }; let result = tokio::select! { diff --git a/crates/harness-core/src/tool/mod.rs b/crates/harness-core/src/tool/mod.rs index 685624e..3ee7e17 100644 --- a/crates/harness-core/src/tool/mod.rs +++ b/crates/harness-core/src/tool/mod.rs @@ -54,6 +54,14 @@ pub trait SubagentSpawner: Send + Sync { async fn spawn(&self, req: SpawnRequest) -> Result; } +/// Lets a child session report the files it read to its job board entry, so a completed +/// specialist advertises what it already looked at (docs/04-multiagent.md). Present only in +/// subagent sessions; the spawner wires it to the right board + job. +#[async_trait] +pub trait ContextReporter: Send + Sync { + async fn report_file(&self, path: String, lines: u32); +} + #[derive(Debug, thiserror::Error)] pub enum ToolError { #[error("permission denied")] @@ -185,6 +193,8 @@ pub struct ToolCtx { /// Present when the engine can spawn subagents (the `task` tool's capability). `None` /// in headless/test contexts with no orchestration wired in. pub spawner: Option>, + /// Present in subagent sessions: lets the read tool report files to the job board. + pub context_reporter: Option>, } #[derive(Debug)] diff --git a/crates/harness-tools/src/bash.rs b/crates/harness-tools/src/bash.rs index 0c3f831..b9dae14 100644 --- a/crates/harness-tools/src/bash.rs +++ b/crates/harness-tools/src/bash.rs @@ -150,6 +150,7 @@ mod tests { ), metadata, spawner: None, + context_reporter: None, } } diff --git a/crates/harness-tools/src/edit/mod.rs b/crates/harness-tools/src/edit/mod.rs index 6cd140a..654ca8d 100644 --- a/crates/harness-tools/src/edit/mod.rs +++ b/crates/harness-tools/src/edit/mod.rs @@ -233,6 +233,7 @@ mod tests { ), metadata, spawner: None, + context_reporter: None, } } diff --git a/crates/harness-tools/src/glob.rs b/crates/harness-tools/src/glob.rs index 44b59db..1600f5f 100644 --- a/crates/harness-tools/src/glob.rs +++ b/crates/harness-tools/src/glob.rs @@ -131,6 +131,7 @@ mod tests { ), metadata, spawner: None, + context_reporter: None, } } diff --git a/crates/harness-tools/src/grep.rs b/crates/harness-tools/src/grep.rs index 012f33c..15cf098 100644 --- a/crates/harness-tools/src/grep.rs +++ b/crates/harness-tools/src/grep.rs @@ -160,6 +160,7 @@ mod tests { ), metadata, spawner: None, + context_reporter: None, } } diff --git a/crates/harness-tools/src/read.rs b/crates/harness-tools/src/read.rs index 4ba2aa3..03f26f4 100644 --- a/crates/harness-tools/src/read.rs +++ b/crates/harness-tools/src/read.rs @@ -102,6 +102,13 @@ impl Tool for ReadTool { "(empty file or offset past end)".to_string(), )); } + + // In a subagent session, advertise this read on the job board. + if let Some(reporter) = &ctx.context_reporter { + let reported = paths::relative_pattern(&ctx.cwd, &path); + reporter.report_file(reported, numbered.len() as u32).await; + } + Ok(ToolOutput::new( params.file_path.clone(), numbered.join("\n"), @@ -140,6 +147,7 @@ mod tests { ), metadata, spawner: None, + context_reporter: None, } } diff --git a/crates/harness-tools/src/write.rs b/crates/harness-tools/src/write.rs index 7011030..6a07da7 100644 --- a/crates/harness-tools/src/write.rs +++ b/crates/harness-tools/src/write.rs @@ -115,6 +115,7 @@ mod tests { ), metadata, spawner: None, + context_reporter: None, } } -- 2.54.0 From 9ee5a1940d909769b5839fba61cfe9d76dfe4bab Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:46:31 +0200 Subject: [PATCH 5/6] M4: jobs pane, subtask drill-in, reminders Adds the jobs pane to the TUI with a new snapshot test, subtask drill-in navigation, and periodic reminders injected into the engine loop for long-running background jobs. --- crates/harness-app/src/lib.rs | 26 ++++ crates/harness-core/src/engine/loop.rs | 105 +++++++++++-- crates/harness-core/src/engine/processor.rs | 13 ++ crates/harness-tui/src/input.rs | 56 +++++++ crates/harness-tui/src/modal.rs | 6 +- crates/harness-tui/src/render.rs | 138 +++++++++++++++++- ...render__tests__snapshot_empty_session.snap | 4 +- ...ss__render__tests__snapshot_jobs_pane.snap | 29 ++++ ...der__tests__snapshot_permission_modal.snap | 4 +- ..._tests__snapshot_session_picker_modal.snap | 4 +- ...__tests__snapshot_tool_card_completed.snap | 4 +- ...nder__tests__snapshot_tool_card_error.snap | 4 +- ...er__tests__snapshot_tool_card_running.snap | 4 +- ..._snapshot_user_and_assistant_messages.snap | 4 +- crates/harness-tui/src/state.rs | 53 ++++++- 15 files changed, 426 insertions(+), 28 deletions(-) create mode 100644 crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index d9822d3..64e964d 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -325,6 +325,8 @@ impl EngineHandle { instructions: self.inner.config.instructions.clone(), cost: Some(self.inner.catalog.cost(provider_id, model_id)), inject_job_board, + reminder_turn_start: self.inner.reminder("turn_start"), + reminder_after_file_tool: self.inner.reminder("after_file_tool"), }; // Reserve the run slot *before* spawning. If we inserted after spawning, a run that @@ -386,6 +388,18 @@ impl EngineHandle { Ok(self.inner.store.parts(message_id).await?) } + pub async fn get_session(&self, session_id: SessionId) -> Result, AppError> { + Ok(self.inner.store.session(session_id).await?) + } + + /// Background jobs spawned by `session_id` (the parent), for the TUI jobs pane / drill-in. + pub async fn jobs( + &self, + session_id: SessionId, + ) -> Result, AppError> { + Ok(self.inner.store.jobs_for_parent(session_id).await?) + } + pub fn is_running(&self, session_id: &SessionId) -> bool { let runs = self.inner.runs.lock().unwrap(); runs.contains_key(session_id) @@ -477,6 +491,16 @@ impl EngineInner { } } + /// An optional orchestration reminder string by hook key (`turn_start`/`after_file_tool`). + fn reminder(&self, key: &str) -> Option { + self.config + .orchestration + .reminders + .as_ref()? + .get(key) + .cloned() + } + /// The ruleset a session evaluates its own tool calls against: low-priority depth guards /// (depth > 0), then global config permissions, then the agent's own rules (highest). fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset { @@ -712,6 +736,8 @@ impl SubagentSpawner for EngineInner { &child_session.model.model_id, )), inject_job_board: agent.mode.is_primary(), + reminder_turn_start: self.reminder("turn_start"), + reminder_after_file_tool: self.reminder("after_file_tool"), }; // Register the launch on the parent board (also makes foreground results reusable). diff --git a/crates/harness-core/src/engine/loop.rs b/crates/harness-core/src/engine/loop.rs index c0f7619..97580f7 100644 --- a/crates/harness-core/src/engine/loop.rs +++ b/crates/harness-core/src/engine/loop.rs @@ -24,6 +24,10 @@ pub struct RunConfig { pub cost: Option, /// Whether to append the background job board to requests (primary/delegating agents). pub inject_job_board: bool, + /// Optional user-provided reminder injected at the start of every turn (off by default). + pub reminder_turn_start: Option, + /// Optional user-provided reminder injected on the turn after a file tool ran. + pub reminder_after_file_tool: Option, } /// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is @@ -162,6 +166,8 @@ pub async fn run_session( ) -> RunOutcome { let mut doomloop = DoomLoopGuard::new(); let mut steps = 0u32; + // Whether the previous step ran a file tool, gating the `after_file_tool` reminder. + let mut prev_used_file_tool = false; loop { match should_continue(&ctx.store, &ctx.session_id).await { @@ -200,18 +206,33 @@ pub async fn run_session( wire_messages.extend(convert_message(message, &parts)); } - // Append the (synthetic, non-persisted) job board to the last user message so the - // orchestrator sees running/reusable subtasks. docs/04-multiagent.md. + // Collect synthetic (non-persisted) blocks to append to the last user message this + // turn: the optional turn-start reminder, the job board, and — if the previous step + // ran a file tool — the optional after-file-tool reminder. docs/04-multiagent.md. + let mut synthetic: Vec = Vec::new(); + if let Some(reminder) = &run_config.reminder_turn_start { + synthetic.push(reminder.clone()); + } if run_config.inject_job_board { if let Some(board) = &ctx.job_board { if let Some(block) = board.format_for_prompt() { - if let Some(last_user) = wire_messages - .iter_mut() - .rev() - .find(|m| m.role == WireRole::User) - { - last_user.content.push(WireContent::Text { text: block }); - } + synthetic.push(block); + } + } + } + if prev_used_file_tool { + if let Some(reminder) = &run_config.reminder_after_file_tool { + synthetic.push(reminder.clone()); + } + } + if !synthetic.is_empty() { + if let Some(last_user) = wire_messages + .iter_mut() + .rev() + .find(|m| m.role == WireRole::User) + { + for text in synthetic { + last_user.content.push(WireContent::Text { text }); } } } @@ -278,6 +299,7 @@ pub async fn run_session( } Ok(outcome) => { accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await; + prev_used_file_tool = outcome.used_file_tool; // A completed step means the orchestrator has now seen any terminal jobs // that were on the board this turn; mark them reconciled. if run_config.inject_job_board { @@ -494,6 +516,8 @@ mod tests { ..Default::default() }), inject_job_board: false, + reminder_turn_start: None, + reminder_after_file_tool: None, }; let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await; @@ -602,6 +626,8 @@ mod tests { instructions: Vec::new(), cost: None, inject_job_board: false, + reminder_turn_start: None, + reminder_after_file_tool: None, }; let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await; @@ -707,6 +733,8 @@ mod tests { instructions: Vec::new(), cost: None, inject_job_board: true, + reminder_turn_start: None, + reminder_after_file_tool: None, }; let provider = std::sync::Arc::new(CapturingProvider { @@ -735,4 +763,63 @@ mod tests { assert!(text.contains("exp-1"), "got: {text}"); assert!(text.contains("map the auth flow"), "got: {text}"); } + + #[tokio::test] + async fn turn_start_reminder_is_injected_into_the_request() { + let store = Store::open_in_memory().unwrap(); + let bus = EventBus::new(); + let model = ModelRef::new("mock", "mock-model"); + let session = Session::new_root("orchestrator", model.clone(), 1); + let session_id = session.id.clone(); + store.upsert_session(session).await.unwrap(); + + let user_message = Message::new_user(session_id.clone(), 1); + store.upsert_message(user_message.clone()).await.unwrap(); + store + .upsert_part(Part { + id: crate::types::PartId::new(), + message_id: user_message.id.clone(), + session_id: session_id.clone(), + idx: 0, + body: PartBody::Text { + text: "do the thing".into(), + synthetic: false, + }, + }) + .await + .unwrap(); + + let cwd = tempfile::tempdir().unwrap(); + let ctx = make_ctx(store, bus, session_id, cwd.path().to_path_buf()).await; + let run_config = RunConfig { + agent_name: "orchestrator".into(), + agent_prompt: "You orchestrate.".into(), + model, + temperature: None, + max_steps: 1, + instructions: Vec::new(), + cost: None, + inject_job_board: false, + reminder_turn_start: Some("REMEMBER: stay on task.".into()), + reminder_after_file_tool: None, + }; + + let provider = std::sync::Arc::new(CapturingProvider { + last: StdMutex::new(None), + }); + let outcome = run_session(provider.clone(), ctx, &run_config, || 2).await; + assert!(matches!(outcome, RunOutcome::Stopped)); + + let req = provider.last.lock().unwrap().clone().expect("a request"); + let last_user = req + .messages + .iter() + .rev() + .find(|m| m.role == WireRole::User) + .expect("a user message"); + let has_reminder = last_user.content.iter().any( + |c| matches!(c, WireContent::Text { text } if text.contains("REMEMBER: stay on task.")), + ); + assert!(has_reminder, "turn-start reminder should be injected"); + } } diff --git a/crates/harness-core/src/engine/processor.rs b/crates/harness-core/src/engine/processor.rs index 8d0e472..c29636c 100644 --- a/crates/harness-core/src/engine/processor.rs +++ b/crates/harness-core/src/engine/processor.rs @@ -33,6 +33,9 @@ pub struct StepOutcome { /// Dollar cost of this step's usage (0.0 when no pricing is available). pub cost: f64, pub aborted: bool, + /// Whether a file-mutating tool (`edit`/`write`) ran this step — drives the optional + /// `after_file_tool` reminder injection on the next turn. + pub used_file_tool: bool, } pub struct StepError { @@ -98,9 +101,13 @@ impl FlushTracker { } } +/// Tool names that mutate files — after one runs, the optional `after_file_tool` reminder fires. +const FILE_TOOLS: &[&str] = &["edit", "write"]; + struct Run<'a> { ctx: &'a StepContext, assistant: Option, + used_file_tool: bool, next_idx: u32, active_text: Option, active_reasoning: Option, @@ -117,6 +124,7 @@ impl<'a> Run<'a> { Self { ctx, assistant: None, + used_file_tool: false, next_idx: 0, active_text: None, active_reasoning: None, @@ -386,6 +394,9 @@ impl<'a> Run<'a> { input: serde_json::Value, doomloop: &mut DoomLoopGuard, ) -> Result<(), ProviderError> { + if FILE_TOOLS.contains(&name.as_str()) { + self.used_file_tool = true; + } let part_id = self.pending_tools.remove(&call_id).unwrap_or_default(); let running = Part { id: part_id.clone(), @@ -607,6 +618,7 @@ pub async fn process_step( usage, cost: step_cost, aborted: true, + used_file_tool: run.used_file_tool, }); } }; @@ -679,5 +691,6 @@ pub async fn process_step( usage, cost: step_cost, aborted: false, + used_file_tool: run.used_file_tool, }) } diff --git a/crates/harness-tui/src/input.rs b/crates/harness-tui/src/input.rs index ee8097c..e4309f8 100644 --- a/crates/harness-tui/src/input.rs +++ b/crates/harness-tui/src/input.rs @@ -23,6 +23,10 @@ pub enum InputAction { SessionPickerUp, SessionPickerDown, SessionPickerSelect, + OpenJobs, + JobsUp, + JobsDown, + JobsDrillIn, } /// Translate a crossterm event into an action and/or mutate `state` directly. @@ -49,10 +53,21 @@ fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction { match &state.modal { ModalState::Permission { .. } => handle_permission_key(key, state), ModalState::SessionPicker { .. } => handle_session_picker_key(key, state), + ModalState::JobsPane { .. } => handle_jobs_pane_key(key, state), ModalState::None => handle_normal_key(key, state), } } +fn handle_jobs_pane_key(key: KeyEvent, _state: &mut AppState) -> InputAction { + match key.code { + KeyCode::Up => InputAction::JobsUp, + KeyCode::Down => InputAction::JobsDown, + KeyCode::Enter => InputAction::JobsDrillIn, + KeyCode::Esc => InputAction::CloseModal, + _ => InputAction::None, + } +} + fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { @@ -119,6 +134,7 @@ fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction { } } KeyCode::Char('s') if ctrl => InputAction::LoadSessions, + KeyCode::Char('j') if ctrl => InputAction::OpenJobs, KeyCode::Up => { let (row, _) = state.input.cursor(); if row == 0 { @@ -158,6 +174,7 @@ fn parse_slash_command(text: &str) -> Option { "/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)), "/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)), "/sessions" => Some(InputAction::LoadSessions), + "/jobs" => Some(InputAction::OpenJobs), "/quit" => Some(InputAction::Quit), _ => None, } @@ -235,6 +252,45 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En } } } + InputAction::OpenJobs => { + if let Some(session_id) = state.session_id.clone() { + match engine.jobs(session_id).await { + Ok(jobs) => { + state.set_jobs(jobs); + state.open_jobs_pane(); + } + Err(e) => tracing::error!(error = %e, "failed to load jobs"), + } + } + } + InputAction::JobsUp => { + if let ModalState::JobsPane { selected } = &mut state.modal { + *selected = selected.saturating_sub(1); + } + state.dirty = true; + } + InputAction::JobsDown => { + let job_count = state.jobs.len(); + if let ModalState::JobsPane { selected } = &mut state.modal { + if *selected + 1 < job_count { + *selected += 1; + } + } + state.dirty = true; + } + InputAction::JobsDrillIn => { + if let Some(child) = state.selected_job_child() { + match engine.get_session(child).await { + Ok(Some(session)) => { + if let Err(e) = modal::select_session(state, engine, session).await { + tracing::error!(error = %e, "failed to open subtask session"); + } + } + Ok(None) => tracing::warn!("subtask session no longer exists"), + Err(e) => tracing::error!(error = %e, "failed to load subtask session"), + } + } + } InputAction::None => {} } } diff --git a/crates/harness-tui/src/modal.rs b/crates/harness-tui/src/modal.rs index bcda0c7..6946c3f 100644 --- a/crates/harness-tui/src/modal.rs +++ b/crates/harness-tui/src/modal.rs @@ -26,13 +26,17 @@ pub async fn select_session( session: Session, ) -> Result<(), harness_app::AppError> { let session_id = session.id.clone(); - let messages = engine.session_messages(session_id).await?; + let messages = engine.session_messages(session_id.clone()).await?; let mut all_parts = Vec::new(); for message in &messages { let parts = engine.message_parts(message.id.clone()).await?; all_parts.extend(parts); } state.set_session(session, messages, all_parts); + // Surface any subtasks this session spawned (drill-in and session-switch both land here). + if let Ok(jobs) = engine.jobs(session_id).await { + state.set_jobs(jobs); + } state.modal = ModalState::None; Ok(()) } diff --git a/crates/harness-tui/src/render.rs b/crates/harness-tui/src/render.rs index 43994da..6cfce68 100644 --- a/crates/harness-tui/src/render.rs +++ b/crates/harness-tui/src/render.rs @@ -33,6 +33,9 @@ pub fn render(frame: &mut Frame, state: &mut AppState) { ModalState::SessionPicker { sessions, selected } => { render_session_picker(frame, sessions, *selected, area); } + ModalState::JobsPane { selected } => { + render_jobs_pane(frame, &state.jobs, *selected, area); + } ModalState::None => {} } } @@ -169,7 +172,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) { let hints = if state.ctrl_c_pressed { "Press Ctrl+C again to quit" } else { - "Ctrl+S: sessions | Esc: abort | Ctrl+C: quit" + "Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: quit" }; let usage = format!( "{} · ${:.4}", @@ -242,6 +245,86 @@ fn render_session_picker( frame.render_widget(paragraph, popup); } +fn render_jobs_pane( + frame: &mut Frame, + jobs: &[harness_core::engine::JobRecord], + selected: usize, + area: Rect, +) { + let popup = centered_rect(70, 70, area); + frame.render_widget(Clear, popup); + + let mut text: Vec> = + vec![Line::from("Background jobs").style(Style::new().add_modifier(Modifier::BOLD))]; + + if jobs.is_empty() { + text.push(Line::default()); + text.push(Line::from("No subtasks spawned yet.").style(Style::new().fg(Color::DarkGray))); + } else { + for (i, job) in jobs.iter().enumerate() { + let marker = if i == selected { "> " } else { " " }; + let (icon, color) = job_state_style(job.state); + let header = format!( + "{marker}{icon} {} · {} · {}", + job.alias, + job.agent, + job_state_label(job.state), + ); + let style = if i == selected { + Style::new().bg(Color::Blue).fg(Color::White) + } else { + Style::new().fg(color) + }; + text.push(Line::from(Span::styled(header, style))); + if let Some(objective) = &job.objective { + text.push( + Line::from(format!(" {objective}")).style(Style::new().fg(Color::Gray)), + ); + } + if !job.context_files.is_empty() { + let files: Vec<&str> = job + .context_files + .iter() + .take(8) + .map(|f| f.path.as_str()) + .collect(); + text.push( + Line::from(format!(" read: {}", files.join(", "))) + .style(Style::new().fg(Color::DarkGray)), + ); + } + } + text.push(Line::default()); + text.push( + Line::from("Enter: open subtask · Esc: close").style(Style::new().fg(Color::DarkGray)), + ); + } + + let block = Block::default().borders(Borders::ALL).title(" jobs "); + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, popup); +} + +fn job_state_label(state: harness_core::engine::JobState) -> &'static str { + use harness_core::engine::JobState; + match state { + JobState::Running => "running", + JobState::Completed => "completed", + JobState::Error => "error", + JobState::Cancelled => "cancelled", + } +} + +fn job_state_style(state: harness_core::engine::JobState) -> (&'static str, Color) { + use harness_core::engine::JobState; + match state { + JobState::Running => ("⚙", Color::Yellow), + JobState::Completed => ("✓", Color::Green), + JobState::Error => ("✗", Color::Red), + JobState::Cancelled => ("∅", Color::DarkGray), + } +} + fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) @@ -487,6 +570,59 @@ mod tests { insta::assert_snapshot!(buffer_to_string(terminal.backend())); } + #[test] + fn snapshot_jobs_pane() { + use harness_core::engine::{ContextFile, JobRecord, JobState}; + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + state.session_id = Some(SessionId("ses_test_001".to_string())); + state.jobs = vec![ + JobRecord { + task_id: "t1".to_string(), + alias: "exp-1".to_string(), + parent_session: SessionId("ses_test_001".to_string()), + child_session: SessionId("ses_child_001".to_string()), + agent: "explorer".to_string(), + description: "map auth".to_string(), + objective: Some("map the auth flow".to_string()), + state: JobState::Completed, + reconciled: true, + result_summary: Some("done".to_string()), + context_files: vec![ContextFile { + path: "src/auth.rs".to_string(), + lines: 42, + }], + launched_at: 1, + updated_at: 2, + last_used_at: 2, + }, + JobRecord { + task_id: "t2".to_string(), + alias: "fix-1".to_string(), + parent_session: SessionId("ses_test_001".to_string()), + child_session: SessionId("ses_child_002".to_string()), + agent: "fixer".to_string(), + description: "patch bug".to_string(), + objective: Some("fix the null deref".to_string()), + state: JobState::Running, + reconciled: false, + result_summary: None, + context_files: Vec::new(), + launched_at: 3, + updated_at: 3, + last_used_at: 3, + }, + ]; + state.modal = ModalState::JobsPane { selected: 1 }; + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + #[test] fn render_empty_state_produces_frame() { let backend = TestBackend::new(80, 24); diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap index bc0e440..39bf51c 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 300 +assertion_line: 381 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap new file mode 100644 index 0000000..05caa97 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 621 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ┌ jobs ────────────────────────────────────────────────┐ │ +│ │Background jobs │ │ +│ │ ✓ exp-1 · explorer · completed │ │ +│ │ map the auth flow │ │ +│ │ read: src/auth.rs │ │ +│ │> ⚙ fix-1 · fixer · running │ │ +│ │ fix the null deref │ │ +│ │ │ │ +│ │Enter: open subtask · Esc: close │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +└───────────└──────────────────────────────────────────────────────┘───────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap index 348638e..014844f 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 430 +assertion_line: 511 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap index 7411661..c6bfbc3 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 487 +assertion_line: 568 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap index f7c0a66..8e0a0fa 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 357 +assertion_line: 438 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap index c0ec904..fb47b48 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 407 +assertion_line: 488 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap index f5f4ce9..0ea0af7 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 382 +assertion_line: 463 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap index 4cc4add..73f4168 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 331 +assertion_line: 412 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/state.rs b/crates/harness-tui/src/state.rs index cb507e1..a9f9905 100644 --- a/crates/harness-tui/src/state.rs +++ b/crates/harness-tui/src/state.rs @@ -1,4 +1,5 @@ use harness_app::EngineHandle; +use harness_core::engine::JobRecord; use harness_core::event::{AppEvent, PermissionRequest, RunOutcome}; use harness_core::permission::PermissionReply; use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState}; @@ -86,6 +87,10 @@ pub enum ModalState { sessions: Vec, selected: usize, }, + /// Background job board for the current session, with a cursor for drill-in. + JobsPane { + selected: usize, + }, } /// All mutable UI state lives here. @@ -109,6 +114,9 @@ pub struct AppState { pub session_cost: f64, /// Accumulated session tokens (input + output), for the status bar. pub session_tokens: u64, + /// Background jobs spawned by the current session, newest activity last. Populated on + /// session load and kept live via `JobUpdated` events; surfaced in the jobs pane. + pub jobs: Vec, } impl AppState { @@ -131,6 +139,7 @@ impl AppState { render_width: 0, session_cost: 0.0, session_tokens: 0, + jobs: Vec::new(), } } @@ -156,6 +165,8 @@ impl AppState { self.session_cost = session.cost; self.session_tokens = session.usage.input + session.usage.output; self.messages.clear(); + // Jobs are reloaded for the newly-selected session by the caller. + self.jobs.clear(); let mut messages = messages; messages.sort_by_key(|m| m.created_at); @@ -258,14 +269,50 @@ impl AppState { } self.dirty = true; } - AppEvent::JobUpdated { .. } - | AppEvent::AuthPrompt { .. } - | AppEvent::ServerNotice { .. } => { + AppEvent::JobUpdated { job } => { + if let Ok(record) = serde_json::from_value::(job.0) { + if self.session_id.as_ref() == Some(&record.parent_session) { + self.upsert_job(record); + self.dirty = true; + } + } + } + AppEvent::AuthPrompt { .. } | AppEvent::ServerNotice { .. } => { self.dirty = true; } } } + /// Replaces this session's job list (e.g. after loading a session). + pub fn set_jobs(&mut self, jobs: Vec) { + self.jobs = jobs; + self.dirty = true; + } + + /// Inserts or replaces a job by `task_id`, preserving list order for stable rendering. + fn upsert_job(&mut self, record: JobRecord) { + if let Some(existing) = self.jobs.iter_mut().find(|j| j.task_id == record.task_id) { + *existing = record; + } else { + self.jobs.push(record); + } + } + + /// Opens the jobs pane (cursor at the top). + pub fn open_jobs_pane(&mut self) { + self.modal = ModalState::JobsPane { selected: 0 }; + self.dirty = true; + } + + /// Child session of the currently-selected job in the jobs pane, for drill-in. + pub fn selected_job_child(&self) -> Option { + if let ModalState::JobsPane { selected } = &self.modal { + self.jobs.get(*selected).map(|j| j.child_session.clone()) + } else { + None + } + } + pub fn scroll_up(&mut self, n: u16) { self.scroll_offset = self.scroll_offset.saturating_sub(n); self.dirty = true; -- 2.54.0 From e32f61e4f80431036e7be4775cfc1148de50ecf4 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:59:46 +0200 Subject: [PATCH 6/6] M4: dedicated opencode (Zen) provider Adds a dedicated provider entry for opencode's Zen proxy on top of the existing OpenAI-compatible codec, with its own config wiring in harness-app and harness-core::config::load. --- crates/harness-app/src/lib.rs | 13 +++++++ crates/harness-core/src/config/load.rs | 1 + crates/harness-providers/src/openai.rs | 53 ++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index 64e964d..b339749 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -147,6 +147,19 @@ impl EngineHandle { }; providers.register(Arc::new(provider)); } + // OpenCode Zen: OpenAI-compatible chat gateway under its own `opencode` provider id, + // so it coexists with a real `openai` provider. `base_url` defaults to Zen's endpoint. + if let Some(key) = config + .providers + .get("opencode") + .and_then(|p| p.api_key.clone()) + { + let base_url = config + .providers + .get("opencode") + .and_then(|p| p.base_url.clone()); + providers.register(Arc::new(OpenAiProvider::opencode(key, base_url))); + } Self::build(cwd, store, config, providers) } diff --git a/crates/harness-core/src/config/load.rs b/crates/harness-core/src/config/load.rs index d9988ed..99709e6 100644 --- a/crates/harness-core/src/config/load.rs +++ b/crates/harness-core/src/config/load.rs @@ -29,6 +29,7 @@ const ENV_CONFIG_PATH: &str = "AI_HARNESS_CONFIG"; const PROVIDER_ENV_KEYS: &[(&str, &str)] = &[ ("anthropic", "ANTHROPIC_API_KEY"), ("openai", "OPENAI_API_KEY"), + ("opencode", "OPENCODE_API_KEY"), ]; fn read_jsonc(path: &Path) -> Result { diff --git a/crates/harness-providers/src/openai.rs b/crates/harness-providers/src/openai.rs index 9961d17..4e7a1aa 100644 --- a/crates/harness-providers/src/openai.rs +++ b/crates/harness-providers/src/openai.rs @@ -8,6 +8,8 @@ use tokio_util::sync::CancellationToken; use crate::codec::{openai_chat, openai_responses}; const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +/// OpenCode Zen's OpenAI-compatible gateway (chat-completions only). +const OPENCODE_BASE_URL: &str = "https://opencode.ai/zen/v1"; /// Which OpenAI wire format to speak for a given model. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,28 +35,57 @@ fn flavor_for(model: &str) -> ApiFlavor { } pub struct OpenAiProvider { + id: String, api_key: String, base_url: String, + /// When set, always speak chat-completions regardless of model name — required for + /// OpenAI-compatible gateways (e.g. OpenCode Zen) that don't implement `/responses`. + chat_only: bool, client: reqwest::Client, } impl OpenAiProvider { pub fn new(api_key: impl Into) -> Self { Self { + id: "openai".to_string(), api_key: api_key.into(), base_url: DEFAULT_BASE_URL.to_string(), + chat_only: false, client: reqwest::Client::new(), } } pub fn with_base_url(api_key: impl Into, base_url: impl Into) -> Self { Self { + id: "openai".to_string(), api_key: api_key.into(), base_url: base_url.into(), + chat_only: false, client: reqwest::Client::new(), } } + /// An OpenCode Zen provider: id `opencode`, chat-completions only, defaulting to Zen's + /// gateway. Pass `base_url = None` to use the default endpoint. + pub fn opencode(api_key: impl Into, base_url: Option) -> Self { + Self { + id: "opencode".to_string(), + api_key: api_key.into(), + base_url: base_url.unwrap_or_else(|| OPENCODE_BASE_URL.to_string()), + chat_only: true, + client: reqwest::Client::new(), + } + } + + /// The wire format for `model`, honoring `chat_only`. + fn flavor(&self, model: &str) -> ApiFlavor { + if self.chat_only { + ApiFlavor::Chat + } else { + flavor_for(model) + } + } + fn classify_error( status: reqwest::StatusCode, body: String, @@ -77,7 +108,7 @@ impl OpenAiProvider { #[async_trait] impl Provider for OpenAiProvider { fn id(&self) -> &str { - "openai" + &self.id } async fn list_models(&self) -> Result, ProviderError> { @@ -90,7 +121,7 @@ impl Provider for OpenAiProvider { req: LlmRequest, cancel: CancellationToken, ) -> Result { - let (path, body) = match flavor_for(&req.model) { + let (path, body) = match self.flavor(&req.model) { ApiFlavor::Responses => ("/responses", openai_responses::build_request(&req)), ApiFlavor::Chat => ("/chat/completions", openai_chat::build_request(&req)), }; @@ -120,7 +151,7 @@ impl Provider for OpenAiProvider { return Err(Self::classify_error(status, body, retry_after)); } - Ok(match flavor_for(&req.model) { + Ok(match self.flavor(&req.model) { ApiFlavor::Responses => openai_responses::decode(response.bytes_stream()), ApiFlavor::Chat => openai_chat::decode(response.bytes_stream()), }) @@ -151,6 +182,22 @@ mod tests { assert_eq!(OpenAiProvider::new("k").id(), "openai"); } + #[test] + fn opencode_provider_is_chat_only_and_ids_as_opencode() { + let p = OpenAiProvider::opencode("k", None); + assert_eq!(p.id(), "opencode"); + assert_eq!(p.base_url, OPENCODE_BASE_URL); + // Even a gpt-*/o* model must route to chat completions on a chat-only gateway. + assert_eq!(p.flavor("gpt-5.5"), ApiFlavor::Chat); + assert_eq!(p.flavor("claude-sonnet-5"), ApiFlavor::Chat); + } + + #[test] + fn opencode_honors_custom_base_url() { + let p = OpenAiProvider::opencode("k", Some("https://example.test/v1".into())); + assert_eq!(p.base_url, "https://example.test/v1"); + } + #[test] fn classifies_context_overflow_from_400_body() { assert!(matches!( -- 2.54.0