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:
- Provider flake — stream returned empty due to upstream infra issue. Retry makes sense here.
- 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)
Summary
Running a subagent via the
agenttool (e.g. during multi-agent review flows like/ultrareview) occasionally fails with:even though other sibling subagents launched with the same prompt/tools succeed. The underlying cause is that
processStreamResponsetreats a model turn that correctly terminates withfinish_reasonbut emits zero visible content (no text, no thought, no tool call) as a hard stream error.Reproduction
/ultrareview) forks severalgeneral-purposesubagents in parallel.finish_reasonset (stream terminates cleanly)contentparts (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:The
NO_RESPONSE_TEXTbranch conflates two cases: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 emptyend_turnin some cases:Combined with the main prompt's
Minimal Output/No Chitchatguidance, 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 cleanfinish_reasonand 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:Suggested fix
Only throw when the finish reason is missing; treat
finish_reason present + empty content + no tool callas a valid end-of-turn. IngeminiChat.ts:If a defensive retry is still desired for genuine provider flake, the trigger should be stream-shape-specific (e.g.,
finish_reasonplus zero tokens onusage_metadata), not any empty response.Impact
/ultrareviewand other fork/agent-based flows, especially for "review/audit" style tasks where the model legitimately has nothing to add.Environment