Skip to content

Reasoning and the loop guard

Qwen3.8 thinks before it answers: with thinking on (the default) it writes a reasoning block, opened by <think> and closed by </think>, and then the answer or the tool call. The server separates the two on the closing tag and returns the reasoning as reasoning_content, the answer as content. Two things can go wrong inside the block: it can end without the model ever closing it, and it can loop. This page explains how the server handles each, and the budget that bounds both. The fields are in Prompting.

--model-enable-thinking (on by default) and --model-reasoning-effort set the defaults for the enable_thinking and reasoning_effort a request can override. An effort the template does not take is rounded up to one it does; for Qwen3.8, high and max are xhigh.

Reasoning spends the same max_tokens as the answer. A block that never closes would end the request as length with no answer, so there is a thinking budget (--model-thinking-budget, 32,768 reasoning tokens by default). After that many reasoning tokens with the block still open, or 2,048 tokens before the request’s max_tokens if that comes first, the server forces a close: a sentence telling the model to answer from what it has, without calling any more tools, followed by </think>. The 2,048 is an answer reserve, sized from the answers that followed a forced close in measurement. A request’s thinking_budget overrides the default, and reasoning_effort: max runs without one.

The wording of the close matters. After an earlier close the model could defer with another tool call instead of answering, and under greedy sampling the same turn then repeated: two agent sessions looped on it for 15 and 32 turns. A close that says to answer, with no more tool calls, ended it (a close that answers).

The model can also draw its end-of-turn token while its reasoning block is still open, usually after reasoning at length and writing the tool call it meant to make inside the reasoning text. The server only reads tool calls from the content channel, so the client received a turn that was all reasoning with no call and no answer, and an agent loop stopped (ADR 0048).

Now an end-of-turn drawn inside an open reasoning block becomes </think> instead, and the model goes on to the answer or the call it was writing. This is decided inside the engine on the host, before the token is fed back to the model, because on these hybrid models the recurrent state absorbs every token it is fed and there is no stepping back from it. Details of the rule:

  • It applies to a request that starts inside its reasoning, with thinking on. It does not apply under ignore_eos, or with thinking off.
  • Only the stop tokens are redirected. A tool-call token drawn in the reasoning stays reasoning text, because reasoning routinely writes about calls it does not mean to make.
  • There is no switch: no client wants a turn that ends with reasoning and nothing else.
  • The request log’s reasoning_redirected_at is the output index of that </think>, so a turn closed this way can be told from one the model closed itself.

This is a deliberate difference from the reference implementation, recorded as an ignis patch: output differs from it only on a turn that would have ended in its reasoning.

On long enumerations the 27B sometimes starts to repeat itself inside its reasoning, and does not stop until the budget closes it. On one long-enumeration prompt about a third of first turns looped at the model card’s sampling, and the same artifact loops at the same rate on another engine, so the loop is the model’s and not the server’s (reasoning loops are the model’s). The loops take several shapes: verbatim text, one token in every slot, counting (the 321 AD, the 322 AD, ...), and templates whose slots vary. A looping turn wastes up to the whole budget, and an agent that reaches the forced close is left only the answer reserve.

The server cannot fix the model, but it can notice and step back (ADR 0052, ADR 0053).

A loop guard sits between the engine’s event stream and the client. It watches only the reasoning of a request that starts inside its reasoning, in a separate task that adds nothing to a decode round. Every check-every characters of reasoning (250) it takes the last window characters (1,000), removes code, folds every run of digits to a single digit, and compresses what is left. A check scores as a loop when that text compresses under ratio (265 thousandths, so to a quarter of its size) and the unmodified text also compresses under raw-gate (300). Each step has a reason: removing code keeps legitimate code out of the score, folding digits turns a counting or template loop into a repetition, and the raw gate keeps numeric tables out, where the digits carry the information.

Compression measures redundancy, and a list, a log or a table is redundant by nature. What separates a loop from those is time: a loop absorbs the model and stays, while a list ends with its content. So the guard acts only when a loop persists: persist checks in a row (4) must score as a loop. On recorded runs, no legitimate agent text stayed over the threshold for more than 6 checks, and 23 of 25 harmful loops stayed for 9 or more.

In rewind mode, the default, the guard takes the loop back:

  1. It walks back to the last window that scored clean (compressing to at least clean, 400 thousandths), moves back margin characters and snaps the cut to a line break within snap characters (300).
  2. It cancels the engine request and submits a continuation: the same input, its prompt extended by the reasoning kept before the cut and a nudge, a sentence in the model’s own voice (“Wait, I’m starting to repeat myself. Let me stop and move on with what I already have.”), with what is left of the generation cap and of the thinking budget.
  3. The generation goes on in the same response. The prompt comes back from its checkpoint, so a rewind restored a 79K and a 138K-token agent context in 0.08 to 0.10 s.

After retries nudges (2) the next loop closes the reasoning instead, as the thinking budget would. A second nudge uses a different sentence (nudge-again).

What the client sees: the reasoning carries a tag, [LOOP DETECTED, REWINDING n TOKENS / m CHARS · h], after the m characters taken back, which the Playground strikes through. The rewound tokens stay in completion_tokens and are also reported as completion_tokens_details.rewound_tokens. Reasoning an agent sends back with its tags is repaired before the next prompt, whatever the guard’s mode, so the model is given back the text it actually saw. A request with images is never rewound.

--model-loop-guard-holdback can hold the last characters of reasoning back from the client, so that a rewind whose cut falls in them reaches the client with no tag and none of the loop. The cost is that the client sees the reasoning that much later.

--model-loop-guard is rewind (act), log (only log what a rewind would do) or off (no guard, which is what a measurement runs). Two rules keep the guard from doing harm:

  • A rewind that would keep fewer than min-kept characters of reasoning (1) is only logged, so a reasoning that opens in a loop is never dropped whole.
  • A continuation with fewer than min-continuation tokens (512) of max_tokens left after the cut is only logged.

On the recorded runs it caught 25 of 25 harmful loops on the prompt it was tuned on, and 6 of 7 on 40 fresh seeds, with no fire on the reasoning of real opencode and qwen-code sessions (the guard, a loop persists). The costs are a fire a median 500 characters later than the first-check rule would have, and loops the model leaves by itself within four checks are no longer touched.

It does not see every loop. Indecision loops, whose text varies between repeats (a decision sentence between reconsiderations), do not compress, and the only harmful agentic loop observed was of that kind. The margins between the thresholds are thin and come from one prompt and two texts; a false rewind or a missed loop found later reopens the defaults.

All the guard’s fields can be changed on a running server with PATCH /v1/config, without a reload; they are listed in Prompting. /metrics counts what the guard does: loops by outcome (ignis_loop_guard_loops_total), requests watched and looped, tokens rewound, and each request’s lowest score (telemetry). The guard runs on chat completions and on /v1/responses; the Responses API page covers the WebSocket.