Skip to content

Dead loop: infinite empty assistant messages (tokens=0) after tool call with empty text #28507

Description

@includewudi

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

  1. Start an interactive session with a custom provider (in our case, glm-5 via llm-router)
  2. LLM returns a tool call (e.g., Read a PNG file) with empty text in the response
  3. Tool executes successfully, result is sent back to the LLM
  4. On the next loop iteration, the LLM returns an empty/errored response (tokens: {input:0, output:0})
  5. 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

  1. 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.

  2. isOverflow() (overflow.ts:20-31) — When tokens = {input:0, output:0}, count = 0, so isOverflow() returns false. The compaction safety net never triggers.

  3. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions