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.
5.4 KiB
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
UsageEventper round:ChatAgentcounts them intolast_turn_rounds(agent/chat.py).AssistantMessageEventcarries the response,ToolCallEvent/ToolResultEventthe batch. The transcript'srounds=Nlabel counts the rounds that asked for tools (every round but the final one), so/historyandlast_turn_roundscan 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 tomax_truncation_continuesmore rounds of the same turn carry on from there), or in an error (a retry re-runs the round). - Bounds.
max_roundsper turn (defaultDEFAULT_MAX_ROUNDS = 32,agent/loop.py). Reaching the allowance yieldsMaxRoundsEvent(rounds, continued=False)—roundsis the allowance that ran out — unless theon_limithook answers yes (CLI:y/n/yyyprompt incli/limits.py): then the event sayscontinued=Trueand anothermax_roundsare 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_roundswithout 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/retrycan continue. - Rounds per turn. Zero (a failure before the first response) to many.
retrycontinues 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_fileauthorizesedit_linenofor that path (mtime-checked) for this turn only (reset_lineno_tracker); yyylimit auto-continue: cleared before and afterrun_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; anytodo_*tool sets it again, andneeds_review()at the stop decides whether a stack untouched this turn gets the review reminder; - usage totals:
last_turn_usageandlast_turn_roundssum the turn's rounds (last_request_usagekeeps the last round's sample).
- read-before-edit: line-numbered
- Not turn-scoped. Session identity and stored rows, the todo stack's
contents, soft-confirm trust (path grants, access/skill grants,
TRUSTABLEtool 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.