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"
|
name = "harness-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
"harness-core",
|
"harness-core",
|
||||||
"harness-lsp",
|
"harness-lsp",
|
||||||
"harness-mcp",
|
"harness-mcp",
|
||||||
"harness-providers",
|
"harness-providers",
|
||||||
"harness-tools",
|
"harness-tools",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -598,6 +603,8 @@ name = "harness-tui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"harness-app",
|
"harness-app",
|
||||||
|
"harness-core",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ harness-providers = { workspace = true }
|
|||||||
harness-tools = { workspace = true }
|
harness-tools = { workspace = true }
|
||||||
harness-mcp = { workspace = true }
|
harness-mcp = { workspace = true }
|
||||||
harness-lsp = { 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]
|
[lints]
|
||||||
workspace = true
|
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]
|
[dependencies]
|
||||||
harness-app = { workspace = true }
|
harness-app = { workspace = true }
|
||||||
|
harness-core = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,3 +1,86 @@
|
|||||||
fn main() {
|
use harness_core::event::RunOutcome;
|
||||||
println!("harness {}", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
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