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.
106 lines
6.0 KiB
Markdown
106 lines
6.0 KiB
Markdown
# 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`. 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.
|