docs: spell out the turn and round model

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.
This commit is contained in:
2026-10-17 17:37:22 +08:00
parent 78b764f70e
commit 729627eee9
3 changed files with 103 additions and 0 deletions
+4
View File
@@ -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`.
+2
View File
@@ -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).
+97
View File
@@ -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`.