# M2b task 06: one turn **Branch:** `m2b` (run `git switch m2b`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add the turn loop with its limits` ## Goal A user message in, an answer out, with tool calls in between and a limit on every kind of runaway. This is the centre of M2b. Two test files define it: `turn.rs` for the record sequences and the tool path, `limits.rs` for the limits and for the append-only property over generated conversations. ## Context From the brief: "Runaway control. Per-turn thinking-token cap, per-turn tool-iteration cap, detection of repeated identical tool calls." The thinking cap is in M2a. The other two are here, with two more: a full context, and the size of a tool result. The recordings used by the tests were made in separate conversations, so their cache numbers do not line up. The loop will rightly write `CacheLoss` records between them; the tests allow for that. ## Files - Copy: `crates/loopd/tests/turn.rs`, `crates/loopd/tests/limits.rs`, `crates/loopd/tests/support/turn.rs`, and three recordings into `crates/loopd/tests/fixtures/http/`: `find_tool.http`, `call_tool.http`, `context_full.http` - Create: `crates/loopd/src/turn.rs` - Modify: `crates/loopd/src/lib.rs`, `docs/implementer-log.md` ## Interfaces ```rust pub enum TurnError { SessionFull, TurnLimit, Infer(InferError), Session(SessionError) } // Debug; Display; Error; From pub struct TurnOutcome { pub content: String, pub usage: proto::Usage } // Debug, Clone, PartialEq, Eq /// What a turn needs besides the session. pub struct Runtime<'a> { pub cfg: &'a Config, pub client: &'a Client, pub port: &'a dyn ToolPort, pub registry: &'a Registry } /// True for the one 400 the server sends when the prompt does not fit. pub fn is_context_full(error: &InferError) -> bool; pub fn run_turn(session: &mut Session, rt: &Runtime<'_>, content: &str, on_event: &mut dyn FnMut(&TurnEvent)) -> Result; ``` `is_context_full`: `Http { status: 400, body }` whose body is JSON with `error.type` equal to `"exceed_context_size_error"`. Look at `fixtures/http/context_full.http` to see one. ## What `run_turn` does 1. Append `User { time: now, content }`. 2. Build a `ChatRequest`: `slot: cfg.slots.main`, `messages: messages(baseline, records)`, `tools: baseline.tools.clone()`, `thinking: true`. Remember `session.last_usage()` from **before** this completion. 3. `client.chat_with_retry`. Map each `ChatEvent` to a `TurnEvent` and pass it on: `Queued`, `Waiting`, `Progress`, `Reasoning`, `Content`, `ThinkingCapped`, `Retrying` map one to one; `ToolCallDelta` is dropped (the channel gets `ToolCallStarted` later instead). On `Err(e)`: if `is_context_full(&e)`, return `SessionFull`; else return `Infer(e)`. Nothing is appended for the failed request. 4. Append `Assistant` with the completion's three fields, then `Usage` with its timings, `reasoning_tokens` and `thinking_capped`. If there was an earlier usage, run `cache_outcome` on its timings and the new ones; on a `Loss`, append `CacheLoss` and emit `TurnEvent::CacheLoss`. 5. No tool calls: return `TurnOutcome { content: content.unwrap_or_default(), usage }`. 6. Otherwise count one iteration; if the count exceeds `cfg.loop.tool_iterations`, return `TurnLimit`. Then for each tool call in order: a. Emit `ToolCallStarted { name }`. Take `session.next_call()` as this call's id. b. Repeat detection, when `cfg.loop.repeat_detection`: the pair (name, arguments) has been seen in this turn already. The first repeat is not run; its result text says the call was already made with these arguments in this turn. A second repeat returns `TurnLimit`. c. Otherwise `dispatch(registry, name, arguments)`. `Local(text)` is the result, with class `Public` and `untrusted: false`. `Port { tool, arguments }` builds a `ToolRequest` with the session id and the call id and sends it to the port; `Result` gives its content, class and untrusted flag; `Failed { message }` gives "The tool failed: …", Public, not untrusted; `Denied { reason }` gives "The call was denied: …"; `PendingApproval` gives a text saying this version cannot wait for approval. d. `cap_result(text, cfg.loop.tool_result_cap)`, then append `ToolResult { time, call, tool_call_id: , content, class, untrusted, truncated }`, then emit `TurnEvent::ToolResult { name, class, truncated }`. 7. Back to step 2. Every `time` is `Timestamp::now()`. It goes into the log only, never into a message. ## Steps - [ ] **1. Copy.** ```sh git switch m2b cp docs/plans/M2b/files/crates/loopd/tests/turn.rs docs/plans/M2b/files/crates/loopd/tests/limits.rs crates/loopd/tests/ cp docs/plans/M2b/files/crates/loopd/tests/support/turn.rs crates/loopd/tests/support/ cp docs/plans/M2b/files/crates/loopd/tests/fixtures/http/*.http crates/loopd/tests/fixtures/http/ ``` Read `limits.rs` first: each limit has a test, and the last test is the property the milestone exists to prove. - [ ] **2. See the tests fail.** `cargo test -p loopd --test turn`. Expected: it does not compile. - [ ] **3. Write `turn.rs`** and add `pub mod turn;` to `lib.rs`. Run `cargo fmt --all`. - [ ] **4. See the tests pass.** `cargo test -p loopd --test turn --test limits`. Expected: `9 passed` and `6 passed`. - [ ] **5. Check the limits yourself.** For each of the five limits in the spec's table (tool iterations, repeated call, thinking cap, context full, tool result size), name the test that covers it. Put the list in your log row. - [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. - [ ] **7. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit` ## Done when - `cargo test -p loopd --test turn --test limits` reports 9 and 6 passed; `make gate` prints `gate: ok`. - `turn.rs` is under 300 lines. If it is not, something is being done twice. ## Stop and report if - `every_request_extends_the_previous_one` fails. Do not change the seeds or the test; the seed is printed so that the case can be replayed.