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-10 16:19:38 +02:00
parent 9ed278bcb5
commit d83f8c84b7
5 changed files with 320 additions and 3 deletions
+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, "");
}
}