KDCube
← Engineering
KDCube Engineering · Deep Dive

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.

17 July 2026Engineering18 minExperienceBlueprint Deck
ReAct state machineaction overseerstreaming gatesmodel feedbackonline governancekeep and stopduplicate answer

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 THESIS

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.

ConceptImplemented contract
Round state machineA rejected decision re-enters decision; only an accepted tool action routes through tool_execution.
Raw-delta prefix guardAfter optional whitespace, the first channel must be thinking. An invalid prefix interrupts provider generation before parsing, subscribers, or early execution.
Gated output lanesEvery 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 overseerThe overseer evaluates ordered tool-strategy compatibility and whether a final answer may coexist with actions already accepted in the round.
Delivery fact ledgerstreamed_state records what gates actually emitted. Progress and final-answer delivery remain distinct facts.
Keep-and-stop recoveryIf 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 noticeA 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 scopeRecovered 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 depthOnline 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 it
tool_executionruns an accepted tool and folds its result back into the timeline
exitthe model completes, the iteration budget is spent, or a violation exhausts its retries

One 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).

THE FACT EVERYTHING IS BUILT AROUND

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.

ENFORCEMENT ORDER · THE PREFIX RULE RUNS BEFORE PARSING model provider · raw text delta 1 · REACT PREFIX GUARD ONLINE allow leading whitespace, then require <channel:thinking> first invalid cancel stream no parser / action persist react.notice → retry valid prefix 2 · GENERIC CHANNEL STREAMER incremental channel extraction · protocol-agnostic thinking USER TIMELINE live progress action fields known 3 · ROUND ACTION OVERSEER ONLINE action + tool_id + traits → allow / deny the gated lane generation completes 4 · POST-STREAM DEFENSE POST HOC full-response shape · JSON / Action validation · consistency (defense in depth)
Fig. 1 — two online boundaries then a post-stream defense: the prefix rule runs before parsing, so a bad prefix never reaches an action.

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.

STREAM GATE · A BUFFERED VALVE PER ACTION LANE pending deltas buffered — not sent allow() allowed flush the buffer, then pass through USER sees the lane deny() denied discard buffer, swallow the rest nothing reaches the user THE USER ONLY EVER SEES A LANE THE OVERSEER EXPLICITLY ALLOWED
Fig. 2 — a gate buffers until the overseer rules: allow flushes to the user, deny discards. Nothing reaches the user unruled.

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.

STRATEGY MATRIX · ORDER DECIDES WHAT MAY SHARE A ROUND following candidate action → already accepted ↓ explor exploit neutral unknown explor exploit neutral unknown okok okokok okokok nono no no nononono A LATER ACTION MUST NOT DEPEND ON THE FIRST'S NOT-YET-VISIBLE RESULT · UNKNOWN GOES ALONE
Fig. 3 — order matters: the row is what was already accepted, the column is the next candidate. Unknown goes alone.

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.

THE RULE

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.

CodeWhat happenedReached a user lane?Notice carries
decision_preamble_before_first_channelnon-whitespace text made the prefix invalidno — provider stream interrupted before parsingthe fact + bounded offending prefix
decision_first_channel_not_thinkingfirst channel was not thinkingno — interrupted before that channel bodythe fact + detected channel
decision_missing_protocol_channelscomplete response lacked a usable action shapeno action executedthe post-stream shape diagnosis
tool_call_invalidtool call failed protocol validationno tool executiontool_id + concrete violations
tool_signature_redparams failed signature validationno tool executiontool_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:

SIGNAL LEDGER · WHAT IS PRESERVED, WHAT IS DROPPED THE MODEL GENERATED the hand, this round THE NEXT DECISION READS the brain's timeline, next round <channel:thinking> Search, then check the second query. </channel:thinking> [react.thinking] Search, then check… streamed; the user saw it → KEPT A0 call_tool web_tools.web_search queries = ["egret","heron"] ✓ valid [react.tool.call] web_search [react.tool.result] 6 → sources_pool A0 executed → call + result KEPT A1 call_tool web_tools.web_search queries = "bittern" ✗ not a list [react.notice] code: tool_call_invalid · index: 1 tool_id: web_tools.web_search violations: ["queries must be a list"] no react.tool.result for A1 — it never executed A1's raw params are NOT echoed back — nothing to replant THE BRAIN LEARNS EXACTLY WHAT THE HAND DID — AND WHAT IT DID NOT
Fig. 4 — the signal ledger: thinking and the valid tool's call+result are preserved; the malformed tool becomes one self-sufficient notice; what was dropped stays dropped.

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.

11 Related reading

Tool Traits: Tell the Runtime What a Tool Meanswhere the strategy traits the overseer reads come from, and the ordered compatibility matrix in full. The companion to this article's gate section.
Create A KDCube ReAct Agenthow a turn's agent is built fresh from config, tools, traits, and capabilities.
The Model Inputthe structure of the context this loop feeds the model.
Isolated Code Executionhow an accepted exec action runs and returns.
KDCube Engineering
№ 2026-07-17 · kdcube.tech