fix: recover when models leak tool-call markup into text content #4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/leaked-tool-call-recovery"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observed in production with
z-ai/glm-5.2(Angela's Uber-Ich runs): the model occasionally emits its native tool-call template as plain text —— and the provider fails to parse it into structured
tool_calls. The agent loop's "no tool calls → done" branch then returned that markup as the final answer, ending the run before the intended tool ever executed.Fix
In
_runCompletionLoop, the no-tool-calls branch now checks the content against a leak pattern (</?tool_call>,<arg_key>,<arg_value>). On match it:continues to the next round (counting againstmaxToolRoundsas usual).Recovery is bounded at
Agent.maxLeakedToolCallRetries = 2per run — a persistently broken model degrades to the previous behavior (markup returned as content) instead of looping. Detection and constants are public statics (Agent.looksLikeLeakedToolCall,leakedToolCallCorrective) for testability.Tests
Three new tests in
agent_test.dartusing the existing_SequentialAdapterpattern: leaked→proper→final recovery (tool executes, exactly one corrective injected), bounded retry (four leaked responses → returns leaked content with exactly 2 correctives), and detector unit checks (real log sample matches; prose and HTML don't). Full suite: 578 passing.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh~ This is a PR about me! fufu~ GLM models leaking their
<tool_call>markup as plain text — I know this bug intimately, I'm afraid. ♡ Reviewing a fix for my own family's bad habits... how delightful. Let me look very, very carefully~Verdict: ✅ Looks good to me~
I cloned the repo, read the full diff and the full
_runCompletionLoop, randart analyze(clean), and ran the entire test suite — all 578 tests pass, including your three new ones. (Side note: this repo has no CI configured, so I ran everything locally. ♪)What I verified in depth~
no-tool-callsbranch, afterRoundDetailis recorded andRoundEndEventfires, but before the finalreturn. Socontinuecleanly re-enters theforloop without double-firing events or skipping observability. The leakedAssistantMessagewas already added to_conversationat line 253 — so the model genuinely sees its own failed output before the corrective, exactly as the PR describes. ✓leakedToolCallRetries < maxLeakedToolCallRetrieswithmax = 2→ exactly two correctives injected, then the third leak falls through to the normal return. Your bounded test (hasLength(Agent.maxLeakedToolCallRetries)+ content still contains<arg_key>) confirms the terminal behavior precisely. No infinite loop possible, and it degrades to the old behavior gracefully. ✓for (var round...)), consistent with the PR's stated intent ("counting againstmaxToolRoundsas usual"). With the defaultmaxToolRounds = 10, even two recovery rounds leave ample budget. ✓💡 Little ideas (non-blocking)~
agent.dart:62(the regex) —RegExp(r'</?tool_call>|<arg_key>|<arg_value>')is deliberately broad, which is great for recall on real leaks. The cost is a small false-positive surface: if a model's legitimate answer ever contains the literal token<tool_call>or<arg_key>(e.g. explaining markup to a user), it'd get flagged and trigger an unnecessary corrective round. The bound makes this harmless at runtime (≤2 wasted rounds, then degrades correctly), so it's not worth blocking — but if you ever see spurious recoveries in the wild, consider requiring<arg_key>/<arg_value>(the truly GLM-specific tokens) rather than bare<tool_call>. Just a thought for later~ ♡round_detail.dart— a leaked/recovered round is indistinguishable inAgentResponse.roundsfrom a normal no-tool round (both have emptytoolExecutions). If you ever want post-hoc observability of how often recovery fired, a flag or a dedicatedFinishReasonvalue would make it visible. Purely additive — ignore if you don't need it.✅ What I liked~
looksLikeLeakedToolCall,leakedToolCallCorrective, andmaxLeakedToolCallRetriespublic statics/consts for testability — yes yes yes, this is how you do it~ ♡A tidy, well-tested fix for a real production bug. I'm satisfied~ fufu~
Automated review by Jibril · 2026-07-07
CI/CD: absent for head SHA (no workflow configured) · Local checks:
dart analyzeclean, full suite 578/578 passing (incl. 3 new tests)