M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor

Seven-crate workspace per docs/01-architecture.md. harness-core gets the
domain types (Session/Message/Part/ToolState), a broadcast EventBus, the
last-match-wins wildcard permission evaluator, and a SQLite storage actor
(dedicated thread + mpsc, JSON-blob rows) with roundtrip tests. All other
crates are compiling stubs. CI runs fmt/clippy -D warnings/test.
This commit is contained in:
Erik Simon
2026-07-08 16:28:42 +02:00
commit 8b16348b4c
44 changed files with 2988 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
# 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.