420f00494f
Sets up the Cargo workspace (harness-core, harness-tools, harness-providers, harness-mcp, harness-lsp, harness-app, harness-tui) and the ten architecture docs under docs/. harness-core gets its foundational types (session/message/part/model ids), an in-process event bus, a table-driven permission evaluate function, and a SQLite-backed storage actor with schema and roundtrip coverage. Every crate compiles empty and a bin stub prints its version.
12 KiB
12 KiB
01 — Architecture
Cargo workspace layout
ai-harness/
├── Cargo.toml # [workspace] members, workspace.dependencies, lints
├── crates/
│ ├── harness-core/ # domain types, event bus, permission engine, session engine,
│ │ # agent loop, agent registry, job board, config, storage, auth.json
│ ├── harness-providers/ # Provider impls: copilot, openai, anthropic; auth flows; models.dev
│ ├── harness-tools/ # built-in tools: bash, read, edit, write, glob, grep, todo, webfetch, task
│ ├── harness-mcp/ # rmcp stdio client → Tool adapters
│ ├── harness-lsp/ # LSP client pool + diagnostics service
│ ├── harness-app/ # composition root: builds Engine, registers providers/tools/mcp/lsp
│ └── harness-tui/ # binary `harness`: ratatui frontend over EngineHandle + event bus
└── assets/
├── agents/*.md # bundled default agent definitions (embedded via include_str!)
├── prompts/*.md # system prompt fragments (tool usage rules, environment header)
└── models-snapshot.json # compiled fallback of models.dev/api.json
Boundary rationale
harness-coreowns the vocabulary (Session/Message/Part,AppEvent,LlmEvent,Tooltrait,Providertrait,Rule/Ruleset) and the engine (run loop, stream processor, permission service, storage actor, job board). Everything depends inward on core; core depends on nothing else in the workspace. This is the crate a futureharness-serverwraps:AppEventisSerialize, so an SSE endpoint isbus.subscribe() → serde_json → SSE frames, andEngineHandleis the future HTTP API surface (prompt, abort, permission reply, list sessions, login).- Feature crates never depend on each other. Tools reach LSP through a core trait (
DiagnosticsSource, see 09-integrations.md), keepingharness-toolsdecoupled fromharness-lsp. harness-appis the only crate that knows about everything. It reads config, constructs theEngine, registers built-ins.harness-tuitoday andharness-servertomorrow both depend onharness-appand get an identical engine.harness-tuicontains zero business logic — it readsAppEvents and callsEngineHandlemethods. This enforces the "TUI is a client" discipline opencode gets from its HTTP split, without the HTTP.
Config and storage live inside harness-core (modules config, store) rather than separate crates — they're consumed only by core and app; splitting creates dependency churn for no benefit at this size.
harness-core module map
src/
types/ # ids.rs, session.rs, message.rs, part.rs, model.rs (ModelRef, ModelInfo, TokenUsage)
event.rs # AppEvent + EventBus (broadcast wrapper)
llm.rs # LlmEvent, LlmRequest, FinishReason, ProviderError, Provider trait
tool.rs # Tool trait, ToolCtx, ToolOutput, ToolRegistry, truncation helper
permission/ # rule.rs (evaluate), service.rs (pending asks, oneshots)
agent/ # def.rs (AgentDef), registry.rs (bundled + override loading), frontmatter.rs
engine/ # engine.rs (Engine, EngineHandle, RunHandle), loop.rs (outer loop),
# processor.rs (stream state machine), retry.rs, doomloop.rs,
# compaction.rs (seam), system.rs (system prompt assembly), jobs.rs (JobBoard)
config/ # schema.rs (serde), load.rs (discovery + merge + interpolation), markdown.rs
store/ # schema.sql, actor.rs (rusqlite thread + mpsc), api.rs (typed async facade)
auth.rs # auth.json read/write, mode 0600
Dependencies
| Crate | Why |
|---|---|
tokio (full) |
async runtime; sync::{broadcast, mpsc, oneshot, Mutex}, process, select! |
tokio-util |
CancellationToken (abort tree: session run → tool → subagent via child_token()) |
futures, async-stream |
Stream combinators; try_stream! to write SSE decoders as readable generators |
reqwest (rustls-tls, stream, json, gzip) |
all HTTP; bytes_stream() feeds SSE decoding |
eventsource-stream |
SSE frame parsing over any byte stream (CRLF, multi-line data:, comments); mature, tiny |
serde, serde_json |
everything; RawValue for pass-through tool schemas |
schemars |
derive JSON Schema for tool parameter structs (single source of truth for tool inputs) |
serde_yaml_ng |
YAML frontmatter of agent/command/skill markdown (serde_yaml proper is archived) |
jsonc-parser |
JSONC config files (comments + trailing commas, like opencode) |
rusqlite (bundled) |
storage; runs on a dedicated thread behind an mpsc actor — simpler than sqlx for a single-writer embedded DB |
ratatui + crossterm (event-stream) |
TUI; crossterm::event::EventStream integrates with tokio::select! |
tui-textarea |
multi-line prompt input widget (cursor, kill-ring) |
pulldown-cmark |
markdown parsing for the chat renderer (we write the ratatui renderer) |
rmcp (client, transport-child-process) |
official Rust MCP SDK, stdio client |
lsp-types |
LSP protocol types; JSON-RPC framing is ~150 lines hand-rolled (client crates are immature) |
ignore + globset |
glob tool (gitignore-aware walking) and permission wildcard matching |
grep-searcher, grep-regex, grep-matcher |
ripgrep's engine as a library — no external rg binary dependency |
similar |
unified diffs for edit/write permission metadata and part records |
ulid |
sortable ids for sessions/messages/parts (chronological ORDER BY id) |
thiserror / anyhow |
error enums in libs; anyhow only in binaries |
tracing + tracing-subscriber + tracing-appender |
logging to file (the TUI owns the terminal) |
dirs |
XDG paths (~/.config/ai-harness, ~/.local/share/ai-harness, ~/.cache/ai-harness) |
htmd (or html2text) |
webfetch HTML → markdown |
shell-words |
tokenize bash commands for permission pattern building |
insta (dev) |
snapshot tests for decoders and TUI TestBackend renders |
wiremock (dev) |
HTTP mocking for provider/auth tests |
Deliberately avoided: actor frameworks, generic middleware, sqlx (compile-time SQL is overkill for 5 tables). Native async fn in traits where object safety allows; #[async_trait] for Tool/Provider since dyn is needed.
Domain model
// types/ids.rs — newtypes over ULID strings
pub struct SessionId(pub String); // "ses_01H..."
pub struct MessageId(pub String);
pub struct PartId(pub String);
// types/session.rs
pub struct Session {
pub id: SessionId,
pub parent_id: Option<SessionId>,
pub depth: u8, // 0 = root; computed at creation from parent
pub title: String,
pub agent: String,
pub model: ModelRef, // { provider_id, model_id }
pub usage: TokenUsage, // running totals
pub cost: f64,
pub extra_rules: Ruleset, // session-scoped "always allow" rules
pub created_at: i64, pub updated_at: i64,
}
pub struct TokenUsage { pub input: u64, pub output: u64, pub reasoning: u64,
pub cache_read: u64, pub cache_write: u64 }
// types/message.rs
pub enum Role { User, Assistant }
pub struct Message {
pub id: MessageId, pub session_id: SessionId, pub role: Role,
pub model: Option<ModelRef>, pub agent: Option<String>,
pub usage: TokenUsage, pub cost: f64,
pub finished: Option<FinishReason>, // None while streaming
pub error: Option<MessageError>, // aborted | provider error | output-length
pub created_at: i64,
}
// types/part.rs — discriminated union, serde(tag = "type")
pub struct Part { pub id: PartId, pub message_id: MessageId, pub session_id: SessionId,
pub idx: u32, pub body: PartBody }
pub enum PartBody {
Text { text: String, synthetic: bool }, // synthetic = injected (job board, reminders)
Reasoning { text: String, signature: Option<String> },
Tool { call_id: String, name: String, state: ToolState },
StepStart,
StepFinish{ usage: TokenUsage, cost: f64, reason: FinishReason },
Subtask { child_session: SessionId, agent: String, description: String, background: bool },
Compaction{ replaces_up_to: MessageId, summary: String }, // v1.5
}
pub enum ToolState {
Pending { partial_input: String }, // streaming JSON args
Running { input: serde_json::Value, title: Option<String>, metadata: serde_json::Value },
Completed { input: serde_json::Value, title: String, output: String,
metadata: serde_json::Value, duration_ms: u64 },
Error { input: serde_json::Value, error: String },
}
// llm.rs — normalized provider stream
pub enum LlmEvent {
TextStart { id: String }, TextDelta { id: String, text: String }, TextEnd { id: String },
ReasoningStart { id: String }, ReasoningDelta { id: String, text: String },
ReasoningEnd { id: String, signature: Option<String> },
ToolInputStart { call_id: String, name: String },
ToolInputDelta { call_id: String, json: String },
ToolCall { call_id: String, name: String, input: serde_json::Value }, // args complete
Finish { reason: FinishReason, usage: TokenUsage },
}
pub enum FinishReason { Stop, ToolCalls, Length, ContentFilter, Unknown(String) }
pub type LlmEventStream = Pin<Box<dyn Stream<Item = Result<LlmEvent, ProviderError>> + Send>>;
pub enum ProviderError {
RateLimited { retry_after: Option<Duration> },
Overloaded, // 5xx / "overloaded_error"
ContextOverflow, // NEVER retried
Auth(String), // triggers token refresh path
Http { status: u16, body: String },
Network(String), Decode(String), Cancelled,
}
// tool.rs
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value; // JSON Schema (schemars)
async fn execute(&self, input: serde_json::Value, ctx: ToolCtx) -> Result<ToolOutput, ToolError>;
}
pub struct ToolCtx {
pub session_id: SessionId, pub message_id: MessageId, pub call_id: String,
pub agent: Arc<AgentDef>,
pub cwd: PathBuf, pub worktree: PathBuf,
pub cancel: CancellationToken, // linked to run token
pub ask: PermissionHandle, // async ask(AskInput) -> Result<(), Denied|Rejected>
pub metadata: MetadataSink, // set(Value) — live-updates Running ToolState + emits PartUpdated
pub engine: EngineHandle, // needed by the task tool; cheap Arc clone
}
pub struct ToolOutput { pub title: String, pub output: String, pub metadata: serde_json::Value }
// event.rs — serializable so a future server can pipe it to SSE unchanged
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AppEvent {
SessionCreated { session: Session }, SessionUpdated { session: Session },
MessageCreated { message: Message }, MessageUpdated { message: Message },
PartUpdated { part: Part }, // full-part upsert (simple, correct)
PartDelta { part_id: PartId, message_id: MessageId, delta: String }, // cheap text streaming
RunStarted { session_id: SessionId },
RunFinished { session_id: SessionId, outcome: RunOutcome },
PermissionAsked { request: PermissionRequest }, // {id, session_id, permission, patterns, metadata}
PermissionResolved{ id: String },
JobUpdated { job: JobRecord },
AuthPrompt { provider: String, user_code: String, verification_uri: String },
ServerNotice{ level: Level, text: String },
}
pub struct EventBus(broadcast::Sender<AppEvent>); // capacity 1024; subscribers tolerate Lagged
Channel topology
Provider SSE ──(Stream<LlmEvent>)──▶ Processor ──▶ StorageTx (mpsc<StoreCmd>, oneshot replies)
│
└──▶ EventBus (broadcast<AppEvent>) ──▶ TUI event loop
└─▶ (future) SSE handler
Tool.execute ── ctx.ask() ──▶ PermissionService.pending: HashMap<id, oneshot::Sender<Reply>>
TUI keypress ── EngineHandle::permission_reply(id, Once|Always|Reject) ──▶ oneshot ──▶ tool resumes
EngineHandle::abort(session) ──▶ RunHandle.cancel.cancel() ──▶ token tree (tools, subagents, HTTP)
Background subagent: tokio::spawn(run_loop(child)) → completion → JobBoard.update → AppEvent::JobUpdated