Files
ai-harness/docs/02-engine.md
T
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

106 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```rust
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*`.
- `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<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. `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<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. `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<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.