Skip to content

Commit ab8d68a

Browse files
committed
fix(oaistream): replay ReasoningContent when converting messages
OpenAI-compatible custom providers (e.g. Qwen served via llama.cpp, vLLM, or other endpoints using the DeepSeek-style reasoning_content convention) have their streamed reasoning captured into msg.ReasoningContent, but convertMessagesWithCaps built the outgoing assistant param from Content, FunctionCall, and ToolCalls only -- the stored reasoning was silently dropped when the conversation history was replayed on the next request. openai-go's typed ChatCompletionAssistantMessageParam has no field for reasoning_content (non-standard extension -- see openai/openai-go#558), so it is attached via SetExtraFields when present. This is a no-op for every model that does not produce reasoning. Fixes #4363 Signed-off-by: Jay <12496124+MysticalMount@users.noreply.github.com>
1 parent 9738ae2 commit ab8d68a

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

‎pkg/model/provider/oaistream/messages.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ func convertMessagesWithCaps(ctx context.Context, messages []chat.Message, mc mo
139139
}
140140
}
141141

142+
// Replay stored reasoning back to OpenAI-compatible providers (e.g. Qwen via
143+
// llama.cpp, vLLM, custom endpoints) that expect it on the assistant turn as
144+
// reasoning_content. openai-go's typed ChatCompletionAssistantMessageParam has
145+
// no such field (non-standard extension - see openai/openai-go#558), so it
146+
// must go through SetExtraFields. See issue #4363.
147+
if msg.ReasoningContent != "" {
148+
assistantParam.SetExtraFields(map[string]any{
149+
"reasoning_content": msg.ReasoningContent,
150+
})
151+
}
152+
142153
if msg.FunctionCall != nil {
143154
assistantParam.FunctionCall.Name = msg.FunctionCall.Name
144155
assistantParam.FunctionCall.Arguments = msg.FunctionCall.Arguments
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package oaistream
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/chat"
10+
"github.com/docker/docker-agent/pkg/modelsdev"
11+
"github.com/docker/docker-agent/pkg/tools"
12+
)
13+
14+
// TestReproIssue4363_ReasoningContentDroppedOnReplay reproduces
15+
// https://github.com/docker/docker-agent/issues/4363.
16+
//
17+
// For OpenAI-compatible custom providers (e.g. Qwen served via llama.cpp,
18+
// vLLM, or any other endpoint that speaks the DeepSeek-style
19+
// "reasoning_content" convention -- see the doc comment on
20+
// chat.Message.ReasoningContent), Docker Agent correctly captures streamed
21+
// reasoning, accumulates it, and stores it on the assistant chat.Message.
22+
//
23+
// However, convertMessagesWithCaps built the outgoing
24+
// ChatCompletionAssistantMessageParam from Content, FunctionCall, and
25+
// ToolCalls only -- msg.ReasoningContent was never read, so the reasoning
26+
// was silently dropped when the conversation was replayed on the next
27+
// request. A reasoning model then has no way to see its own prior
28+
// reasoning after a tool call, and must reconstruct it from scratch --
29+
// inflating reasoning tokens and latency on every subsequent turn.
30+
//
31+
// The fix attaches the stored reasoning to the outgoing assistant message
32+
// via SetExtraFields, since openai-go's typed
33+
// ChatCompletionAssistantMessageParam has no native field for it (this is
34+
// a non-standard, provider-specific extension; see openai/openai-go#558).
35+
func TestReproIssue4363_ReasoningContentDroppedOnReplay(t *testing.T) {
36+
t.Parallel()
37+
38+
store := modelsdev.NewDatabaseStore(&modelsdev.Database{})
39+
40+
t.Run("bug: reasoning content is carried onto the replayed assistant message", func(t *testing.T) {
41+
t.Parallel()
42+
43+
messages := []chat.Message{
44+
{
45+
Role: chat.MessageRoleAssistant,
46+
Content: "The capital of France is Paris.",
47+
ReasoningContent: "The user asked about France's capital; I recall it is Paris.",
48+
},
49+
}
50+
51+
result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil)
52+
require.Len(t, result, 1)
53+
require.NotNil(t, result[0].OfAssistant, "expected an assistant message param")
54+
55+
extra := result[0].OfAssistant.ExtraFields()
56+
require.NotNil(t, extra, "expected extra fields to carry reasoning_content")
57+
assert.Equal(t, "The user asked about France's capital; I recall it is Paris.", extra["reasoning_content"],
58+
"issue #4363: stored reasoning must be replayed to OpenAI-compatible providers, not silently dropped")
59+
})
60+
61+
t.Run("no reasoning content: no extra field is added", func(t *testing.T) {
62+
t.Parallel()
63+
64+
messages := []chat.Message{
65+
{Role: chat.MessageRoleAssistant, Content: "Hi there!"},
66+
}
67+
68+
result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil)
69+
require.Len(t, result, 1)
70+
require.NotNil(t, result[0].OfAssistant)
71+
72+
extra := result[0].OfAssistant.ExtraFields()
73+
assert.NotContains(t, extra, "reasoning_content",
74+
"models with no stored reasoning should not gain a reasoning_content field")
75+
})
76+
77+
t.Run("reasoning content survives alongside tool calls", func(t *testing.T) {
78+
t.Parallel()
79+
80+
messages := []chat.Message{
81+
{
82+
Role: chat.MessageRoleAssistant,
83+
Content: "",
84+
ReasoningContent: "I should call the weather tool for this city.",
85+
ToolCalls: []tools.ToolCall{
86+
{ID: "call_1"},
87+
},
88+
},
89+
}
90+
91+
result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil)
92+
require.Len(t, result, 1)
93+
require.NotNil(t, result[0].OfAssistant)
94+
95+
extra := result[0].OfAssistant.ExtraFields()
96+
assert.Equal(t, "I should call the weather tool for this city.", extra["reasoning_content"])
97+
assert.Len(t, result[0].OfAssistant.ToolCalls, 1, "tool calls must still be converted alongside reasoning")
98+
})
99+
}

0 commit comments

Comments
 (0)