Files
darman 420f00494f M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor
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.
2026-07-10 16:19:27 +02:00

6.0 KiB
Raw Permalink Blame History

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 → new Text part. TextDelta → append + PartDelta event. Same for Reasoning*.
  • ToolInputStartTool part in Pending (streaming partial JSON shown live in the TUI).
  • ToolCall → transition to Running, then execute inline, sequentially, in stream order (matching opencode): doom-loop check → tool lookup → tool.execute(input, ctx) under select! with cancel → persist Completed/Error state → PartUpdated.
  • Finish → write StepFinish part, 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 both permission and pattern; default Ask. Wildcard = * matching across separators (globset with literal_separator(false)).
  • ctx.ask(AskInput { permission, patterns, always_patterns, metadata }) flow:
    1. Evaluate each pattern. DenyErr(Denied) → tool returns an error telling the model the action was denied (loop continues, model can adjust).
    2. Ask → insert a oneshot::Sender<Reply> into the pending map, publish AppEvent::PermissionAsked, then select! { reply, cancel }.
    3. Reply Once → proceed. Always → append Rule { permission, pattern: always_pattern, action: Allow } to session.extra_rules (persisted), proceed. RejectErr(Rejected) → tool error.
  • 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-ms header if present, else min(2s · 2^attempt, 30s); max 8 attempts; every wait raced against cancel.
  • 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. M1M5 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.