WP Webhooks / Blog / AI integration
Article · AI integration

The Reply That Was Two Bytes Short: Repairing Truncated LLM JSON

A real bug post-mortem: Gemini’s JSON mode returned our agent’s envelope without its closing brace — twice, deterministically — and the conservative repair that recovers it without ever inventing content.

7 min 2026-07-17
#ai#gemini#postmortem

TL;DR: While capturing screenshots for a documentation page, our in-admin AI agent lost the same turn twice: Gemini's JSON mode returned the reply envelope without its final closing brace. The trace log made it diagnosable in minutes; the fix, shipped in Webhook Actions 2.2.1, is a conservative JSON repair that:

  • appends the missing closers only for a purely structural cut — never inside a string, and only after a complete value;
  • still fails safe when real content was lost, because a force-closed partial plan is worse than a lost turn;
  • records the provider's finishReason on every model call, so truncation and token exhaustion are never confused again.

/ The bug

What does a two-byte truncation look like in production?

The prompt was the most ordinary request our agent handles: "When a Gravity Forms form is submitted, send the entry as JSON to my webhook." The model did the right thing — it decided to search the site's action hooks before proposing anything. Then the turn simply vanished: instead of running the read and continuing, the chat printed the model's raw JSON as plain text and stopped.

We ran the identical prompt again. Identical failure. The AI Dev Trace showed why:

Trace — response_raw ends two bytes early (both calls)

{
  "assistant_message": "I am going to search for Gravity Forms triggers on your site…",
  "reads": [
    {
      "ability": "list_triggers",
      "input": { "search": "gform" }
    }
  ]
                // ← the reply ends HERE. The root "}" never arrived.

// trace metadata, identical on both calls:
"parsed_ok": false,
"temperature": 0.2

Everything about the reply is correct — the message, the read request, the nesting — except that it is two bytes short: a newline and the root }. And it happened at the same structural position in two independent API calls. The request used responseMimeType: "application/json" — Gemini's JSON mode, the setting whose entire job is to guarantee parseable output.

Deterministic truncation is a diagnosis in itself. At temperature 0.2 with an identical prompt the token sequence is near-deterministic, so whatever ends generation early reproduces at the same offset. A flaky network or a token-budget stop would cut at different places; the same two bytes missing twice points at generation. First rule of this bug class: it is not your URL, not your prompt, and not random — and it will happen again on the next identical request.

/ The lost turn

Why did a 99.5%-complete reply cost the whole turn?

Our agent parses model replies through a ladder: try a straight json_decode, then look for a fenced block, then run a conservative repair pass that fixes the quirks models actually emit — a stray trailing brace, a dangling comma before a closer, raw newlines inside string values.

That repair had one deliberate blind spot, documented in its own docblock: truncated replies fail safe rather than being force-closed. The reasoning was sound. If a reply is cut mid-plan, force-closing the JSON produces a smaller plan that looks complete — steps silently missing, green checkmarks on a build that can only fail later. We had already been burned by exactly that shape of failure once (the capability-drift bug), so "never fabricate a complete-looking document" was a hard rule.

But the rule was too blunt. It treated every unterminated object as potentially lossy, including the one case that is provably lossless: a reply where nothing but closing brackets is missing. So a reply that was 99.5% delivered fell through the whole ladder, and the fallback did the only honest thing left — showed the raw text as a plain message with no reads and no plan. Turn lost.

/ The fix

When is force-closing truncated JSON provably safe?

The repair scanner already walks the text character by character, tracking string literals and escape sequences so brackets inside strings don't affect nesting. The fix extends it to remember the stack of open structures — which closers are owed, in which order. When the text runs out with structures still open, it now force-closes, but only when two conditions both hold:

The two guard conditions for a structural cut

1. The scan must NOT end inside a string literal.
   A cut mid-string means content was lost. Fail safe.

2. The last emitted token must already be a COMPLETE value:
   a closed string, "}", "]", true / false / null, or a finished number.

   Ends with  ,  :  {  [   → a value was coming and never arrived. Fail safe.
   Ends with  "…"  }  ]    → only the closers are missing. Append them.

