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.
6.0 KiB
02 — Engine: Agent Loop, Permissions, Retry
Direct port of opencode's two-level loop (packages/opencode/src/session/prompt.ts outer runLoop + session/processor.ts inner stream state machine) onto tokio.
Outer loop
engine/loop.rs::run_session — one tokio task per active session, tracked in Engine.runs: Mutex<HashMap<SessionId, RunHandle>> where RunHandle { cancel: CancellationToken, join: JoinHandle<RunOutcome> }.
loop:
msgs = store.messages(session)
if last assistant message finished with reason != ToolCalls and has no pending tool parts:
break (Stop)
agent = registry.get(session.agent)
system = system_prompt(agent, env_header, custom_instructions)
tools = tool_registry.for_agent(agent) // filtered by agent tool permissions
req = build_request(model, system, convert(msgs), tools, temperature)
// + job-board synthetic part appended to last user message if agent.mode == Primary
step = run_step(req) // inner processor, wrapped in retry policy
match step { Continue => loop, Stop => break, Compact => compact(); loop }
Exit condition matches opencode: the loop keeps stepping as long as the model finished with ToolCalls (tool results need to be fed back) and stops once it produces a final answer. agent.max_steps bounds iterations; when exceeded, append a final assistant-role nudge message (opencode's MAX_STEPS_PROMPT behavior).
Inner processor
engine/processor.rs — a state machine struct consuming the LlmEventStream:
struct Processor<'a> {
assistant: Message, // created on first event, persisted immediately
parts: Vec<Part>,
active_text: Option<PartId>,
active_reasoning: Option<PartId>,
pending_tools: HashMap<String /*call_id*/, PartId>,
flusher: DeltaFlusher, // buffers TextDelta; flush to store every 50 ms or 2 KB;
// emits AppEvent::PartDelta immediately (UI realtime,
// SQLite writes throttled)
}
Event handling:
TextStart→ newTextpart.TextDelta→ append +PartDeltaevent. Same forReasoning*.ToolInputStart→Toolpart inPending(streaming partial JSON shown live in the TUI).ToolCall→ transition toRunning, then execute inline, sequentially, in stream order (matching opencode): doom-loop check → tool lookup →tool.execute(input, ctx)underselect!withcancel→ persistCompleted/Errorstate →PartUpdated.Finish→ writeStepFinishpart, add usage/cost to message + session, return:
enum StepResult { Continue, Stop, Compact }
// Continue if reason == ToolCalls
// Compact if usage.input + usage.output > model.context_limit * 0.90 (seam, stub until M6)
// Stop otherwise
Permission engine
permission/rule.rs + permission/service.rs — port of packages/opencode/src/permission/index.ts.
pub struct Rule { pub permission: String, pub pattern: String, pub action: Action }
pub enum Action { Allow, Deny, Ask }
pub type Ruleset = Vec<Rule>;
evaluate(permission, pattern, stack): flatten[config_rules, agent_rules, session.extra_rules]in that order, find the last rule where wildcards match bothpermissionandpattern; defaultAsk. Wildcard =*matching across separators (globsetwithliteral_separator(false)).ctx.ask(AskInput { permission, patterns, always_patterns, metadata })flow:- Evaluate each pattern.
Deny→Err(Denied)→ tool returns an error telling the model the action was denied (loop continues, model can adjust). Ask→ insert aoneshot::Sender<Reply>into the pending map, publishAppEvent::PermissionAsked, thenselect! { reply, cancel }.- Reply
Once→ proceed.Always→ appendRule { permission, pattern: always_pattern, action: Allow }tosession.extra_rules(persisted), proceed.Reject→Err(Rejected)→ tool error.
- Evaluate each pattern.
- Subagent sessions evaluate against the intersection of parent-effective and child rulesets (see 04-multiagent.md).
Retry policy
engine/retry.rs — wraps the start of a step only; once any event has been consumed we surface the error rather than retry (opencode behavior).
- Retryable:
RateLimited,Overloaded,Network,Http { 5xx }. - Delay:
retry-after/retry-after-msheader if present, elsemin(2s · 2^attempt, 30s); max 8 attempts; every wait raced againstcancel. ContextOverflow: never retried →StepResult::Compact(until compaction lands: error notice advising a new session).Auth: one token-refresh attempt, then retry once.
Doom-loop guard
engine/doomloop.rs: keep the last 3 (tool_name, blake3(input_json)) per run; if the next call equals all 3, force a ctx.ask with permission key doom_loop regardless of ruleset ("model appears stuck repeating {tool}"). Ported from opencode's DOOM_LOOP_THRESHOLD = 3.
Cancellation
RunHandle.cancel is the root token. Tools receive cancel.child_token(); subagent runs receive another child. On abort:
- in-flight tool parts →
ToolState::Error { error: "interrupted" } - the assistant message is marked
error: Aborted RunFinished { Aborted }is emitted- provider HTTP streams end because the reqwest futures are dropped inside
select!
Compaction seam (v1.5 / M6)
trait Compactor { async fn compact(&self, session: &Session, msgs: &[Message]) -> Result<Summary> }
The loop already branches on StepResult::Compact. M1–M5 ship a stub. M6 implements: summarize history via small_model, write a Compaction part, and have convert(msgs) cut history at the compaction point.
System prompt assembly
engine/system.rs: ordered blocks — environment header (cwd, platform, date, git status), agent prompt (markdown body from the agent definition), project instructions (config.instructions files, e.g. AGENTS.md), skills index (names + descriptions). Blocks stay separate in LlmRequest.system: Vec<String> because Anthropic cache breakpoints need the structure.