From 630ad45e2010cb6ae3970fe11ddc69b3df741311 Mon Sep 17 00:00:00 2001 From: Ahsan Ali <20csm2k25@gmail.com> Date: Sat, 19 Sep 2026 23:45:19 +0500 Subject: [PATCH] fix(oaistream): replay ReasoningContent when converting messages Reasoning captured from OpenAI-compatible custom providers (e.g. Qwen via llama.cpp) was stored on chat.Message.ReasoningContent but never read back when converting the conversation for the next request. convertMessagesWithCaps built the outgoing assistant message from Content, FunctionCall, and ToolCalls only, so reasoning was silently dropped on replay. This meant a reasoning model could never see its own prior reasoning after a tool call, forcing it to reconstruct that analysis from scratch on every subsequent turn, inflating reasoning tokens and latency in long-running agent workflows. openai-go's typed ChatCompletionAssistantMessageParam has no field for reasoning_content, since it is a non-standard, provider-specific extension (see openai/openai-go#558), so the stored reasoning is attached via SetExtraFields instead. Fixes #4363 Signed-off-by: Ahsan Ali <20csm2k25@gmail.com> --- pkg/model/provider/oaistream/messages.go | 11 +++ .../oaistream/repro_issue4363_test.go | 96 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 pkg/model/provider/oaistream/repro_issue4363_test.go diff --git a/pkg/model/provider/oaistream/messages.go b/pkg/model/provider/oaistream/messages.go index 0f724ce5df..6040a1e0da 100644 --- a/pkg/model/provider/oaistream/messages.go +++ b/pkg/model/provider/oaistream/messages.go @@ -139,6 +139,17 @@ func convertMessagesWithCaps(ctx context.Context, messages []chat.Message, mc mo } } + // Replay stored reasoning back to OpenAI-compatible providers (e.g. Qwen via + // llama.cpp, vLLM, custom endpoints) that expect it on the assistant turn as + // reasoning_content. openai-go's typed ChatCompletionAssistantMessageParam has + // no such field (non-standard extension - see openai/openai-go#558), so it + // must go through SetExtraFields. See issue #4363. + if msg.ReasoningContent != "" { + assistantParam.SetExtraFields(map[string]any{ + "reasoning_content": msg.ReasoningContent, + }) + } + if msg.FunctionCall != nil { assistantParam.FunctionCall.Name = msg.FunctionCall.Name assistantParam.FunctionCall.Arguments = msg.FunctionCall.Arguments diff --git a/pkg/model/provider/oaistream/repro_issue4363_test.go b/pkg/model/provider/oaistream/repro_issue4363_test.go new file mode 100644 index 0000000000..53720a3dea --- /dev/null +++ b/pkg/model/provider/oaistream/repro_issue4363_test.go @@ -0,0 +1,96 @@ +package oaistream + +import ( +"testing" + +"github.com/stretchr/testify/assert" +"github.com/stretchr/testify/require" + +"github.com/docker/docker-agent/pkg/chat" +"github.com/docker/docker-agent/pkg/modelsdev" +"github.com/docker/docker-agent/pkg/tools" +) + +// TestReproIssue4363_ReasoningContentDroppedOnReplay reproduces +// https://github.com/docker/docker-agent/issues/4363. +// +// For OpenAI-compatible custom providers (e.g. Qwen served via llama.cpp, +// vLLM, or any other endpoint that speaks the DeepSeek-style +// "reasoning_content" convention -- see the doc comment on +// chat.Message.ReasoningContent), Docker Agent correctly captures streamed +// reasoning, accumulates it, and stores it on the assistant chat.Message. +// +// However, convertMessagesWithCaps built the outgoing +// ChatCompletionAssistantMessageParam from Content, FunctionCall, and +// ToolCalls only -- msg.ReasoningContent was never read, so the reasoning +// was silently dropped when the conversation was replayed on the next +// request. A reasoning model then has no way to see its own prior +// reasoning after a tool call, and must reconstruct it from scratch -- +// inflating reasoning tokens and latency on every subsequent turn. +// +// The fix attaches the stored reasoning to the outgoing assistant message +// via SetExtraFields, since openai-go's typed +// ChatCompletionAssistantMessageParam has no native field for it (this is +// a non-standard, provider-specific extension; see openai/openai-go#558). +func TestReproIssue4363_ReasoningContentDroppedOnReplay(t *testing.T) { +t.Parallel() + +store := modelsdev.NewDatabaseStore(&modelsdev.Database{}) + +t.Run("bug: reasoning content is carried onto the replayed assistant message", func(t *testing.T) { +t.Parallel() +messages := []chat.Message{ +{ +Role: chat.MessageRoleAssistant, +Content: "The capital of France is Paris.", +ReasoningContent: "The user asked about France's capital; I recall it is Paris.", +}, +} + +result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil) +require.Len(t, result, 1) +require.NotNil(t, result[0].OfAssistant, "expected an assistant message param") + +extra := result[0].OfAssistant.ExtraFields() +require.NotNil(t, extra, "expected extra fields to carry reasoning_content") +assert.Equal(t, "The user asked about France's capital; I recall it is Paris.", extra["reasoning_content"], +"issue #4363: stored reasoning must be replayed to OpenAI-compatible providers, not silently dropped") +}) + +t.Run("no reasoning content: no extra field is added", func(t *testing.T) { +t.Parallel() +messages := []chat.Message{ +{Role: chat.MessageRoleAssistant, Content: "Hi there!"}, +} + +result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil) +require.Len(t, result, 1) +require.NotNil(t, result[0].OfAssistant) + +extra := result[0].OfAssistant.ExtraFields() +assert.NotContains(t, extra, "reasoning_content", +"models with no stored reasoning should not gain a reasoning_content field") +}) + +t.Run("reasoning content survives alongside tool calls", func(t *testing.T) { +t.Parallel() +messages := []chat.Message{ +{ +Role: chat.MessageRoleAssistant, +Content: "", +ReasoningContent: "I should call the weather tool for this city.", +ToolCalls: []tools.ToolCall{ +{ID: "call_1"}, +}, +}, +} + +result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil) +require.Len(t, result, 1) +require.NotNil(t, result[0].OfAssistant) + +extra := result[0].OfAssistant.ExtraFields() +assert.Equal(t, "I should call the weather tool for this city.", extra["reasoning_content"]) +assert.Len(t, result[0].OfAssistant.ToolCalls, 1, "tool calls must still be converted alongside reasoning") +}) +}