Skip to content

fix(runtime): stop content-only turns that end with a bare EOF (no finish_reason)#3669

Open
awschmeder wants to merge 1 commit into
docker:mainfrom
awschmeder:fix/3668-bare-eof-content-turn-reentry-loop
Open

fix(runtime): stop content-only turns that end with a bare EOF (no finish_reason)#3669
awschmeder wants to merge 1 commit into
docker:mainfrom
awschmeder:fix/3668-bare-eof-content-turn-reentry-loop

Conversation

@awschmeder

Copy link
Copy Markdown

Summary

Fixes #3668.

A sub-agent reached via transfer_task returns control to its hub by finishing its turn: a final message with content and no tool call. When the provider closes that turn's SSE stream with a bare EOF -- no per-choice finish_reason (common with OpenAI-compatible gateways such as litellm/CBORG, which emit only a terminal [DONE] sentinel) -- and the message has real content but no tool calls, handleStream keyed its stop decision on empty content and returned Stopped=false. runTurn then re-entered the model with an unchanged message list and the model re-emitted the same completion forever, so the hand-off back to the hub never completed.

The same defect applies to any turn that ends with a bare EOF and produces content but no tool calls; the transfer_task hub-and-spoke hand-off is where it is most visible, because a sub-agent's normal, correct way to return control is exactly such a turn.

Change

Key the bare-EOF stop decision on the absence of tool calls, matching the mid-stream path (Stopped: len(toolCalls) == 0):

stoppedDueToNoOutput := len(toolCalls) == 0

A turn on the bare-EOF path is terminal whenever no tool calls remain to execute, regardless of whether it produced content. The empty-content cases the original #3145 guard covered (token limit, whitespace-only or reasoning-only reply) stay covered, because those turns also have no tool calls. The finishReason inference below this line already distinguishes content-vs-no-content for reporting, so nothing else changes there.

Tests

  • Adds TestHandleStream_ContentOnlyBareEOFStops: a stream that emits real content and no tool calls, then closes with a bare EOF, must report Stopped=true. Complements the existing TestHandleStream_WhitespaceOnlyContentStops (the Stream silently produces empty response when model outputs non-standard delta.reasoning SSE field (Qwen3 thinking mode) #3145 case).
  • Updates TestCompactLiveSession_DuplicateSessionIDsCompactOnlyTargetEntry: its older stream relied on a content-only bare-EOF turn continuing to a second model call. That reflected the bug, not intended semantics. The turn is now a tool call so the older stream deterministically reaches its iteration boundary independent of the stop rule.

Verification

gofmt, go vet ./pkg/runtime/, go build ./..., and go test ./pkg/runtime/... all pass. (task/golangci-lint were not available in my environment; CI will cover the full lint.)

A sub-agent reached via transfer_task returns control to its hub by
finishing its turn: a final message with content and no tool call. When
the provider closes that turn's SSE stream with a bare EOF and no
per-choice finish_reason (common with OpenAI-compatible gateways such as
litellm/CBORG), handleStream keyed its stop decision on empty content, so
a content-bearing final turn with no tool calls reported Stopped=false.
runTurn then re-entered the model with an unchanged message list and the
model re-emitted the same completion forever, so the hand-off never
completed.

Key the bare-EOF stop decision on the absence of tool calls, matching the
mid-stream path. A turn is terminal on bare EOF whenever no tool calls
remain to execute, regardless of content. The empty-content cases the
original docker#3145 guard covered (token limit, whitespace-only or
reasoning-only reply) stay covered because they also have no tool calls.

Add TestHandleStream_ContentOnlyBareEOFStops. Update
TestCompactLiveSession_DuplicateSessionIDsCompactOnlyTargetEntry, whose
older stream relied on a content-only bare-EOF turn continuing to a second
model call; make that turn a tool call so it deterministically reaches its
iteration boundary independent of the stop rule.

Fixes docker#3668

Signed-off-by: awschmeder <awschmeder@lbl.gov>
@awschmeder
awschmeder requested a review from a team as a code owner July 15, 2026 19:28
@aheritier aheritier added area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Jul 15, 2026

@Sayt-0 Sayt-0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root cause and fix are correct: the bare-EOF path now matches the mid-stream path (Stopped: len(toolCalls) == 0). Build, vet and runtime tests pass locally on the merge with current main.

CI failure (TestDocYAMLSnippetsAreValid on docs/tools/scheduler/index.md) is unrelated to this diff: the run hit the window where main was broken between #3632 and #3670, and the file does not exist on this branch. A re-run or rebase should clear it.

Blocking: the "content + bare EOF keeps the loop running" test idiom was converted in only one of the three live-session tests using it. Measured by instrumenting both compaction drain sites:

drain path main this PR
iteration boundary (runQueuedCompaction, loop.go:444) 3 tests 0
teardown (finishLiveSession) 2 tests 5

TestCompactLiveSession_ExecutesAtIterationBoundary still passes but via the teardown drain, so the boundary path loses all coverage. Converting its turn 1 to a tool-call turn (same idiom as DuplicateSessionIDsCompactOnlyTargetEntry) restores it. Same pattern in HookVetoSynthesizesSkipped, minor there.

// back in); stealing the request would consume this step as the
// compaction summary instead.
{stream: newStreamBuilder().AddStopWithUsage(1, 1).Build()},
// The compaction summary call, drained by the newer stream's own

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"drained by the newer stream's own iteration boundary" is now inaccurate: with the new stop rule the newer stream's content turn stops, so the request is drained at teardown (finishLiveSession), not at an iteration boundary. Either fix the comment or make this turn a tool-call turn as well.

Comment thread pkg/runtime/streaming.go
// reply) remains covered because it also has no tool calls.
// NOTE(krissetto): this can likely be removed once compaction works properly with all providers (aka dmr)
stoppedDueToNoOutput := strings.TrimSpace(fullContent.String()) == "" && len(toolCalls) == 0
stoppedDueToNoOutput := len(toolCalls) == 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: stoppedDueToNoOutput no longer matches its condition (no tool calls, content or not); stoppedNoToolCalls, or inlining the expression into the return, would be clearer. The comment could also be condensed to the invariant: a bare-EOF turn is terminal when no tool calls remain; the empty-content case (#3145) stays covered as a subset.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

transfer_task sub-agent loops forever when its final message ends with a bare EOF (no finish_reason)

3 participants