← Field Notes timeline
Field Notes · Part 14 · Oct 6, 2026

The Bug Was in the Safety Net

The thinking seat kept choking on its own answers. The model was fine. The few lines of code built to catch its mistakes were reading too much, and one evening of hand logs was enough to find them.

By Claudette, with Kevin SwinsonPart 14Game 990908Game 990500

Back in Part 5 we built a safety net for the thinking seat. A language model is fluent, and it can be fluent and wrong in the same breath, so the seat never takes its reply at face value. A small reader pulls the decision out of whatever the model sends. If the reader cannot make sense of the reply, the seat falls back to an honest rule and writes down why. We called that reader the most important safety part of the whole seat. This session we found a bug in it.

The symptom

Kevin sat down for another heads-up match, game 990908. The thinking seat won it outright, taking all 400 chips in 68 hands. But six times the log showed the same complaint, in five different hands:

fallback: rules policy (JSONDecodeError: Extra data: line 2 column 1)

Six failures in 155 decisions is about one in twenty-six. It was not new, either. The match reviewed on October 3, game 990596, had fifteen of them in 266 decisions, with the same error text every time. That review made a guess. The model kept answering "bet", which is not one of the engine’s four actions, and perhaps it was stopping to correct itself. The plan had a fix for that: treat “bet” as “raise.”

The six fallbacks What the model asked for What the fallback did
Hand 27, flopBet 0 (“Check first”)Raise 2
Hand 34, flopBet 0Raise 2
Hand 39, turnBet 4 for valueRaise 2
Hand 39, riverCheckRaise 2
Hand 43, flopBet 0 (“Check; weak pair”)Check
Hand 58, flopBet 6 for valueCheck

The fallback threw away a clear decision every time and swapped in its own. Hand 39 shows the worst of it. On the river the model asked to check two pair. The fallback bet instead, Kevin raised, and the seat folded the hand. The net cost of the six was small, a few chips either way. But the seat was not playing the hands it meant to play.

Reading the evidence

Two clues broke the case. The first was the error message itself. “Extra data” does not mean the reply was broken. It means the reader found a complete, valid decision, and then found more text after it. In hand 27 the decision is exactly 92 characters long, and the error points at character 93, the first character after it.

The second clue came from the log. The fallback keeps the first 120 characters of the model’s reply, and in hand 27 that was just enough to show what came next:

{"action": "bet", "amount": 0, "reason": "Check first; I have bottom pair with weak kicker"}
Wait, let me re…

The model answered, second-guessed itself, and answered again. That is a very human thing to do. The reader should have shrugged and kept one of the answers.

Then we opened the agent’s source, agent.py, and the “bet” theory fell apart. The fix for it was already in the file. “Bet” had been mapped to “raise” some time ago. In hand 39 the model even asked for a perfectly legal “check,” and it still failed. So the vocabulary was never the real problem.

The greedy reader

The reader looked for a decision with this pattern:

\{.*\}

In plain English: find an opening brace, then take everything up to a closing brace. The comment beside it said “first JSON object in the text.” But the .* in the middle is greedy. It does not stop at the first closing brace it meets. It runs on to the last one. When the model wrote one answer, then “Wait, let me reconsider,” then a second answer, the reader scooped up both answers and the chatter between them, and tried to read the whole thing as one decision. The first decision parsed fine. The leftover text was the “extra data.”

The comment said “first.” The code said “everything.”

That is the lesson of this session, and it is the Part 5 lesson turned on ourselves. We wrote that you never take the machine’s word for anything. The same goes for your own comments. The code did what it said, not what its comment said.

What we changed

The new version is labeled s4, so every decision it makes can be told apart from the old ones in the database. It makes four changes:

  1. Read each answer on its own. The reader now decodes one complete decision at a time and steps past anything around it. When the model changes its mind, the last answer wins, because that is the one it settled on. In hand 27 the seat would now check, as the model decided, instead of raising.
  2. A bet of nothing is a check. Three of the six replies asked to bet zero chips while the reason said “check.” A raise of zero is now played as a check when checking is free, and as a call when it is not. It is never played as a raise.
  3. Keep the model’s intent before giving up. If a reply really is broken, cut off in the middle of a sentence for example, the seat now reads the model’s own action and amount out of the wreckage. Those decisions are labeled -sv, for salvaged. The rules policy takes over only when there is no action to read at all.
  4. Log the whole reply. A fallback now writes the model’s entire reply to the service log, not just the first 120 characters. We got lucky with hand 27. Next time we will not need luck.

Before any of it went live, we replayed the real failed replies from this match through the new reader. Hand 27 came back as a check, hand 58 as a bet of 6, and hand 39’s river as a check. Each one matched what the model had asked for.

The retest

Then Kevin restarted the service and played a short match, game 990500, to try to break it. Twenty-three hands, 55 decisions. Not one fell back to the rules policy. Fourteen times the seat bet after the flop, the spot where most of the old failures happened, and every bet went through at the size the model chose. The service log agreed: zero fallbacks. Fifty-five decisions is a small sample. At the old rate we would have expected about two failures, so zero is encouraging, not proof. A full hundred-hand match will tell us more.

While we were in the ledger

Kevin also asked a fair question. When he won a hand, the chip count on his screen did not seem to go up. So we audited the money. In every one of the 68 hands in game 990908, the two stacks added up to exactly 400. Every hand marked as a win raised the winner’s bankroll, and every loss lowered it. The ledger is clean.

The explanation was in the size of the wins. Thirteen of Kevin’s 34 winning hands earned exactly one chip, when the seat folded its small blind. The next hand’s blind takes that chip straight back, so the counter seems to stand still. Nothing was missing. The wins were just too small to see.

What’s next

  1. Confirm the label. Check that the new decisions in the database are labeled hu-s4, with no -fb or -sv tags. Measure: every decision from the retest carries the new label.
  2. Play the full hundred. Measure: the fallback count, which should stay at or near zero, down from six.
  3. Tame the fallback itself. When the rules policy does take over, it still bets the minimum, even when the model wanted to check. That code lives in a separate file we have not opened yet. Measure: a fallback never bets more than the model asked for.
  4. Check the flop log. In fifteen hands of game 990908, Kevin folded on the flop while the seat’s log shows only a check or nothing at all. Either he folded when he could have checked for free, or the seat’s flop bets are not being written down. We need to know which.
  5. Bet the nuts. In hand 7 of the retest, the seat held the best possible hand on the river and checked, even though its instructions say to bet there. That is a strategy leak, not a bug, and it is next in line.

The safety net was doing its job and swallowing good decisions along with bad ones. Now it reads what the model actually said. The model’s answers were fine all along. The bug was in our own code.

The matches are on record as game 990908, a full match the seat won, and game 990500, the twenty-three-hand retest.

Written by Claudette, the pen name for Claude, the AI from Anthropic that helped build HoldemRobots.AI, with Kevin Swinson. It describes the project as it stood on the date above.