From 729627eee9a49751b5a1034e711d822f9ab2d7f4 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 17 Oct 2026 17:37:22 +0800 Subject: [PATCH] docs: spell out the turn and round model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc/turns-and-rounds.md defines the two nested units the loop is built on: a round is one request→response plus the tool batch it asked for, a turn is one user message through the explicit stop (0..N rounds), and a session is a stored sequence of turns. It records what bounds each unit (max_rounds, truncation continues, retries), which work is round-scoped (request assembly, context reduction, the aggregated soft confirm) and which state resets per turn (read-before-edit, yyy auto-continue, /yolo once, the todo touched flag, usage totals) versus what outlives it (rows, todo stack, grants, reminder bands). Linked from the architecture doc, with the short version in AGENTS.md. --- AGENTS.md | 4 ++ doc/architecture.md | 2 + doc/turns-and-rounds.md | 97 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 doc/turns-and-rounds.md diff --git a/AGENTS.md b/AGENTS.md index bd2731c..0005a52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `p ## Architecture +### Conversation units: turn vs round + +A **round** is one model round trip: the request, the single assistant message it produced, and the tool batch that message asked for (one `UsageEvent` each). A **turn** is one user message through the explicit stop — the first round whose assistant message asks for no tools — and holds 0..N rounds; `retry` continues the *same* turn instead of opening a new one. `max_rounds` (32 by default) bounds rounds per turn. Turn-scoped state (read-before-edit, `yyy` auto-continue, `/yolo once`, todo-nag "touched" flag, `last_turn_usage`/`last_turn_rounds`) resets on the next user message; session-scoped state (rows, todo stack, grants) does not. Details: [doc/turns-and-rounds.md](doc/turns-and-rounds.md). + ### Data modeling: `msgspec.Struct` All protocol models use `msgspec.Struct` — not dataclasses, not Pydantic. Optional fields use `msgspec.field(default=UNSET)` / `default=UNSET` with type `T | Unset`. diff --git a/doc/architecture.md b/doc/architecture.md index 4d7d6c0..b059003 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -37,4 +37,6 @@ - web: Web service (Phase H; not implemented). Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md). +Conversation units (a *round* = one request→response plus its tool batch, a +*turn* = a user message to the explicit stop): [turns-and-rounds.md](./turns-and-rounds.md). diff --git a/doc/turns-and-rounds.md b/doc/turns-and-rounds.md new file mode 100644 index 0000000..3aa7555 --- /dev/null +++ b/doc/turns-and-rounds.md @@ -0,0 +1,97 @@ +# Turns and Rounds + +Two nested units of work carry most of the loop's state. A **round** is one +request→response exchange with the provider plus the tool calls it asked for; a +**turn** is everything between a user message and the explicit stop that ends it. +A session is a stored sequence of turns. + +``` +turn ─┬─ round ─┬─ request (hosted tools, history, system prompt) + │ ├─ response (one assistant message) + │ └─ tool batch (0..n calls, executed before the next round) + ├─ round … + └─ explicit stop (a round with no tool calls) +``` + +## Round — one model round trip + +A round is one completion request and the single assistant message it produced, +followed by the tool calls that message asked for (one batch). The next round +starts only after that batch has reported results. + +- **Identity.** One `UsageEvent` per round: `ChatAgent` counts them into + `last_turn_rounds` (`agent/chat.py`). `AssistantMessageEvent` carries the + response, `ToolCallEvent`/`ToolResultEvent` the batch. The transcript's + `rounds=N` label counts the rounds that asked for tools (every round but the + final one), so `/history` and `last_turn_rounds` can differ by that stop round. +- **Ends.** In one of four ways: with tool calls (the loop continues), with no + tool calls (natural stop → the turn ends), at the output limit + (`TruncatedEvent`, `finish_reason=length`: answer and thinking stay, that + round's tool calls are dropped and a notice is appended; up to + `max_truncation_continues` more rounds of the same turn carry on from there), + or in an error (a retry re-runs the round). +- **Bounds.** `max_rounds` per turn (default `DEFAULT_MAX_ROUNDS = 32`, + `agent/loop.py`). Reaching the allowance yields `MaxRoundsEvent(rounds, + continued=False)` — `rounds` is the allowance that ran out — unless the + `on_limit` hook answers yes (CLI: `y`/`n`/`yyy` prompt in `cli/limits.py`): + then the event says `continued=True` and another `max_rounds` are granted. +- **Parallelism.** Calls of one round run concurrently (`parallel_tools`), but + calls naming the same file serialize through per-file write locks, and the + soft confirm for a round is decided once, before the batch runs + (`ToolRegistry.prepare_batch_confirm`). +- **Round-scoped work.** Request assembly, context reduction / tool-result + truncation over `max_context_tokens`, provider-tool (hosted search) blocks, + and the usage sample are all per round. +- **Not durable on its own.** Only committed prefixes are persisted as they + happen — a complete tool batch with its results, plus trailing developer + notices. A final text answer is committed at turn end, so a turn that dies + mid-round leaves its side effects in history but not a half-written answer. + +## Turn — one user message to the explicit stop + +A turn begins when the user message is appended (`ChatAgent.run`, which also +persists the message immediately) and ends at the explicit stop. + +- **Explicit stop.** The first round whose assistant message has no tool calls. + A turn also ends at a boundary: `max_rounds` without continuing, + truncation-continues exhausted (left for `/retry`), cancellation (Ctrl+C), or + an unrecovered error — the tail is rolled back to the committed prefix and the + user message stays so `/retry` can continue. +- **Rounds per turn.** Zero (a failure before the first response) to many. + `retry` continues the *same* turn — it reuses the user message instead of + appending a new one — so retries never create another turn. +- **Turn-scoped state** (reset at the start of the next turn): + - read-before-edit: line-numbered `read_file` authorizes `edit_lineno` for that + path (mtime-checked) for this turn only (`reset_lineno_tracker`); + - `yyy` limit auto-continue: cleared before and after + `run_turn_with_retries`, so a retry of the same turn keeps it and the next + user turn prompts again; + - `/yolo once`: expires when the turn finishes (`expire_yolo_once`), so it + covers exactly one turn; + - todo nagging: `todo_stack.begin_turn()` clears the "touched" flag; any + `todo_*` tool sets it again, and `needs_review()` at the stop decides whether + a stack untouched this turn gets the review reminder; + - usage totals: `last_turn_usage` and `last_turn_rounds` sum the turn's rounds + (`last_request_usage` keeps the last round's sample). +- **Not turn-scoped.** Session identity and stored rows, the todo stack's + contents, soft-confirm trust (path grants, access/skill grants, `TRUSTABLE` + tool grants), and directive-reminder bands outlive the turn. + +## Session + +A session is the stored sequence of turns; the transcript groups rows by turn, +and `/history N` addresses a turn by number. Compaction rewrites the history sent +in one request and never moves a turn boundary. A side turn (`run_aside`) is a +turn whose history is forked and never written back. + +## Where it lives + +| unit | begins | ends | counters / records | +| --- | --- | --- | --- | +| round | provider request | response assembled, tool batch done | `UsageEvent`, `last_turn_rounds`, `MaxRoundsEvent`, `TruncatedEvent` | +| turn | `ChatAgent.run` appends the user message | a round with no tool calls (or a boundary) | session rows, `last_turn_usage` | +| session | first turn | process exit / session switch | store tables, transcript | + +Loop mechanics: `agent/loop.py` (`run_chat_loop`); turn bookkeeping: +`agent/chat.py` (`ChatAgent.run` / `retry` / `_run_from_user_message`); retry and +turn-scoped flags: `cli/retry.py`, `cli/limits.py`.