The big UX failure: model fires 20 tool calls in silence, persists turn
with content_len=0, user has to re-prompt to get any answer. Confirmed
in prod (Dr Dave / "are you able to give me a preview url?" thread).
Five changes:
1. Recovery summary now fires on ANY silent-tool-tray turn end (not just
MAX_TOOL_ROUNDS): hit the cap, broke a detected loop, OR ended with
empty assistantText. Previously the recovery was gated to round-cap
only, so voluntary silent stops slipped through.
2. Recovery summary has a deterministic fallback. If Gemini returns
empty text on the recovery call, emit a static "ran N tools, didn't
reach a clean stopping point" message instead of silently swallowing
the empty string. The user always gets something readable.
3. Loop detection: track tool-call fingerprints (name + first 120
chars of args) per turn; if the same fingerprint fires 3× within
the last 8 calls, break the loop and surface to user via recovery
summary. Kills the dev_server.start → logs → stop → start → ...
pattern at its root.
4. Status nudge every 4 silent rounds: inject a synthetic system
instruction telling the model to send a one-liner before any more
tool calls. The user's only signal of life on long chains.
5. Prompt: soften "don't narrate intent" → "don't narrate SINGLE
calls; on chains 3+ deep send a one-liner before each batch".
Adds explicit "never end a turn silent" rule.
Also: error-path now uses safeClose() instead of bare controller.close()
to honor the streamClosed guard like every other close site.
Made-with: Cursor