M1: composition root and harness run -p debug command

Adds the harness-app composition root wiring config, providers, tools, and the engine loop together, plus the harness run -p "<prompt>" debug command in harness-tui::main for driving a one-shot prompt end-to-end from the CLI.
This commit is contained in:
2026-07-08 17:28:37 +02:00
parent 9ed278bcb5
commit d83f8c84b7
5 changed files with 320 additions and 3 deletions
Generated
+7
View File
@@ -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]]
+7
View File
@@ -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
+219 -1
View File
@@ -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<PermissionService>,
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<Self, AppError> {
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<String, AppError> {
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::<Vec<_>>()
.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, "");
}
}
+2
View File
@@ -10,6 +10,8 @@ path = "src/main.rs"
[dependencies]
harness-app = { workspace = true }
harness-core = { workspace = true }
tokio = { workspace = true }
[lints]
workspace = true
+85 -2
View File
@@ -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 \"<prompt>\" [-m provider/model]");
}
fn parse_run_args(args: &[String]) -> Option<(String, Option<String>)> {
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<String> = 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);
}