The worst bugs aren’t the ones that crash. They’re the ones that go quiet.
This one’s from an AI project I work on — the kind of chat where you attach some context, ask the agent to do a thing, approve a step, and then… nothing. No answer. No error. No spinner that eventually gives up. Just an empty message sitting there like the model wandered off for coffee and never came back.
What makes it worth writing up isn’t the app — it’s the shape of the bug. It’s the kind of thing anyone wiring up LLM agents is going to trip over eventually.
The symptom
The report was simple: “the chat freezes after I approve the action.”
A few things made it slippery:
- It was data-dependent. Some inputs worked fine; others froze. That screamed “bad data” and sent me down the wrong path for a while.
- It was silent. The response came back empty. No exception surfaced to the UI, nothing obvious in the request. From the outside it looked like a hang; from the logs it looked like a success with no content.
- It happened after a human-approval step. The agent can call tools, and some tools require the user to approve them before they run (a human-in-the-loop gate). The freeze always landed right after an approval.
A silent, data-dependent, post-approval hang. Three properties, three different rabbit holes.
The false lead (and why I built a tiny repro)
My first theory was the obvious one: the “bad data” inputs were producing records that failed a validation step somewhere, and a thrown validation error was hanging the stream.
It was a great theory. It was also wrong — and I only found out because I stopped theorizing and wrote a throwaway script.
The script did one narrow thing: pull real records for the “bad” inputs and run them through the exact validation the app uses, printing any failures. I expected a wall of red.
I got zero failures. Every record validated cleanly.
That 20-line script saved me hours. The lesson I keep re-learning: when you have a hypothesis about data, reproduce it against real data before you spend a day “fixing” it. A disproven theory is progress; a theory you keep half-believing is quicksand.
So it wasn’t the data. Back to the drawing board — but now with the “why is it data-dependent?” question reframed: maybe the data didn’t cause the freeze, it just changed the agent’s behavior in a way that triggered it.
Root cause #1: parallel approvals
Here’s the reframe that cracked it.
When there was nothing interesting to look at, the agent answered from what it already had and called no tools — no approval, no freeze. When there was something to dig into, the agent got eager and fired several tool calls at once, in parallel, each needing its own approval.
And that parallel-approval path is where things fell apart.
The model provider we use has a strict rule: every tool call the model makes must be answered with a matching result in the very next turn. One call → one result. If the model emits three tool calls and the follow-up turn comes back with only some of them resolved, the provider rejects the whole message. And “rejects” here doesn’t mean a nice error — it means an empty completion. Which the UI faithfully renders as… nothing.
The approval flow, on the resume path, wasn’t reliably reattaching all of the parallel tool calls’ results. So the moment the agent chose to fan out into multiple approvals, the next turn was malformed, the provider returned empty, and the chat “froze.” (This is a known rough edge in the agent framework’s approval-resume handling — there are open issues tracking it upstream. Reassuring, in the “it’s not just me” sense.)
Single tool call? One call, one result, clean turn, works every time. That’s why simple cases never froze.
The fix
Two moves:
- Stop the fan-out. Force the model to make one tool call per step whenever an approval-gated tool is in play. Most providers expose a “disable parallel tool calls” switch for exactly this. One call → one approval → one result → a well-formed turn. It trades a little latency for not hanging, which is a trade I will take every single time.
- Nudge the behavior in the prompt. Tell the agent to reason from what it already has first, then pull additional detail deliberately — one step at a time — instead of opening with a barrage of parallel reads. Prompts don’t guarantee anything, but paired with the provider switch, the barrage was gone.
Root cause #2: a thrown tool is a silent hang
I thought I was done. Then a second freeze showed up in a different scenario, and the trace told a familiar story: an empty completion, again.
This time the agent had called some tools whose backends happened to be offline in my environment. The tools did a plain network call and, when it failed, threw.
Here’s the thing I hadn’t fully internalized until this bug: in an agent loop, a thrown tool is not an error the user sees — it’s a hang. When a tool throws, the framework may register the call but never produce a result for it. And we’re right back to the provider’s rule: a tool call with no matching result → malformed turn → empty completion → frozen chat. The actual error (“backend unreachable”) never made it anywhere a human could see it, and — worse — never made it back to the model, so the agent couldn’t even say “that source is down, here’s what I have without it.”
The fix is a small mindset shift — and honestly, the real takeaway of the whole thing:
In an agent system, make failure a value, not an exception.
Every tool now wraps its backend call and, on any failure, returns a structured result with empty data and an error string instead of throwing. Concretely, that means:
- The turn stays well-formed (there’s always a result for every call) → no hang.
- The model sees the failure and can relay it: “I couldn’t reach that source, so here’s my read from what’s available.”
- The failure is still logged (surfaced, not swallowed) — returning a graceful value is not the same as hiding the problem.
That last point matters. “Handle errors gracefully” and “never swallow errors” sound like they’re in tension, but they’re not: the tool’s caller is the model, so the graceful thing is to hand the model the error as data — while also logging it for the humans.
What I’m taking away
A few things I want to remember the next time I build an agent feature:
| Lesson | Why |
|---|---|
| A thrown tool = a silent hang | Providers reject a turn with an unanswered tool call; the UI shows nothing. Return errors, don’t throw them. |
| Surface, don’t swallow | Graceful degradation still logs the failure and tells the model — the caller is the model, so the error is data. |
| Serialize human-in-the-loop tools | Parallel calls that each need approval are a minefield on the resume path. One call per step is boring and reliable. |
| Reproduce before you “fix” | A 20-line script against real data killed my best wrong theory in minutes. |
| Silence is a symptom | An “empty success” is one of the loudest signals there is, once you learn to hear it. |
None of these are exotic. But they’re the kind of thing you internalize the hard way — by staring at an empty chat bubble long enough to start taking it personally.
If there’s a single line to carry out of this: in agent land, an exception you forget to catch doesn’t blow up loudly — it just goes quiet. Build so that failure is something the agent can talk about, not something that swallows the whole turn.
Onwards.