# 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>` where `RunHandle { cancel: CancellationToken, join: JoinHandle }`. ``` 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`: ```rust struct Processor<'a> { assistant: Message, // created on first event, persisted immediately parts: Vec, active_text: Option, active_reasoning: Option, pending_tools: HashMap, 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*`. - `ToolInputStart` → `Tool` 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: ```rust 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`. ```rust pub struct Rule { pub permission: String, pub pattern: String, pub action: Action } pub enum Action { Allow, Deny, Ask } pub type Ruleset = Vec; ``` - `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. `Deny` → `Err(Denied)` → tool returns an error telling the model the action was denied (loop continues, model can adjust). 2. `Ask` → insert a `oneshot::Sender` 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. `Reject` → `Err(Rejected)` → tool error. - Subagent sessions evaluate against the **intersection** of parent-effective and child rulesets (see [04-multiagent.md](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) ```rust trait Compactor { async fn compact(&self, session: &Session, msgs: &[Message]) -> Result } ``` 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` because Anthropic cache breakpoints need the structure.