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>
117 lines
6.0 KiB
Markdown
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.
|