Under those guards, appending the owed closers — ] then } in our field case — reconstructs exactly the document the model produced, byte for byte plus the brackets it owed. Nothing is invented; no plan step can be silently dropped, because a cut that could have lost a step (after a comma, after a colon, inside a value) still fails safe. The two failure philosophies coexist: lossless recovery for structural cuts, honest refusal for lossy ones.

The regression suite pins both sides: the exact truncated envelope from the field trace parses with its reads intact, while mid-string, after-comma, after-colon, after-opener, and mid-literal cuts all still return null — alongside every pre-existing repair behavior (stray trailing braces, dangling commas, unescaped newlines).

/ Finish reasons

How do you tell truncation from token exhaustion?

The second change is observability. Every provider reports why generation stopped — Gemini's candidates[0].finishReason, Anthropic's stop_reason, OpenAI's choices[0].finish_reason — and we were throwing that field away. Without it, a truncated reply in the trace is ambiguous: did the model emit broken JSON, or did we cut it off with a token budget? Those are different bugs with different fixes, and telling them apart previously required reasoning about latencies and reply lengths.

Every transport now surfaces the provider's finish reason, and the AI Dev Trace records it as finish_reason next to parsed_ok and the raw response. A STOP with missing brackets is the model's quirk; a MAX_TOKENS is ours. One field, one glance.

/ Takeaways

What should any LLM-parsing pipeline take from this?

1. JSON mode is a strong hint, not a contract. Constrained decoding eliminates prose wrappers and code fences, but a structurally incomplete reply is still possible. If your parser's failure mode for that case is "discard the turn", you will eventually discard turns that were 99.5% delivered.

2. Repair must be lossless or refuse. The dangerous repairs are the ones that can fabricate a smaller-but-valid document. Gate any force-close on proof that only structure was lost — and keep failing safe everywhere else. A lost turn annoys the user; a silently thinned agent plan ships something broken with checkmarks on it.

3. Log the raw reply and the finish reason before parsing. Truncation is invisible in parsed output, because the parse failed. The verbatim response_raw plus the provider's finish reason turned this from "the AI randomly breaks sometimes" into a two-trace diagnosis and a same-day fix.

The fix ships in Webhook Actions 2.2.1 — the full entry is in the changelog. For the architecture this parser lives in, see how the in-admin agent is built and the earlier post-mortem that shaped its fail-safe philosophy.

FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Does Gemini’s JSON mode guarantee syntactically valid JSON? +
Mostly, but not absolutely. Setting responseMimeType to application/json constrains decoding and eliminates prose wrappers and markdown fences, but flash-class models can still emit a reply cut off before the final closing brace or bracket. Any parser consuming JSON-mode output in production needs a strategy for a structurally incomplete reply — either a conservative repair or an explicit retry.
Why did the truncation happen at exactly the same spot twice? +
Low temperature. At temperature 0.2 with an identical prompt, the model’s token sequence is near-deterministic, so whatever caused the early stop reproduced at the same structural position in both calls. Deterministic truncation is a useful diagnostic signal: a random network problem or token-limit stop would cut at different offsets, while the same two bytes missing twice points at the generation itself.
When is it safe to auto-close truncated JSON from an LLM? +
Only when the cut is purely structural: the scan must not end inside a string literal, and the last emitted token must already be a complete value — a closed string, object, array, number, or literal. Then appending the missing closers restores exactly what the model produced, losing nothing. A cut after a comma, colon, or opening bracket means a value was lost, and force-closing there fabricates a smaller document that looks complete — the most dangerous possible outcome for an agent plan.
What is finishReason and why log it? +
Every major provider reports why generation stopped: Gemini’s candidates[0].finishReason, Anthropic’s stop_reason, OpenAI’s choices[0].finish_reason. STOP means the model chose to end; MAX_TOKENS/length means the output was cut by budget. Logging it per call costs one field and instantly separates “the model produced broken JSON” from “we truncated the model” — two failures with completely different fixes.
How do I debug truncated LLM output in production? +
Record the raw response verbatim before any parsing or repair, alongside the parse outcome and the provider’s finish reason. Truncation bugs are invisible in parsed output — by definition the parse failed — so the raw string is the only evidence. Our AI Dev Trace stores response_raw, parsed_ok, and finish_reason per model call, which is exactly what made this bug diagnosable from two log entries.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate