-
Notifications
You must be signed in to change notification settings - Fork 417
fix(runtime): stop content-only turns that end with a bare EOF (no finish_reason)
#3669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -359,14 +359,11 @@ mainLoop: | |||||||||
|
|
||||||||||
| applyXMLFallback() | ||||||||||
|
|
||||||||||
| // If the stream completed without producing any usable content or tool | ||||||||||
| // calls, likely because of a token limit, stop to avoid breaking the request | ||||||||||
| // loop. Whitespace-only content counts as no output: it carries no answer, | ||||||||||
| // and runTurn's empty-turn detection uses the same trimmed-empty test, so | ||||||||||
| // treating it as stopped here guarantees that an empty-turn warning is always | ||||||||||
| // followed by a turn exit rather than an identical-message re-entry (#3145). | ||||||||||
| // Invariant: a bare-EOF turn (no per-choice finish_reason) is terminal | ||||||||||
| // whenever there are no tool calls — the outer loop has nothing to continue | ||||||||||
| // on. Turns with tool calls keep Stopped=false so the loop executes them. | ||||||||||
| // NOTE(krissetto): this can likely be removed once compaction works properly with all providers (aka dmr) | ||||||||||
| stoppedDueToNoOutput := strings.TrimSpace(fullContent.String()) == "" && len(toolCalls) == 0 | ||||||||||
| stoppedNoToolCalls := len(toolCalls) == 0 | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] Stale NOTE comment: now implies a permanent invariant is a removable workaround The retained Consider replacing the NOTE with a brief comment explaining why this invariant is permanent:
Suggested change
Or updating the NOTE to reflect that the condition changed meaning, e.g.:
|
||||||||||
|
|
||||||||||
| // Prefer the provider's explicit finish reason when available (e.g. | ||||||||||
| // tool_calls). Only fall back to inference when no explicit reason was | ||||||||||
|
|
@@ -399,7 +396,7 @@ mainLoop: | |||||||||
| ReasoningContent: fullReasoningContent.String(), | ||||||||||
| ThinkingSignature: thinkingSignature, | ||||||||||
| ThoughtSignature: thoughtSignature, | ||||||||||
| Stopped: stoppedDueToNoOutput, | ||||||||||
| Stopped: stoppedNoToolCalls, | ||||||||||
| FinishReason: finishReason, | ||||||||||
| Usage: messageUsage, | ||||||||||
| }, nil | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -190,6 +190,33 @@ func TestHandleStream_WhitespaceOnlyContentStops(t *testing.T) { | |||||
| "a whitespace-only, bare-EOF turn must stop so the empty-turn warning is followed by a turn exit, not an identical re-entry (#3145)") | ||||||
| } | ||||||
|
|
||||||
| // TestHandleStream_ContentOnlyBareEOFStops guards the loop that OpenAI-compatible | ||||||
| // gateways (litellm/CBORG) trigger: they close the SSE stream with a bare EOF and | ||||||
| // never send a per-choice finish_reason. A turn that produced real content but no | ||||||
| // tool calls has nothing left for the run loop to execute, so it must report | ||||||
| // Stopped=true. Keying the stop decision on empty content instead of "no tool | ||||||
| // calls" left such a final message with Stopped=false, so runTurn re-entered the | ||||||
| // model with identical messages and re-emitted the same completion text forever. | ||||||
| func TestHandleStream_ContentOnlyBareEOFStops(t *testing.T) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] Missing test for the symmetric bare-EOF+tool-calls path (Stopped=false)
The existing A companion test like: func TestHandleStream_ToolCallsBareEOFContinues(t *testing.T) {
stream := newStreamBuilder().
AddToolCallName("call_1", "some_tool").
AddToolCallArguments("call_1", `{}`).
Build() // bare EOF — no finish_reason chunk
// ... assert res.Stopped == false
}would close this gap and make the invariant's two sides explicit.
|
||||||
| stream := newStreamBuilder(). | ||||||
| AddContent("Frontmatter ingestion complete."). // real content, no tool calls | ||||||
| Build() // no terminal chunk: bare EOF, no finish reason | ||||||
|
|
||||||
| a := agent.New("root", "test", agent.WithModel(&mockProvider{id: "test/mock-model", stream: stream})) | ||||||
| sess := session.New(session.WithUserMessage("go")) | ||||||
|
|
||||||
| evCh := make(chan Event, 64) | ||||||
| res, err := handleStream( | ||||||
| t.Context(), nil, stream, a, nil, sess, nil, | ||||||
| defaultTelemetry{}, NewChannelSink(evCh), defaultStreamIdleTimeout, | ||||||
| ) | ||||||
| require.NoError(t, err) | ||||||
|
|
||||||
| assert.Empty(t, res.Calls) | ||||||
| assert.True(t, res.Stopped, | ||||||
| "a content-bearing, bare-EOF turn with no tool calls must stop so the run loop exits instead of re-entering with identical messages") | ||||||
| } | ||||||
|
|
||||||
| // stalledStream is a chat.MessageStream that blocks in Recv() until | ||||||
| // either unblocked or the stream is closed. It is used to simulate a | ||||||
| // half-open TCP connection where the remote side stops sending data. | ||||||
|
|
||||||
There was a problem hiding this comment.
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.