Description
After an LLM returns a tool call with empty/no text content, the agent loop enters an infinite cycle creating empty assistant messages (tokens: {input:0, output:0}) without ever invoking the LLM. The only recovery path is manual compaction.
Reproduction
- Start an interactive session with a custom provider (in our case, glm-5 via llm-router)
- LLM returns a tool call (e.g., Read a PNG file) with empty text in the response
- Tool executes successfully, result is sent back to the LLM
- On the next loop iteration, the LLM returns an empty/errored response (
tokens: {input:0, output:0})
- Loop continues indefinitely creating empty assistant messages
Session evidence: ses_1bba6f1c4ffe55rFTzStkU2fyM — 162 total messages, two dead zones:
- msg[143-149]: 7 empty assistant messages, all tokens=0
- msg[155-161]: 7 empty assistant messages, all tokens=0
- Recovery at msg[150] and msg[162] via compaction
Root Cause Analysis
The exit condition (prompt.ts L1268-1276)
The while(true) loop at prompt.ts:1248 breaks only when ALL four conditions are true:
if (
lastAssistant?.finish && // (1) finish is truthy
!["tool-calls"].includes(lastAssistant.finish) && // (2) not tool-calls
!hasToolCalls && // (3) no pending tools
lastUser.id < lastAssistant.id // (4) ID ordering
) {
break
}
When the LLM returns an empty response after a tool result, finish may be "unknown" or unset (condition 1 fails), and the loop continues.
Why safety nets don't catch it
-
DOOM_LOOP_THRESHOLD (processor.ts:424) — Only detects 3 consecutive identical tool calls (same tool name + same input). Does NOT catch empty responses or different tools.
-
isOverflow() (overflow.ts:20-31) — When tokens = {input:0, output:0}, count = 0, so isOverflow() returns false. The compaction safety net never triggers.
-
No max-empty-steps guard — There is no mechanism to detect "N consecutive steps with zero tokens" and force-exit the loop.
Recovery
Only compaction recovers the session — it regenerates context and creates an assistant message with finish: "stop", which passes the exit check.
Suggested Fix
Add a guard in the runLoop to detect consecutive empty responses:
// In the while(true) loop, after processor returns:
if (handle.message.tokens.input === 0 && handle.message.tokens.output === 0) {
emptyStepCount++
if (emptyStepCount >= 3) {
yield* slog.warn("exiting loop: consecutive empty responses")
break
}
} else {
emptyStepCount = 0
}
Alternatively, isOverflow() could treat consecutive zero-token responses as a degenerate case that should trigger compaction or loop exit.
Environment
- opencode: v1.15.5 (installed via Homebrew)
- Provider: custom (glm-5 via llm-router, OpenAI-compatible API)
- OS: macOS (darwin)
Related Issues
Description
After an LLM returns a tool call with empty/no text content, the agent loop enters an infinite cycle creating empty assistant messages (
tokens: {input:0, output:0}) without ever invoking the LLM. The only recovery path is manual compaction.Reproduction
tokens: {input:0, output:0})Session evidence:
ses_1bba6f1c4ffe55rFTzStkU2fyM— 162 total messages, two dead zones:Root Cause Analysis
The exit condition (
prompt.tsL1268-1276)The
while(true)loop atprompt.ts:1248breaks only when ALL four conditions are true:When the LLM returns an empty response after a tool result,
finishmay be"unknown"or unset (condition 1 fails), and the loop continues.Why safety nets don't catch it
DOOM_LOOP_THRESHOLD(processor.ts:424) — Only detects 3 consecutive identical tool calls (same tool name + same input). Does NOT catch empty responses or different tools.isOverflow()(overflow.ts:20-31) — Whentokens = {input:0, output:0},count = 0, soisOverflow()returnsfalse. The compaction safety net never triggers.No max-empty-steps guard — There is no mechanism to detect "N consecutive steps with zero tokens" and force-exit the loop.
Recovery
Only compaction recovers the session — it regenerates context and creates an assistant message with
finish: "stop", which passes the exit check.Suggested Fix
Add a guard in the
runLoopto detect consecutive empty responses:Alternatively,
isOverflow()could treat consecutive zero-token responses as a degenerate case that should trigger compaction or loop exit.Environment
Related Issues
delta.roleinstead of"assistant"#28427 — Provider returns emptydelta.role, stream error preventsfinishfrom being set