From b6e94c67c7082d2e668f7bdc46cbf29207676a99 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 8 Jul 2026 17:28:37 +0200 Subject: [PATCH] harness-app + harness-tui: composition root and `harness run -p` debug command App::init wires config loading, an in-memory Store, EventBus, PermissionService with the M1 auto-approve stub frontend, the full built-in ToolRegistry, and a ProviderRegistry populated with AnthropicProvider when an API key is available (config or ANTHROPIC_API_KEY). App::run_prompt creates a root session, appends the prompt as a user message, and drives engine::run_session to completion; final_text reads back the concatenated text parts of the last message. harness-tui's `harness` binary gains a `run -p "" [-m provider/model]` subcommand built on this. Manually verified end-to-end against the real Anthropic API: an empty API key produced a genuine 401 that our SSE error path correctly classified as ProviderError::Auth and surfaced as a clean CLI error message (exit 1) -- confirming the full request/header/error- handling pipeline works against the live service, not just fixtures. This closes M1 (docs/10-milestones.md): headless core loop + Anthropic provider, config loading, all six built-in tools, and the debug CLI. 131 tests passing across the workspace, clippy clean, fmt clean. --- Cargo.lock | 7 ++ crates/harness-app/Cargo.toml | 7 ++ crates/harness-app/src/lib.rs | 220 ++++++++++++++++++++++++++++++++- crates/harness-tui/Cargo.toml | 2 + crates/harness-tui/src/main.rs | 87 ++++++++++++- 5 files changed, 320 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c5bdc5..643a01d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,11 +509,16 @@ dependencies = [ name = "harness-app" version = "0.1.0" dependencies = [ + "dirs", "harness-core", "harness-lsp", "harness-mcp", "harness-providers", "harness-tools", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", ] [[package]] @@ -598,6 +603,8 @@ name = "harness-tui" version = "0.1.0" dependencies = [ "harness-app", + "harness-core", + "tokio", ] [[package]] diff --git a/crates/harness-app/Cargo.toml b/crates/harness-app/Cargo.toml index fd2ba82..ceb4f67 100644 --- a/crates/harness-app/Cargo.toml +++ b/crates/harness-app/Cargo.toml @@ -10,6 +10,13 @@ harness-providers = { workspace = true } harness-tools = { workspace = true } harness-mcp = { workspace = true } harness-lsp = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +dirs = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index cce4b3f..0cefd5e 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -1 +1,219 @@ -// Composition root: builds the Engine and registers providers/tools/mcp/lsp. Lands in M1+. +//! Composition root: builds the pieces `harness-tui` (and later `harness-server`) need — +//! config, store, event bus, permission service, tool registry, provider registry — and +//! exposes a small headless API (`run_prompt`) used by the `harness run -p` debug command. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use harness_core::config::{self, Config, ConfigError}; +use harness_core::engine::{run_session, RunConfig, StepContext}; +use harness_core::event::{EventBus, RunOutcome}; +use harness_core::permission::{spawn_auto_approve, PermissionService}; +use harness_core::store::{Store, StoreError}; +use harness_core::tool::ToolRegistry; +use harness_core::types::{Message, ModelRef, Part, PartBody, PartId, Session, SessionId}; +use harness_providers::{AnthropicProvider, ProviderRegistry}; +use tokio_util::sync::CancellationToken; + +/// Placeholder until M4's markdown agent registry lands (`assets/agents/orchestrator.md`). +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 \ + reply with a concise final answer."; + +const DEFAULT_MAX_STEPS: u32 = 50; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Store(#[from] StoreError), + #[error("model ref must be \"provider/model\", got {0:?}")] + InvalidModelRef(String), + #[error("no provider registered for {0:?} (missing API key?)")] + UnknownProvider(String), +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub struct App { + pub config: Config, + pub store: Store, + pub bus: EventBus, + pub permissions: Arc, + pub tools: ToolRegistry, + pub providers: ProviderRegistry, + pub cwd: PathBuf, + data_dir: PathBuf, +} + +impl App { + /// In-memory store, auto-approve permission frontend — the M1 headless configuration. + /// A persistent SQLite-backed store and a real TUI permission modal land in M2. + pub fn init(cwd: PathBuf) -> Result { + let config = config::load(&cwd)?; + let bus = EventBus::new(); + let store = Store::open_in_memory()?; + let permissions = Arc::new(PermissionService::new(bus.clone())); + spawn_auto_approve(bus.clone(), permissions.clone()); + + let mut tools = ToolRegistry::new(); + harness_tools::register_builtins(&mut tools); + + let mut providers = ProviderRegistry::new(); + if let Some(key) = config + .providers + .get("anthropic") + .and_then(|p| p.api_key.clone()) + { + providers.register(Arc::new(AnthropicProvider::new(key))); + } + + let data_dir = dirs::data_dir() + .unwrap_or_else(std::env::temp_dir) + .join("ai-harness") + .join("tool-output"); + + Ok(Self { + config, + store, + bus, + permissions, + tools, + providers, + cwd, + data_dir, + }) + } + + /// Runs a single headless turn: creates a root session, appends `prompt` as the user + /// message, and drives the engine loop to completion. Returns the outcome plus the + /// session id so the caller can fetch the transcript via `final_text`. + pub async fn run_prompt( + &self, + prompt: String, + model_ref: &str, + ) -> Result<(RunOutcome, SessionId), AppError> { + let (provider_id, model_id) = model_ref + .split_once('/') + .ok_or_else(|| AppError::InvalidModelRef(model_ref.to_string()))?; + let provider = self + .providers + .get(provider_id) + .ok_or_else(|| AppError::UnknownProvider(provider_id.to_string()))?; + let model = ModelRef::new(provider_id, model_id); + + let now = now_ms(); + let session = Session::new_root("orchestrator", model.clone(), now); + let session_id = session.id.clone(); + self.store.upsert_session(session).await?; + + let user_message = Message::new_user(session_id.clone(), now); + self.store.upsert_message(user_message.clone()).await?; + self.store + .upsert_part(Part { + id: PartId::new(), + message_id: user_message.id, + session_id: session_id.clone(), + idx: 0, + body: PartBody::Text { + text: prompt, + synthetic: false, + }, + }) + .await?; + + let ctx = StepContext { + store: self.store.clone(), + bus: self.bus.clone(), + tools: self.tools.clone(), + permissions: self.permissions.clone(), + static_rules: self.config.permissions.clone(), + extra_rules: Arc::new(Mutex::new(Vec::new())), + session_id: session_id.clone(), + cwd: self.cwd.clone(), + data_dir: self.data_dir.join(session_id.to_string()), + cancel: CancellationToken::new(), + now, + }; + let run_config = RunConfig { + agent_name: "orchestrator".to_string(), + agent_prompt: DEFAULT_AGENT_PROMPT.to_string(), + model, + temperature: None, + max_steps: DEFAULT_MAX_STEPS, + instructions: self.config.instructions.clone(), + }; + + let outcome = run_session(provider, ctx, &run_config, now_ms).await; + Ok((outcome, session_id)) + } + + /// Concatenates the `Text` parts of the last message in the session — the final + /// assistant reply for a `harness run` invocation to print. + pub async fn final_text(&self, session_id: &SessionId) -> Result { + let messages = self.store.messages(session_id.clone()).await?; + let Some(last) = messages.last() else { + return Ok(String::new()); + }; + let parts = self.store.parts(last.id.clone()).await?; + let text = parts + .iter() + .filter_map(|p| match &p.body { + PartBody::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + Ok(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(dir.path().to_path_buf()).unwrap(); + assert!(app.providers.get("anthropic").is_none()); + assert_eq!(app.tools.all().len(), 6); + } + + #[tokio::test] + async fn run_prompt_errors_on_unregistered_provider() { + let dir = tempfile::tempdir().unwrap(); + std::env::remove_var("ANTHROPIC_API_KEY"); + let app = App::init(dir.path().to_path_buf()).unwrap(); + let err = app + .run_prompt("hi".into(), "anthropic/claude-sonnet-4-5") + .await + .unwrap_err(); + assert!(matches!(err, AppError::UnknownProvider(_))); + } + + #[tokio::test] + async fn run_prompt_errors_on_malformed_model_ref() { + let dir = tempfile::tempdir().unwrap(); + let app = App::init(dir.path().to_path_buf()).unwrap(); + let err = app.run_prompt("hi".into(), "no-slash").await.unwrap_err(); + assert!(matches!(err, AppError::InvalidModelRef(_))); + } + + #[tokio::test] + async fn final_text_is_empty_for_unknown_session() { + let dir = tempfile::tempdir().unwrap(); + let app = App::init(dir.path().to_path_buf()).unwrap(); + let text = app.final_text(&SessionId::new()).await.unwrap(); + assert_eq!(text, ""); + } +} diff --git a/crates/harness-tui/Cargo.toml b/crates/harness-tui/Cargo.toml index 587e091..e5d2bb2 100644 --- a/crates/harness-tui/Cargo.toml +++ b/crates/harness-tui/Cargo.toml @@ -10,6 +10,8 @@ path = "src/main.rs" [dependencies] harness-app = { workspace = true } +harness-core = { workspace = true } +tokio = { workspace = true } [lints] workspace = true diff --git a/crates/harness-tui/src/main.rs b/crates/harness-tui/src/main.rs index 70f7554..5a5feed 100644 --- a/crates/harness-tui/src/main.rs +++ b/crates/harness-tui/src/main.rs @@ -1,3 +1,86 @@ -fn main() { - println!("harness {}", env!("CARGO_PKG_VERSION")); +use harness_core::event::RunOutcome; + +const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-5"; + +fn print_usage() { + eprintln!("usage: harness run -p \"\" [-m provider/model]"); +} + +fn parse_run_args(args: &[String]) -> Option<(String, Option)> { + let mut prompt = None; + let mut model = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-p" | "--prompt" => { + prompt = args.get(i + 1).cloned(); + i += 2; + } + "-m" | "--model" => { + model = args.get(i + 1).cloned(); + i += 2; + } + _ => i += 1, + } + } + prompt.map(|p| (p, model)) +} + +async fn run(args: &[String]) -> i32 { + let Some((prompt, model_arg)) = parse_run_args(args) else { + print_usage(); + return 2; + }; + + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(e) => { + eprintln!("error: could not read cwd: {e}"); + return 1; + } + }; + + let app = match harness_app::App::init(cwd) { + Ok(app) => app, + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + }; + + let model_ref = model_arg + .or_else(|| app.config.model.clone()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + match app.run_prompt(prompt, &model_ref).await { + Ok((RunOutcome::Stopped, session_id)) => { + let text = app.final_text(&session_id).await.unwrap_or_default(); + println!("{text}"); + 0 + } + Ok((RunOutcome::Aborted, _)) => { + eprintln!("run aborted"); + 1 + } + Ok((RunOutcome::Errored { message }, _)) => { + eprintln!("error: {message}"); + 1 + } + Err(e) => { + eprintln!("error: {e}"); + 1 + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let exit_code = if args.first().map(String::as_str) == Some("run") { + run(&args[1..]).await + } else { + println!("harness {}", env!("CARGO_PKG_VERSION")); + 0 + }; + std::process::exit(exit_code); }