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>
6.0 KiB
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 intocrates/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
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
- Append
User { time: now, content }. - Build a
ChatRequest:slot: cfg.slots.main,messages: messages(baseline, records),tools: baseline.tools.clone(),thinking: true. Remembersession.last_usage()from before this completion. client.chat_with_retry. Map eachChatEventto aTurnEventand pass it on:Queued,Waiting,Progress,Reasoning,Content,ThinkingCapped,Retryingmap one to one;ToolCallDeltais dropped (the channel getsToolCallStartedlater instead). OnErr(e): ifis_context_full(&e), returnSessionFull; else returnInfer(e). Nothing is appended for the failed request.- Append
Assistantwith the completion's three fields, thenUsagewith its timings,reasoning_tokensandthinking_capped. If there was an earlier usage, runcache_outcomeon its timings and the new ones; on aLoss, appendCacheLossand emitTurnEvent::CacheLoss. - No tool calls: return
TurnOutcome { content: content.unwrap_or_default(), usage }. - Otherwise count one iteration; if the count exceeds
cfg.loop.tool_iterations, returnTurnLimit. Then for each tool call in order: a. EmitToolCallStarted { name }. Takesession.next_call()as this call's id. b. Repeat detection, whencfg.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 returnsTurnLimit. c. Otherwisedispatch(registry, name, arguments).Local(text)is the result, with classPublicanduntrusted: false.Port { tool, arguments }builds aToolRequestwith the session id and the call id and sends it to the port;Resultgives its content, class and untrusted flag;Failed { message }gives "The tool failed: …", Public, not untrusted;Denied { reason }gives "The call was denied: …";PendingApprovalgives a text saying this version cannot wait for approval. d.cap_result(text, cfg.loop.tool_result_cap), then appendToolResult { time, call, tool_call_id: <the model's call id>, content, class, untrusted, truncated }, then emitTurnEvent::ToolResult { name, class, truncated }. - Back to step 2.
Every time is Timestamp::now(). It goes into the log only, never into a message.
Steps
- 1. Copy.
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.rsand addpub mod turn;tolib.rs. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p loopd --test turn --test limits. Expected:9 passedand6 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 limitsreports 9 and 6 passed;make gateprintsgate: ok.turn.rsis under 300 lines. If it is not, something is being done twice.
Stop and report if
every_request_extends_the_previous_onefails. Do not change the seeds or the test; the seed is printed so that the case can be replayed.