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:
Generated
+7
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
harness-app = { workspace = true }
|
||||
harness-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user