Files
kyleandClaude Fable 5.1 e156975649 Add M2b plan: ten tasks, tests, recordings and the first system prompt
The tasks build the agent loop on M2a's client: channel messages and
the usage record in proto, four config tables, the tool port and
registry with find_tool and call_tool, the baseline and replay, the
session store, the turn loop with its limits and the append-only
property test, the channel server, loopd serve, bxctl chat, and the
device checks including a four-turn conversation with a restart.

Checked against a private reference implementation: the gate passes
after every task in order, the new suites pass under CPU load, and the
reference passes make verify-device on straylight with no cache loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:19:02 -07:00

117 lines
6.0 KiB
Markdown

# 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<SessionError>
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<TurnOutcome, TurnError>;
```
`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: <the model's 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.