Skip to content

subagent fails with "Model stream ended with empty response text" when model legitimately ends turn with no content #3516

@zhangxy-zju

Description

@zhangxy-zju

Summary

Running a subagent via the agent tool (e.g. during multi-agent review flows like /ultrareview) occasionally fails with:

Failed to run subagent: Model stream ended with empty response text.

even though other sibling subagents launched with the same prompt/tools succeed. The underlying cause is that processStreamResponse treats a model turn that correctly terminates with finish_reason but emits zero visible content (no text, no thought, no tool call) as a hard stream error.

Reproduction

  • Multi-agent review session (e.g. /ultrareview) forks several general-purpose subagents in parallel.
  • One subagent whose task is "fresh audit"-style (review after sibling reviews have already surfaced findings) completes 20+ tool calls, then the model decides it has nothing to add and ends its final turn with:
    • finish_reason set (stream terminates cleanly)
    • empty content parts (no text, no thinking, no function call)

Result: the subagent is reported as failed despite having done its work; the parent review aggregates an error result and degrades the overall output.

Root cause

packages/core/src/core/geminiChat.ts:1088-1101:

const hasAnyContent = contentText || thoughtText;
if (!hasToolCall && (!hasFinishReason || !hasAnyContent)) {
  if (!hasFinishReason) {
    throw new InvalidStreamError('Model stream ended without a finish reason.',
      'NO_FINISH_REASON');
  } else {
    throw new InvalidStreamError('Model stream ended with empty response text.',
      'NO_RESPONSE_TEXT');
  }
}

The NO_RESPONSE_TEXT branch conflates two cases:

  1. Provider flake — stream returned empty due to upstream infra issue. Retry makes sense here.
  2. Legitimate empty end_turn — model cleanly decided no further content is warranted. This is valid model behavior and should not be an error.

The existing retry budget (2 retries, geminiChat.ts:81-84) helps case 1 but does nothing for case 2, which then bubbles up and kills the subagent.

Contributing prompt behavior

The subagent system prompt (packages/core/src/agents/runtime/agent-core.ts:1090-1096) actively pushes the model toward empty end_turn in some cases:

Important Rules:
 - You operate in non-interactive mode: do not ask the user questions; proceed with available context.
 - Use tools only when necessary to obtain facts or make changes.
 - When the task is complete, return the final result as a normal model response (not a tool call) and stop.

Combined with the main prompt's Minimal Output / No Chitchat guidance, this tells the model "don't end with a tool call, be concise, use text only when needed" — which a literal interpretation satisfies by ending with a clean finish_reason and no content blocks.

Comparison with Claude Code

Claude Code treats this situation as a legitimate turn end, not a failure. isResultSuccessful() has an explicit carve-out:

// API completed (message_delta set stop_reason) but yielded no assistant
// content — [...] recognizes end_turn-with-zero-content-blocks as legitimate
// and passes through without throwing. Observed on task_notification drain
// turns: model returns stop_reason=end_turn, outputTokens=4, textContentLength=0
// — it saw the subagent result and decided nothing needed saying.
return stopReason === 'end_turn'

Suggested fix

Only throw when the finish reason is missing; treat finish_reason present + empty content + no tool call as a valid end-of-turn. In geminiChat.ts:

- const hasAnyContent = contentText || thoughtText;
- if (!hasToolCall && (!hasFinishReason || !hasAnyContent)) {
-   if (!hasFinishReason) {
-     throw new InvalidStreamError('Model stream ended without a finish reason.', 'NO_FINISH_REASON');
-   } else {
-     throw new InvalidStreamError('Model stream ended with empty response text.', 'NO_RESPONSE_TEXT');
-   }
- }
+ if (!hasToolCall && !hasFinishReason) {
+   throw new InvalidStreamError('Model stream ended without a finish reason.', 'NO_FINISH_REASON');
+ }
+ // finish_reason present + empty content is a legitimate no-op turn — accept it.

If a defensive retry is still desired for genuine provider flake, the trigger should be stream-shape-specific (e.g., finish_reason plus zero tokens on usage_metadata), not any empty response.

Impact

  • Spurious subagent failures in /ultrareview and other fork/agent-based flows, especially for "review/audit" style tasks where the model legitimately has nothing to add.
  • Flaky, non-deterministic: sibling subagents launched in parallel with identical prompts succeed while one fails, making the behavior confusing to users.

Environment

  • qwen-code version: 0.14.5
  • Provider / model: DashScope (qwen3-coder family)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions