The Self-Governing ReAct Round: State Machine, Live Gates, and Honest Feedback
A KDCube ReAct turn is a state machine that runs the model, watches what it generates as it streams, decides in real time what may reach the user, and — when something goes wrong — tells the model exactly what happened before asking it to continue.
A KDCube ReAct turn is not one model call. It is a state machine that runs the model, watches what it generates as it streams, decides in real time what may reach the user, and — when something goes wrong — tells the model exactly what happened before asking it to continue.
The harness must be fully transparent to the model about what was done with its output. No silent re-runs, no silent drops. The brain must know what the hand did — otherwise it cannot make the right next move.
Everything below is how one round earns that guarantee: the loop that governs it, two online enforcement boundaries, the post-stream defense, the strategy gates that decide which of several streamed actions survive, and the feedback contract that turns a failure into a fact the model can act on.
00 Implementation highlights
The implementation is built from explicit contracts rather than one catch-all validator. Together they make governance depend on observed effects, not on assumptions about what generated text probably did.
| Concept | Implemented contract |
|---|---|
| Round state machine | A rejected decision re-enters decision; only an accepted tool action routes through tool_execution. |
| Raw-delta prefix guard | After optional whitespace, the first channel must be thinking. An invalid prefix interrupts provider generation before parsing, subscribers, or early execution. |
| Gated output lanes | Every streamed action and final-answer lane starts buffered. Policy must explicitly allow it before any buffered content reaches the user; denial discards it. |
| Online action overseer | The overseer evaluates ordered tool-strategy compatibility and whether a final answer may coexist with actions already accepted in the round. |
| Delivery fact ledger | streamed_state records what gates actually emitted. Progress and final-answer delivery remain distinct facts. |
| Keep-and-stop recovery | If a final answer reached the user before post-stream validation failed, the runtime preserves that exact answer and finishes instead of generating a duplicate. |
| Self-sufficient corrective notice | A rejected candidate becomes a structured react.notice with the code and decisive details the next round needs; accepted calls, results, and streamed progress remain separate facts. |
| Completion-lineage scope | Recovered answer state belongs only to the immediate completion lineage. Folding a new live event clears it, so an old answer cannot leak into a later close. |
| Defense in depth | Online guards enforce rules that must hold before effects occur; post-stream validation checks the complete shape and agreement between incremental and full-response parsing. |
These contracts are intentionally ordered. A late validator cannot undo a streamed answer or an executed tool, so every policy is enforced at the earliest boundary where the runtime has enough information to decide it correctly.
01 The loop in one picture
The runtime is a small state machine over three nodes. Every iteration starts
at decision.
┌──────────────┐ action=call_tool & validated ┌────────────────┐ ───▶ │ decision │ ───────────────────────────────▶ │ tool_execution │ │ │ ◀─────────────────────────────── │ │ └──────────────┘ done └────────────────┘ │ ▲ │ │ retry_decision (protocol violation, steer finalize) │ └────────────────────────────── │ action=complete / exit OR exit_reason ▼ ┌──────────────┐ │ exit │ └──────────────┘
decisionruns one model generation and validates ittool_executionruns an accepted tool and folds its result back into the timelineexitthe model completes, the iteration budget is spent, or a violation exhausts its retriesOne subtlety governs the rest of the article: retry_decision
is checked before the route to tool_execution. So when a
round needs to re-decide — a protocol violation, a steer that cancels work and
enters finalize — the loop goes decision → decision, not through the
tool node. That routing choice is correct, but it means the decision node itself
has to be honest about what already happened, because nothing downstream will
patch it up.
02 A round is a live stream, not a finished blob
The naive mental model — the model generates a response, then the runtime validates it — is wrong for how KDCube actually works. The decision response is streamed, its channels are parsed character by character, and some of them reach the user while the model is still typing.
The decision protocol is channel-tagged:
<channel:thinking> short user-facing status </channel:thinking>
<channel:action>```json { "action": "call_tool", "tool_call": {...} } ```</channel:action>
<channel:code> code, only for an exec action </channel:code>
<channel:summary> continuity summary, only on complete/exit </channel:summary>
As those characters arrive, a char-level parser splits the channels and
decodes the action JSON incrementally. thinking and root
notes stream to the user immediately. The action's
final_answer streams too — once it is allowed to
(that qualifier is the whole next section).
A round can stream its answer to the user and still fail validation afterward. Streamed text cannot be unsent.
03 Enforcement order: the package journey
Validation is not one gate over a finished response. The order matters because an action channel can become eligible for early execution before generation ends. The ReAct-specific prefix policy therefore runs on each raw provider delta before the generic channel parser sees it.
The generic streamer remains reusable: it does not know that ReAct requires
thinking first. It only exposes the raw-delta policy hook and
propagates a generic stream-policy exception. The ReAct runtime owns the prefix
rule.
online boundary 1 · prefix guardplain prose, a code fence, or a legacy tag interrupts the provider stream as soon as the prefix can no longer become valid; a tag split across arbitrary chunks is allowed while incomplete; a non-thinking first channel is rejected when its opening tag completes. Because this runs before parsing, no action subscriber or early-execution listener sees the rejected response.online boundary 2 · action overseeras soon as a streamed action's action and tool_id are known, the overseer judges compatibility with the actions already accepted this round; its gated output reaches the user only if allowed.post-stream defenseafter generation, the runtime re-checks the complete response, validates the action JSON against the Action model, and checks channel/action consistency. Rechecking the first-channel invariant here is deliberate defense in depth, not the first point of enforcement.A round can pass both online boundaries, stream a final answer, and still fail the complete JSON/schema check. Holding those two truths together is what separates correct feedback from a duplicate answer.
04 The overseer, up close: gates as valves
The multi-action protocol lets one response request more than one action (at most two per round). They stream interleaved, so the runtime cannot wait for the end to decide which are legal — it decides per action, live. It does this with a gate per output lane.
While pending, deltas are buffered, not sent.
allow() flushes them and opens the lane; deny() throws
the buffer away and swallows everything after. So the user only ever sees a lane
the overseer explicitly allowed. There are two decisions the overseer makes as
each action appears.
1. Trait compatibility (the multi-tool case). Every tool carries a strategy trait — exploration (it fetches data a later step will read), exploitation (it consumes data already visible), neutral (neither), or unknown. Where those traits come from and how they are configured — in tool code and per agent connection — is the subject of its own article, Tool Traits; here we only need the rule they feed the overseer. Two actions may share a round only when their traits are compatible; the second must not depend on the first's not-yet-visible result. An incompatible later candidate is denied — its lane never opens.
Two independent renders of an already-visible source (PDF + PPTX) share a round
happily — both exploitation, neither depends on the other. A search followed by
an action that reads that search does not — that is g(f()) with
f's result still in flight, so the second waits for the next round.
2. The answer-lane gate (the non-tool case). A
complete/exit action's final_answer is
user-visible, so its gate has a stricter rule: it opens only when every action
accepted so far is itself final or a neutral tool. If a round paired an
exploration tool with a complete, the answer lane stays shut — the
user does not see a final answer that was about to contradict a tool call still
running.
A0 = call_tool web_search (exploration) ── allowed A1 = complete final_answer (final) ── answer lane? prior action A0 is exploration, NOT neutral/final ──▶ answer lane DENIED; A1 dropped as incompatible with A0
This is why "the model's answer reached the user" is never an accident in KDCube. It is a recorded decision: the overseer allowed that specific lane.
05 What the user saw — the load-bearing fact
The duplicate-prevention decision turns on one precise question: did an
allowed final_answer lane reach the user? The answer is not
inferred from raw model text. Each action gate counts only the deltas it actually
emitted; buffered-then-denied deltas count zero. That audit is carried on the
decision packet as streamed_state:
streamed_state = {
answer_streamed: true,
answer_text: "…exactly what the user saw…",
lanes: [ {index, lane, status, emitted_chars}, … ]
}
thinking is a separate live progress stream and is persisted
through its own timeline path. It is not evidence that a final answer streamed.
From the recorded facts, a rejected round has three materially different
outcomes.
1. FINAL ANSWER STREAMED → keep that exact answer and stop; never ask the model to repeat it 2. PROGRESS STREAMED, BUT NO FINAL ANSWER → preserve the progress; persist the diagnosis; retry when allowed 3. PREFIX/CANDIDATE NEVER REACHED ANY USER LANE → persist the diagnosis only; retry when allowed
Conflating those outcomes is the bug. Telling the model "the user saw the
answer" because it emitted only thinking is as wrong as retrying
after an actual final answer was already shown. Feedback must come from recorded
delivery facts, never a guess.
06 Corrective feedback is a self-sufficient notice
The corrective diagnosis is persisted as react.notice; it survives
into the next decision render and carries its whole diagnosis inline. Other facts
that really happened remain in their own blocks: streamed thinking, accepted tool
calls, and tool results are not collapsed into the notice. The builder merges a
structured extra dict straight into the notice the model reads.
So a rejected tool call arrives at the model as a complete, self-contained record:
{
"code": "protocol_violation.tool_call_invalid",
"message": "tool_call failed protocol validation …",
"index": 0,
"tool_id": "web_tools.web_search",
"violations": ["param 'queries' must be a list", "..."]
}
Why self-sufficiency is a rule, not a nicety: in production the model does not get its raw generation back. The debug block that carries raw decision JSON is filtered out of the production context. The notice is the authoritative corrective diagnosis for a failed round, so it must carry the decisive facts itself — the exact tool, the exact rule — never lean on a debug artifact that production strips away. Real progress, accepted calls, and results remain available through their own timeline blocks.
(The one exception is the steer-interrupted raw: when a live steer cancels a round, the interrupted generation is shown, so the finalizing model sees its own unfinished work.)
07 Final answer streamed: keep it and stop
Here is the failure that motivated the whole design. A model streamed a valid
complete whose final_answer the overseer allowed — the
user read it — and then the post-stream JSON parser choked on a formatting quirk
in the action fence. The old behavior retried the round. The model, never told its
answer had already shown, produced a fresh answer. The user saw the same thing
twice.
We have the answer? That is it — keep it and stop.
a round's answer STREAMED, then post-stream validation rejects it
│
▼
take the answer text from streamed_state (fact, not a regex)
│
▼
DO NOT retry the model. Synthesize the complete it meant,
attributed to THIS completion lineage, and finish.
│
├── more live events pending? → fold them, continue (new lineage)
└── none? → the turn ends here
The only reason to continue after a salvaged answer is more events to process — and that falls out for free, because the finalize returns through the normal completion path, which already folds pending events. Two disciplines keep this honest:
fact-basedthe kept text is streamed_state.answer_text — exactly what the gate passed to the user — not a pattern match over raw output.lineage, not turna turn has as many final answers as live events produce; there is no single "turn final answer." A salvaged answer is valid only between a failed round and its immediate retry within the same completion lineage. Any accepted live event clears it; a fresh non-empty close supersedes it. why per-turn salvage would be WRONG:
R1: answer A streams, post-stream check fails salvage = A
a followup folds ─────────────────────────── salvage = "" (cleared)
R2 (new lineage): model closes empty → NOT backfilled with A
The same rule now holds across post-stream rejection paths: if
streamed_state.answer_streamed is true, schema and channel/action
consistency errors keep the already-delivered answer and stop instead of
re-kicking the model. An online prefix rejection cannot enter this branch because
it happens before any channel lane exists.
08 No streamed final answer: preserve facts, add the diagnosis
When no final answer reached the user, the model learns what effect occurred and the structured why. Progress that really streamed stays in the timeline; rejected candidate material is not promoted into a tool call or result.
| Code | What happened | Reached a user lane? | Notice carries |
|---|---|---|---|
decision_preamble_before_first_channel | non-whitespace text made the prefix invalid | no — provider stream interrupted before parsing | the fact + bounded offending prefix |
decision_first_channel_not_thinking | first channel was not thinking | no — interrupted before that channel body | the fact + detected channel |
decision_missing_protocol_channels | complete response lacked a usable action shape | no action executed | the post-stream shape diagnosis |
tool_call_invalid | tool call failed protocol validation | no tool execution | tool_id + concrete violations |
tool_signature_red | params failed signature validation | no tool execution | tool_id + signature diagnosis |
Preamble is the cleanest no-effect case: the generation is interrupted on the first delta that cannot still become a valid prefix. The runtime records a bounded diagnostic preview, not the rest of the rejected generation, and no action path runs.
What the next round sees
The clearest way to see the contract is to place a round's generation next to the timeline its next decision reads. Take a multi-action round where the model called two tools — one valid, one malformed:
Read it as a ledger. Thinking is preserved — it streamed, the
user saw it, so it stays a react.thinking block. The valid
tool's call and result are preserved — A0 ran, so its
react.tool.call and react.tool.result are in the timeline,
contributed by the tool handler at execution time. The malformed tool
becomes a self-sufficient notice — A1 produced no user-visible effect, so
the model gets the fact and the structured diagnosis in one
react.notice, and nothing more. What is dropped stays
dropped — no phantom result for a call that never ran, no echo of the
rejected parameters.
09 Why this only broke with a new model
The parser assumption that started the duplicate-answer incident was months old and had never once misfired — because every model in use wrote the action fence the same way the protocol's own examples show it. The first locally served model with a slightly different fence habit met the assumption, and the two validation layers disagreed about the same bytes: the online gate and the char-level streamer accepted the answer and showed it; the post-stream parser rejected it.
Nothing in the platform had changed. The trigger was the model population; the fragility was the latent disagreement between the layers. The fix restored the governing invariant — post-stream parsing accepts whatever the streaming layer accepted — and locked the exact bytes in a regression test. It is a clean example of the KDCube discipline: fix the failure class (the two layers must agree), not just the trigger (one model's fence style).
10 The payoff
Put the pieces together and a ReAct round governs itself without ever lying to the model:
state machinegives every round a clean re-entry when it must re-decide, and never routes a failed round through a node that would paper over it.online prefix guardrejects an invalid response before the generic parser, so no subscriber or early execution observes it.online overseerdecides, per streamed action, what may reach the user — tool-strategy compatibility for multi-tool rounds and a stricter answer-lane rule for finals.feedback contractturns every rejection into a persisted, self-sufficient notice — and when the answer already streamed, keeps it and stops instead of asking the model to repeat itself.The brain always knows what the hand did. That is the property that lets a serving-constrained local model, a fast hosted model, and a multi-tool exploitation round all run through the same loop and behave.