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

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 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

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.
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.