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
finishReasonon 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.