mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
fix(oaistream): replay reasoning content with opt-out
Fixes #4363 Signed-off-by: Jay <12496124+MysticalMount@users.noreply.github.com>
This commit is contained in:
14 files changed
+851
-9
No files matched your search
+1
-1
@@ -1756,7 +1756,7 @@
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"description": "Provider-specific options. Anthropic: thinking_prefix_mismatch (error or drop_block; defaults to drop_block on Claude models whose server checks thinking prefixes: Haiku 5.5, Opus 5.5, Sonnet 5.5 and Fable 5.1, plus Fable 5.2+ by name), strict_tools (true for eligible schemas or a tool-name array requiring strict mode), cache_diagnostics (boolean; Claude API only), native_compaction (boolean; synchronous whole-history signed compaction on Claude API), cache_preserving_updates (boolean; native system/tool/effort changes with turn-scoped reminders on supported models), thinking_display: updates (progress updates beta on Fable 5+ and Mythos 5.1). Sampling parameters: top_k (integer, supported by anthropic, google, amazon-bedrock, and custom OpenAI-compatible providers like vLLM/Ollama), repetition_penalty (float, forwarded to custom OpenAI-compatible providers), min_p (float, forwarded to custom providers), seed (integer, forwarded to OpenAI). Lifecycle: unload_api (string) overrides the unload endpoint inherited from the provider config (relative path resolved against base_url's scheme+host, or an absolute URL); used by the runtime's `unload` on_agent_switch builtin hook to release model resources between agent switches. Infrastructure options: http_headers (map of string to string, adds custom HTTP headers to every request; used for OpenAI-compatible providers like github-copilot which requires Copilot-Integration-Id). dmr: runtime_flags; supports_images (boolean) and supports_pdf (boolean) declare which document attachment types the local model accepts. DMR-hosted models are not in the models.dev catalog, so attachment capabilities cannot be detected automatically and default to text-only; set supports_images: true for a vision model so image attachments are forwarded instead of silently dropped. anthropic/amazon-bedrock (Claude): interleaved_thinking (boolean, default true). anthropic: thinking_display ('summarized', 'omitted', 'updates', or 'display') controls whether thinking blocks are returned in responses when thinking is enabled. Claude Opus 4.7 hides thinking by default ('omitted'); set thinking_display: summarized to receive thinking blocks. 'display' (full thinking blocks) is only accepted by pre-4.6 token-thinking models (e.g. Sonnet 4.5, Haiku 4.5); models from the adaptive-thinking generation onward (Opus/Sonnet 4.6+, Sonnet 5, Fable 5) reject it, and docker-agent fails fast with a configuration error (fallbacks models are validated too); fallbacks (array of model ID strings, in priority order; enables the server-side-fallback beta so refused requests are retried with the listed models in a single round trip; rejected when the primary or any fallback is a Claude 5.x model until issue #4584). OpenAI-compatible APIs: service_tier (string, forwarded unchanged to Chat Completions and Responses, including WebSocket and reranking; 'fast' or 'priority' enables Fast mode at premium pricing on supported models; omitted or empty leaves the API default, non-string values are ignored). openai/chatgpt: preserve_reasoning (boolean, default false) preserves ordered Responses output and encrypted reasoning for stateless continuation; native_tool_search also enables output replay. openai: cache_diagnostics (boolean, default false) logs Responses prompt-cache comparison results with --debug on GPT-5.6+; comparisons are scoped to the current session and resolved model; ignored for chatgpt and custom base_url endpoints. transport ('sse' or 'websocket') to choose between SSE and WebSocket streaming for the Responses API; native_tool_search (boolean, default false; also enables ordered output replay) declares the agent's deferred tools with defer_loading and a server-executed tool_search tool so OpenAI's hosted tool search discovers them (only openai models supporting deferred tools, gpt-5.4+; ignored on chatgpt, OpenAI-compatible endpoints including a custom base_url, and Chat Completions requests). openai/anthropic/google: rerank_prompt (string) to fully override the system prompt used for RAG reranking (advanced - prefer using results.reranking.criteria for domain-specific guidance). Google: google_search (boolean) enables Google Search grounding, google_maps (boolean) enables Google Maps grounding, code_execution (boolean) enables server-side code execution, url_context (boolean) enables reading public URLs during generation, output_dimensionality (positive integer) truncates Gemini embedding vectors to that size when the model is used as a RAG embedding_model; service_tier (string, forwarded unchanged as the top-level serviceTier of every generateContent request, including reranking, title generation, and compaction, on the Gemini API, Vertex AI, and the Docker models gateway; 'flex' for discounted, queued processing that may wait up to 15 minutes before the first byte, 'standard' for the default tier, 'priority' for premium processing; omitted or empty leaves the API default, non-strLine truncated
|
||||
"description": "Provider-specific options. Anthropic: thinking_prefix_mismatch (error or drop_block; defaults to drop_block on Claude models whose server checks thinking prefixes: Haiku 5.5, Opus 5.5, Sonnet 5.5 and Fable 5.1, plus Fable 5.2+ by name), strict_tools (true for eligible schemas or a tool-name array requiring strict mode), cache_diagnostics (boolean; Claude API only), native_compaction (boolean; synchronous whole-history signed compaction on Claude API), cache_preserving_updates (boolean; native system/tool/effort changes with turn-scoped reminders on supported models), thinking_display: updates (progress updates beta on Fable 5+ and Mythos 5.1). Sampling parameters: top_k (integer, supported by anthropic, google, amazon-bedrock, and custom OpenAI-compatible providers like vLLM/Ollama), repetition_penalty (float, forwarded to custom OpenAI-compatible providers), min_p (float, forwarded to custom providers), seed (integer, forwarded to OpenAI). Lifecycle: unload_api (string) overrides the unload endpoint inherited from the provider config (relative path resolved against base_url's scheme+host, or an absolute URL); used by the runtime's `unload` on_agent_switch builtin hook to release model resources between agent switches. Infrastructure options: http_headers (map of string to string, adds custom HTTP headers to every request; used for OpenAI-compatible providers like github-copilot which requires Copilot-Integration-Id). dmr: runtime_flags; supports_images (boolean) and supports_pdf (boolean) declare which document attachment types the local model accepts. DMR-hosted models are not in the models.dev catalog, so attachment capabilities cannot be detected automatically and default to text-only; set supports_images: true for a vision model so image attachments are forwarded instead of silently dropped. anthropic/amazon-bedrock (Claude): interleaved_thinking (boolean, default true). anthropic: thinking_display ('summarized', 'omitted', 'updates', or 'display') controls whether thinking blocks are returned in responses when thinking is enabled. Claude Opus 4.7 hides thinking by default ('omitted'); set thinking_display: summarized to receive thinking blocks. 'display' (full thinking blocks) is only accepted by pre-4.6 token-thinking models (e.g. Sonnet 4.5, Haiku 4.5); models from the adaptive-thinking generation onward (Opus/Sonnet 4.6+, Sonnet 5, Fable 5) reject it, and docker-agent fails fast with a configuration error (fallbacks models are validated too); fallbacks (array of model ID strings, in priority order; enables the server-side-fallback beta so refused requests are retried with the listed models in a single round trip; rejected when the primary or any fallback is a Claude 5.x model until issue #4584). OpenAI-compatible APIs: service_tier (string, forwarded unchanged to Chat Completions and Responses, including WebSocket and reranking; 'fast' or 'priority' enables Fast mode at premium pricing on supported models; omitted or empty leaves the API default, non-string values are ignored). openai/chatgpt: preserve_reasoning (boolean, default false) preserves ordered Responses output and encrypted reasoning for stateless continuation; native_tool_search also enables output replay. Any OpenAI-compatible destination: replay_reasoning (boolean) replays the reasoning stored on assistant turns back as a reasoning_content field on later Chat Completions requests, so a reasoning model sees its own earlier reasoning instead of re-deriving it after each tool call (distinct from preserve_reasoning, which replays encrypted reasoning items on the Responses API and does not apply to Chat Completions); default true for Chat Completions; set replay_reasoning to false for an endpoint that rejects or must not receive the non-standard field; a non-boolean value also disables replay. openai: cache_diagnostics (boolean, default false) logs Responses prompt-cache comparison results with --debug on GPT-5.6+; comparisons are scoped to the current session and resolved model; ignored for chatgpt and custom base_url endpoints. transport ('sse' or 'websocket') to choose between SSE and WebSocket streaming for the Responses API; native_tool_search (boolean, default false; also enables ordered output replay) declares the agent's deferred tools with defer_loading and a server-executed tool_search tool so OpenAI's hosted tool search discovers them (only openai models supporting deferred tools, gpt-5.4+; ignored on chatgpt, OpenAI-compatible endpoints including a custom base_url, and Chat Completions requests). openai/anthropic/google: rerank_prompt (string) to fully override the system prompt used for RAG reranking (advanced - prefer using results.reranking.criteria for domain-specific guidance). Google: google_search (boolean) enables Google Search grounding, google_maps (boolean) enables Google Maps grounding, code_execution (boolean) enables server-side code execution, url_context (boolean) enables reading publiLine truncated
|
||||
"additionalProperties": true
|
||||
},
|
||||
"track_usage": {
|
||||
|
||||
@@ -166,6 +166,51 @@ agents:
|
||||
>
|
||||
> Models that stream reasoning under `delta.reasoning` (e.g. Qwen3 served via OVHcloud AI Endpoints, OpenRouter, or a self-hosted vLLM / SGLang deployment) are fully supported. Docker Agent reads both the `delta.reasoning_content` and `delta.reasoning` fields from the stream, so thinking blocks are captured and shown in the TUI regardless of which field the server uses.
|
||||
|
||||
### Replaying reasoning on later turns
|
||||
|
||||
Captured reasoning is sent back to the model on subsequent requests as a `reasoning_content` field on the assistant message. Servers that understand the field read it back, so the model sees its own earlier reasoning instead of re-deriving it after every tool call. Reasoning that is only useful to the model is not free: replaying it can multiply the size of a long request, so it is limited to turns the session keeps anyway (those with content or a tool call).
|
||||
|
||||
Chat Completions reasoning replay defaults on. Because `reasoning_content` is a
|
||||
non-standard extension, set `provider_opts.replay_reasoning: false` for a model
|
||||
or provider whose endpoint rejects or must not receive it. An invalid,
|
||||
non-boolean value also fails closed and disables replay.
|
||||
|
||||
Set the opt-out on a model, or once on a provider and let its models inherit it.
|
||||
A model that sets the option itself wins over the provider:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
local_llm:
|
||||
base_url: http://localhost:8000/v1
|
||||
provider_opts:
|
||||
replay_reasoning: false # opt out every local_llm model
|
||||
|
||||
models:
|
||||
qwen_default:
|
||||
provider: local_llm
|
||||
model: qwen3 # inherits the opt-out
|
||||
|
||||
qwen_replay:
|
||||
provider: local_llm
|
||||
model: qwen3
|
||||
provider_opts:
|
||||
replay_reasoning: true # opts this model back in
|
||||
|
||||
agents:
|
||||
root:
|
||||
model: qwen_default
|
||||
```
|
||||
|
||||
This option is specific to the Chat Completions API and its `reasoning_content`
|
||||
field. It is not the same as `preserve_reasoning`, which replays the encrypted
|
||||
reasoning items of first-party OpenAI Responses sessions; neither option
|
||||
applies to the other API. See [Reasoning state preservation](../openai/index.md#reasoning-state-preservation).
|
||||
|
||||
The field is worth a look if you switch models mid-session or use fallbacks:
|
||||
Docker Agent stores reasoning from every provider in one place, so a later Chat
|
||||
Completions model may otherwise receive reasoning captured from another vendor.
|
||||
Set `replay_reasoning: false` on that destination when this is undesirable.
|
||||
|
||||
### API Router (Requesty, LiteLLM)
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -218,9 +218,9 @@ generated values. This option does not apply to the Responses API.
|
||||
|
||||
## Reasoning State Preservation
|
||||
|
||||
Reasoning replay is opt-in, so existing configurations keep their request and
|
||||
session-storage behavior. Enable it for first-party OpenAI Responses sessions,
|
||||
including the `chatgpt` provider:
|
||||
Responses reasoning replay is opt-in, so existing configurations keep their
|
||||
request and session-storage behavior. Enable it for first-party OpenAI
|
||||
Responses sessions, including the `chatgpt` provider:
|
||||
|
||||
```yaml
|
||||
provider_opts:
|
||||
@@ -247,3 +247,9 @@ WebSocket requests replay the full history without also chaining it through `pre
|
||||
|
||||
See [OpenAI's reasoning guide](https://developers.openai.com/api/docs/guides/reasoning)
|
||||
and [cache diagnostics guide](https://developers.openai.com/api/docs/guides/prompt-caching/diagnostics).
|
||||
|
||||
`preserve_reasoning` is unrelated to `provider_opts.replay_reasoning`, which
|
||||
replays plain reasoning text as a `reasoning_content` field on Chat
|
||||
Completions requests to OpenAI-compatible servers. It does not apply to the
|
||||
Responses API, and `preserve_reasoning` does not apply to Chat Completions.
|
||||
See [Replaying reasoning on later turns](../custom/index.md#replaying-reasoning-on-later-turns).
|
||||
@@ -0,0 +1,52 @@
|
||||
# Example: Replaying reasoning to an OpenAI-compatible model
|
||||
#
|
||||
# Docker Agent captures reasoning from reasoning models (delta.reasoning_content
|
||||
# or delta.reasoning on OpenAI-compatible streams) and replays it on later turns,
|
||||
# so the model sees its own earlier reasoning instead of re-deriving it after
|
||||
# every tool call. The replay is sent as a reasoning_content field on the
|
||||
# assistant message.
|
||||
#
|
||||
# Replay defaults on for Chat Completions. Set
|
||||
# provider_opts.replay_reasoning: false when an endpoint rejects or must not
|
||||
# receive the non-standard field. Replayed reasoning counts against the context
|
||||
# window, so a model that reasons at length is also the case where opting out
|
||||
# saves the most tokens.
|
||||
#
|
||||
# This option is Chat Completions only. The Responses API has a separate
|
||||
# preserve_reasoning option for its encrypted reasoning items; neither option
|
||||
# applies to the other API.
|
||||
|
||||
providers:
|
||||
# A local server fronting an open-weight reasoning model. Replay is already
|
||||
# on for this destination; the explicit setting is shown for contrast.
|
||||
local_vllm:
|
||||
provider: openai
|
||||
api_type: openai_chatcompletions
|
||||
base_url: http://localhost:8000/v1
|
||||
|
||||
models:
|
||||
qwen_replay:
|
||||
provider: local_vllm
|
||||
model: qwen3-30b-a3b
|
||||
provider_opts:
|
||||
replay_reasoning: true
|
||||
|
||||
# Same server, but this model should never receive stored reasoning.
|
||||
qwen_no_replay:
|
||||
provider: local_vllm
|
||||
model: qwen3-30b-a3b
|
||||
provider_opts:
|
||||
replay_reasoning: false
|
||||
|
||||
agents:
|
||||
root:
|
||||
model: qwen_replay
|
||||
description: Main assistant, replaying its own reasoning
|
||||
instruction: |
|
||||
You are a helpful AI assistant. Be concise and helpful in your responses.
|
||||
|
||||
analyst:
|
||||
model: qwen_no_replay
|
||||
description: Sub-agent that starts each turn from scratch
|
||||
instruction: |
|
||||
You are a specialized assistant for specific tasks.
|
||||
+11
-4
@@ -56,10 +56,17 @@ type Message struct {
|
||||
Content string `json:"content"`
|
||||
MultiContent []MessagePart `json:"multi_content,omitempty"`
|
||||
|
||||
// This property is used for the "reasoning" feature supported by deepseek-reasoner
|
||||
// which is not in the official documentation.
|
||||
// the doc from deepseek:
|
||||
// - https://api-docs.deepseek.com/api/create-chat-completion#responses
|
||||
// ReasoningContent carries the reasoning text of a reasoning model. It is a
|
||||
// generic internal field, not one wire field: it is filled from
|
||||
// delta.reasoning_content / delta.reasoning on OpenAI-compatible streams
|
||||
// (DeepSeek, Qwen, vLLM, llama.cpp, ...), from Anthropic and Bedrock thinking
|
||||
// blocks, from Gemini reasoning parts, and from the OpenAI Responses API.
|
||||
// Providers that keep reasoning signed or encrypted of their own store that
|
||||
// alongside in ThinkingSignature or ThoughtSignature.
|
||||
//
|
||||
// Replaying it on later turns stops a reasoning model from re-deriving work it
|
||||
// already produced. Chat Completions replay defaults on and can be disabled
|
||||
// with provider_opts.replay_reasoning for incompatible endpoints.
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
|
||||
// ThinkingSignature is used for Anthropic's extended thinking feature
|
||||
|
||||
@@ -195,6 +195,11 @@ func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) [
|
||||
if override := c.CapsOverride(); override != nil {
|
||||
caps = modelinfo.CapsWith(override.Image, override.PDF, override.Audio, override.Video)
|
||||
}
|
||||
// Chat Completions reasoning replay defaults on;
|
||||
// provider_opts.replay_reasoning: false opts out.
|
||||
if !oaistream.ReplayReasoning(c.ModelConfig.ProviderOpts) {
|
||||
messages = oaistream.WithoutReplayedReasoning(messages)
|
||||
}
|
||||
openaiMessages := oaistream.ConvertMessagesWithCaps(ctx, messages, caps)
|
||||
return oaistream.MergeConsecutiveMessages(openaiMessages)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package dmr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/config/latest"
|
||||
"github.com/docker/docker-agent/pkg/model/provider/base"
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
// Chat Completions reasoning replay defaults on and
|
||||
// provider_opts.replay_reasoning: false turns it off.
|
||||
func TestConvertMessages_ReasoningReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts map[string]any
|
||||
wantSent bool
|
||||
}{
|
||||
{name: "on by default", wantSent: true},
|
||||
{name: "explicit opt-out", opts: map[string]any{"replay_reasoning": false}, wantSent: false},
|
||||
{name: "explicit true keeps replay enabled", opts: map[string]any{"replay_reasoning": true}, wantSent: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &Client{Config: base.Config{ModelConfig: latest.ModelConfig{
|
||||
Provider: "dmr",
|
||||
Model: "ai/qwen3",
|
||||
ProviderOpts: tt.opts,
|
||||
}}}
|
||||
|
||||
converted := c.convertMessages(t.Context(), []chat.Message{
|
||||
{Role: chat.MessageRoleUser, Content: "weather?"},
|
||||
{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
Content: "checking",
|
||||
ReasoningContent: "I should call the weather tool",
|
||||
ToolCalls: []tools.ToolCall{{ID: "c1", Function: tools.FunctionCall{Name: "weather", Arguments: `{}`}}},
|
||||
},
|
||||
})
|
||||
|
||||
raw, err := json.Marshal(converted)
|
||||
require.NoError(t, err)
|
||||
|
||||
var msgs []map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &msgs))
|
||||
|
||||
var assistant map[string]any
|
||||
for _, m := range msgs {
|
||||
if m["role"] == "assistant" {
|
||||
assistant = m
|
||||
}
|
||||
}
|
||||
require.NotNil(t, assistant, "assistant turn must survive conversion: %s", raw)
|
||||
|
||||
if tt.wantSent {
|
||||
assert.Equal(t, "I should call the weather tool", assistant["reasoning_content"])
|
||||
} else {
|
||||
assert.NotContains(t, assistant, "reasoning_content")
|
||||
}
|
||||
assert.Len(t, assistant["tool_calls"], 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func ConvertMessagesWithTransform(ctx context.Context, messages []chat.Message,
|
||||
msg := &messages[i]
|
||||
|
||||
// Skip invalid assistant messages upfront. This can happen if the model is out of tokens (max_tokens reached)
|
||||
if msg.Role == chat.MessageRoleAssistant && len(msg.ToolCalls) == 0 && len(msg.MultiContent) == 0 && strings.TrimSpace(msg.Content) == "" {
|
||||
if msg.Role == chat.MessageRoleAssistant && len(msg.ToolCalls) == 0 && len(msg.MultiContent) == 0 && strings.TrimSpace(msg.Content) == "" && strings.TrimSpace(msg.ReasoningContent) == "" {
|
||||
continue
|
||||
}
|
||||
if msg.Role != chat.MessageRoleTool {
|
||||
@@ -126,10 +126,12 @@ func ConvertMessagesWithTransform(ctx context.Context, messages []chat.Message,
|
||||
|
||||
case chat.MessageRoleAssistant:
|
||||
assistantParam := openai.ChatCompletionAssistantMessageParam{}
|
||||
hasContent := false
|
||||
|
||||
if len(msg.MultiContent) == 0 {
|
||||
if msg.Content != "" {
|
||||
assistantParam.Content.OfString = param.NewOpt(msg.Content)
|
||||
hasContent = true
|
||||
}
|
||||
} else {
|
||||
// Convert multi-content for assistant messages
|
||||
@@ -145,9 +147,33 @@ func ConvertMessagesWithTransform(ctx context.Context, messages []chat.Message,
|
||||
}
|
||||
if len(contentParts) > 0 {
|
||||
assistantParam.Content.OfArrayOfContentParts = contentParts
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Replay stored reasoning so a reasoning model can see its own previous
|
||||
// reasoning instead of re-deriving it (#4363). openai-go has no
|
||||
// reasoning_content field on the assistant param -- it is a non-standard
|
||||
// extension (openai/openai-go#558) -- so it can only go through
|
||||
// SetExtraFields.
|
||||
//
|
||||
// Scope: this replays reasoning carried by a turn that is retained in
|
||||
// session history, i.e. one that also has content or tool calls. A turn
|
||||
// whose only payload is reasoning is dropped earlier, by the history
|
||||
// filters (session.isUsageOnlyMessage and session.normalizeMessageContent
|
||||
// both key on content and tool calls), so it never reaches this
|
||||
// converter. Retaining such turns is a separate change with context-size
|
||||
// consequences.
|
||||
//
|
||||
// Whether reasoning reaches this converter at all is the caller's policy;
|
||||
// provider clients clear it when replay_reasoning is disabled. See
|
||||
// [ReplayReasoning].
|
||||
if strings.TrimSpace(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
|
||||
@@ -169,6 +195,13 @@ func ConvertMessagesWithTransform(ctx context.Context, messages []chat.Message,
|
||||
assistantParam.ToolCalls = toolCalls
|
||||
}
|
||||
|
||||
// content is only optional when tool_calls are present, and llama.cpp rejects
|
||||
// an assistant message carrying neither, so a reasoning-only turn must still
|
||||
// send content: "". See issue #4363.
|
||||
if !hasContent && len(assistantParam.ToolCalls) == 0 {
|
||||
assistantParam.Content.OfString = param.NewOpt("")
|
||||
}
|
||||
|
||||
openaiMessage.OfAssistant = &assistantParam
|
||||
|
||||
case chat.MessageRoleTool:
|
||||
|
||||
@@ -119,6 +119,16 @@ func TestConvertMessages(t *testing.T) {
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "reasoning-only assistant message not skipped",
|
||||
messages: []chat.Message{{Role: chat.MessageRoleAssistant, ReasoningContent: "thinking"}},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "whitespace-only reasoning assistant message skipped",
|
||||
messages: []chat.Message{{Role: chat.MessageRoleAssistant, ReasoningContent: " "}},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "tool message",
|
||||
messages: []chat.Message{
|
||||
@@ -386,3 +396,156 @@ func TestToolAttachmentsFollowCompleteResults(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertMessages_ReasoningContentWireFormat covers issue #4363. Assertions are on
|
||||
// the serialized request body, not on the SDK struct: a field that openai-go omits is
|
||||
// invisible to struct-level assertions, and llama.cpp rejects an assistant message that
|
||||
// carries neither content nor tool_calls.
|
||||
func TestConvertMessages_ReasoningContentWireFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := modelsdev.NewDatabaseStore(&modelsdev.Database{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg chat.Message
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
// A reasoning model that exhausts its output budget mid-reasoning leaves an
|
||||
// assistant turn with no content and no tool calls. Dropping it loses the
|
||||
// reasoning; keeping it with no content is rejected by the backend.
|
||||
name: "reasoning-only turn replays reasoning with empty content",
|
||||
msg: chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ReasoningContent: "the answer requires two steps",
|
||||
},
|
||||
want: map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "the answer requires two steps",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reasoning alongside text",
|
||||
msg: chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
Content: "Paris.",
|
||||
ReasoningContent: "France's capital, from memory",
|
||||
},
|
||||
want: map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "Paris.",
|
||||
"reasoning_content": "France's capital, from memory",
|
||||
},
|
||||
},
|
||||
{
|
||||
// content stays absent here: tool_calls satisfies the backend's requirement.
|
||||
name: "reasoning alongside tool calls",
|
||||
msg: chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ReasoningContent: "I should call the weather tool",
|
||||
ToolCalls: []tools.ToolCall{{
|
||||
ID: "call_1",
|
||||
Function: tools.FunctionCall{
|
||||
Name: "weather",
|
||||
Arguments: `{"city":"Paris"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"role": "assistant",
|
||||
"reasoning_content": "I should call the weather tool",
|
||||
"tool_calls": []any{map[string]any{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "weather",
|
||||
"arguments": `{"city":"Paris"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no stored reasoning adds no extension",
|
||||
msg: chat.Message{Role: chat.MessageRoleAssistant, Content: "Hi there!"},
|
||||
want: map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace-only reasoning is not replayed",
|
||||
msg: chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
Content: "Hi there!",
|
||||
ReasoningContent: " ",
|
||||
},
|
||||
want: map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reasoning is replayed on a multi-content turn",
|
||||
msg: chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
MultiContent: []chat.MessagePart{{Type: chat.MessagePartTypeText, Text: "answer"}},
|
||||
ReasoningContent: "reasoning",
|
||||
},
|
||||
want: map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []any{map[string]any{"type": "text", "text": "answer"}},
|
||||
"reasoning_content": "reasoning",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := ConvertMessages(t.Context(), []chat.Message{tt.msg}, modelsdev.ID{}, store, nil)
|
||||
require.Len(t, result, 1)
|
||||
require.NotNil(t, result[0].OfAssistant)
|
||||
|
||||
data, err := json.Marshal(result[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
var got map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &got))
|
||||
assert.Equal(t, tt.want, got, "serialized assistant message: %s", data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertMessages_AssistantAlwaysMarshalable asserts every assistant turn that
|
||||
// survives conversion serializes to a shape llama.cpp accepts: either content or
|
||||
// tool_calls must be present.
|
||||
func TestConvertMessages_AssistantAlwaysMarshalable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := modelsdev.NewDatabaseStore(&modelsdev.Database{})
|
||||
|
||||
messages := []chat.Message{
|
||||
{Role: chat.MessageRoleAssistant, ReasoningContent: "thinking"},
|
||||
{Role: chat.MessageRoleAssistant, Content: "text"},
|
||||
{Role: chat.MessageRoleAssistant, MultiContent: []chat.MessagePart{{Type: chat.MessagePartTypeImageURL, ImageURL: &chat.MessageImageURL{URL: "http://example.com/i.png"}}}},
|
||||
{Role: chat.MessageRoleAssistant, ToolCalls: []tools.ToolCall{{ID: "call_1", Function: tools.FunctionCall{Name: "f", Arguments: "{}"}}}},
|
||||
}
|
||||
|
||||
result := ConvertMessages(t.Context(), messages, modelsdev.ID{}, store, nil)
|
||||
require.Len(t, result, len(messages))
|
||||
|
||||
for i, converted := range result {
|
||||
data, err := json.Marshal(converted)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &got))
|
||||
_, hasContent := got["content"]
|
||||
_, hasToolCalls := got["tool_calls"]
|
||||
assert.True(t, hasContent || hasToolCalls,
|
||||
"message %d rejected by llama.cpp: assistant message must contain either 'content' or 'tool_calls', got %s", i, data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package oaistream
|
||||
|
||||
/*
|
||||
This file holds the policy for replaying stored assistant reasoning to an
|
||||
OpenAI-compatible endpoint, and the message rewrite that policy requires.
|
||||
*/
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
)
|
||||
|
||||
// ProviderOptReplayReasoning is the provider_opts key that overrides, per
|
||||
// model, whether stored assistant reasoning is replayed as a reasoning_content
|
||||
// field on Chat Completions requests. A boolean; when unset, replay defaults
|
||||
// on. It does not apply to the Responses API, where
|
||||
// provider_opts.preserve_reasoning governs the replay of encrypted reasoning
|
||||
// items instead.
|
||||
const ProviderOptReplayReasoning = "replay_reasoning"
|
||||
|
||||
// ReplayReasoning reports whether assistant reasoning stored in the session may
|
||||
// be replayed to a model's endpoint. Replay defaults on. An explicit
|
||||
// provider_opts.replay_reasoning overrides the default. A
|
||||
// value that is present but not a boolean turns replay off instead of falling
|
||||
// back to the default: the option is how an operator keeps reasoning away from
|
||||
// an endpoint, so a typo must not leave replay enabled.
|
||||
//
|
||||
// Replaying [chat.Message.ReasoningContent] lets a reasoning model see its own
|
||||
// previous reasoning after a tool call (issue #4363). Because reasoning_content
|
||||
// is a non-standard extension, the explicit false value is the compatibility
|
||||
// escape hatch for endpoints that reject or must not receive it.
|
||||
func ReplayReasoning(providerOpts map[string]any) bool {
|
||||
value, present := providerOpts[ProviderOptReplayReasoning]
|
||||
if !present {
|
||||
return true
|
||||
}
|
||||
if enabled, ok := value.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
warnInvalidReplayReasoning.Do(func() {
|
||||
slog.Warn("Rejecting provider_opts.replay_reasoning: value is not a boolean; assistant reasoning will not be replayed", "value", value)
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// warnInvalidReplayReasoning keeps the diagnostic above to once per process:
|
||||
// the option is read on every request.
|
||||
var warnInvalidReplayReasoning sync.Once
|
||||
|
||||
// WithoutReplayedReasoning returns messages with assistant reasoning cleared,
|
||||
// for a model that opted out with [ProviderOptReplayReasoning]. Only the
|
||||
// reasoning field is
|
||||
// affected: content, tool calls, and media are left alone, so an assistant turn
|
||||
// that had both reasoning and content still converts to a valid message.
|
||||
//
|
||||
// The input is never mutated -- the slice is copied only when something has to
|
||||
// change -- because the messages belong to the session, not to the provider.
|
||||
func WithoutReplayedReasoning(messages []chat.Message) []chat.Message {
|
||||
out := messages
|
||||
copied := false
|
||||
for i := range messages {
|
||||
if messages[i].Role != chat.MessageRoleAssistant || messages[i].ReasoningContent == "" {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = slices.Clone(messages)
|
||||
copied = true
|
||||
}
|
||||
out[i].ReasoningContent = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package oaistream
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
func TestReplayReasoning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts map[string]any
|
||||
want bool
|
||||
}{
|
||||
{name: "defaults on", want: true},
|
||||
{
|
||||
name: "explicit true",
|
||||
opts: map[string]any{ProviderOptReplayReasoning: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicit false",
|
||||
opts: map[string]any{ProviderOptReplayReasoning: false},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// A quoted value is a typo, and the option exists to keep reasoning
|
||||
// away from an endpoint, so it must fail closed.
|
||||
name: "non-bool value fails closed",
|
||||
opts: map[string]any{ProviderOptReplayReasoning: "yes"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated provider opts ignored",
|
||||
opts: map[string]any{"context_size": float64(8192)},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tt.want, ReplayReasoning(tt.opts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutReplayedReasoning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []chat.Message{
|
||||
{Role: chat.MessageRoleSystem, Content: "be helpful"},
|
||||
{Role: chat.MessageRoleUser, Content: "weather?"},
|
||||
{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ReasoningContent: "call the weather tool",
|
||||
ToolCalls: []tools.ToolCall{{ID: "c1", Function: tools.FunctionCall{Name: "weather", Arguments: `{}`}}},
|
||||
},
|
||||
{Role: chat.MessageRoleTool, ToolCallID: "c1", Content: "sunny"},
|
||||
{Role: chat.MessageRoleAssistant, Content: "it is sunny", ReasoningContent: "answer"},
|
||||
}
|
||||
// Snapshot so the no-mutation contract is checked against the original.
|
||||
original := slices.Clone(in)
|
||||
|
||||
got := WithoutReplayedReasoning(in)
|
||||
|
||||
assert.Equal(t, "be helpful", got[0].Content)
|
||||
assert.Equal(t, "weather?", got[1].Content)
|
||||
|
||||
// Reasoning is gone, but the turn itself stays convertible.
|
||||
assert.Empty(t, got[2].ReasoningContent)
|
||||
assert.Len(t, got[2].ToolCalls, 1)
|
||||
assert.Equal(t, "c1", got[2].ToolCalls[0].ID)
|
||||
|
||||
assert.Equal(t, "sunny", got[3].Content)
|
||||
assert.Empty(t, got[4].ReasoningContent)
|
||||
assert.Equal(t, "it is sunny", got[4].Content)
|
||||
|
||||
assert.Equal(t, original, in, "input messages must not be mutated")
|
||||
}
|
||||
|
||||
func TestWithoutReplayedReasoning_NothingToStrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []chat.Message{
|
||||
{Role: chat.MessageRoleUser, Content: "hi"},
|
||||
{Role: chat.MessageRoleAssistant, Content: "hello"},
|
||||
// Reasoning on a non-assistant role is never converted, so it must not
|
||||
// force a copy of the whole history.
|
||||
{Role: chat.MessageRoleUser, Content: "again", ReasoningContent: "stray"},
|
||||
}
|
||||
|
||||
got := WithoutReplayedReasoning(in)
|
||||
|
||||
assert.Equal(t, in, got)
|
||||
assert.Equal(t, "stray", got[2].ReasoningContent)
|
||||
}
|
||||
@@ -219,9 +219,22 @@ func (c *Client) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// replaysReasoning reports whether stored assistant reasoning is replayed as
|
||||
// reasoning_content on Chat Completions requests. Replay defaults on; an
|
||||
// explicit provider_opts.replay_reasoning value can opt out.
|
||||
func (c *Client) replaysReasoning() bool {
|
||||
return oaistream.ReplayReasoning(c.ModelConfig.ProviderOpts)
|
||||
}
|
||||
|
||||
// convertMessages converts chat.Message to openai.ChatCompletionMessageParamUnion
|
||||
// using the shared oaistream implementation.
|
||||
func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) []openai.ChatCompletionMessageParamUnion {
|
||||
// Gated here rather than inside the converter so that every path below --
|
||||
// including the prompt-cache breakpoint path, which must keep computing the
|
||||
// same body it will send -- sees the same messages.
|
||||
if !c.replaysReasoning() {
|
||||
messages = oaistream.WithoutReplayedReasoning(messages)
|
||||
}
|
||||
// Explicit prompt-cache breakpoints take priority over the consecutive-
|
||||
// message merge below. [sendsExplicitCacheBreakpoints] only admits genuine
|
||||
// OpenAI vendor endpoints — including a trusted named custom OpenAI
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/config/latest"
|
||||
"github.com/docker/docker-agent/pkg/model/provider/base"
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
// assistantWire marshals the converted request and returns the first assistant
|
||||
// message as a raw map, so the test asserts on the wire shape the endpoint
|
||||
// actually receives -- reasoning_content is a non-standard extension that only
|
||||
// survives marshalling, never the typed param.
|
||||
func assistantWire(t *testing.T, c *Client, messages []chat.Message) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(c.convertMessages(t.Context(), messages))
|
||||
require.NoError(t, err)
|
||||
|
||||
var msgs []map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &msgs))
|
||||
for _, m := range msgs {
|
||||
if m["role"] == "assistant" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("no assistant message in request: %s", raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func reasoningHistory() []chat.Message {
|
||||
return []chat.Message{
|
||||
{Role: chat.MessageRoleUser, Content: "weather?"},
|
||||
{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
Content: "checking",
|
||||
ReasoningContent: "I should call the weather tool",
|
||||
ToolCalls: []tools.ToolCall{{ID: "c1", Function: tools.FunctionCall{Name: "weather", Arguments: `{}`}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning replay is a Chat Completions capability, not an endpoint
|
||||
// classification. Every destination defaults on and may opt out explicitly.
|
||||
func TestConvertMessages_ReasoningReplayDefaultsOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
baseURL string
|
||||
opts map[string]any
|
||||
}{
|
||||
{name: "custom provider", provider: "local_llm", baseURL: "http://127.0.0.1:8080/v1", opts: map[string]any{"api_type": "openai_chatcompletions"}},
|
||||
{name: "first-party OpenAI", provider: "openai"},
|
||||
{name: "Azure", provider: "azure", baseURL: "https://team.openai.azure.com/openai/deployments/gpt-5.6"},
|
||||
{name: "hosted alias", provider: "xai"},
|
||||
{name: "DeepSeek", provider: "deepseek"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &Client{Config: base.Config{ModelConfig: latest.ModelConfig{
|
||||
Provider: tt.provider, Model: "qwen3", BaseURL: tt.baseURL, ProviderOpts: tt.opts,
|
||||
}}}
|
||||
msg := assistantWire(t, c, reasoningHistory())
|
||||
assert.Equal(t, "I should call the weather tool", msg["reasoning_content"])
|
||||
assert.Len(t, msg["tool_calls"], 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMessages_ReasoningReplayOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
baseURL string
|
||||
opts map[string]any
|
||||
wantSent bool
|
||||
}{
|
||||
{
|
||||
name: "explicit true keeps replay enabled on OpenAI",
|
||||
provider: "openai",
|
||||
opts: map[string]any{"replay_reasoning": true},
|
||||
wantSent: true,
|
||||
},
|
||||
{
|
||||
name: "explicit true keeps replay enabled on a hosted alias",
|
||||
provider: "xai",
|
||||
opts: map[string]any{"replay_reasoning": true},
|
||||
wantSent: true,
|
||||
},
|
||||
{
|
||||
name: "custom provider opts out",
|
||||
provider: "openai",
|
||||
baseURL: "http://127.0.0.1:8080/v1",
|
||||
opts: map[string]any{"api_type": "openai_chatcompletions", "replay_reasoning": false},
|
||||
wantSent: false,
|
||||
},
|
||||
{
|
||||
name: "explicit true keeps replay enabled on ChatGPT",
|
||||
provider: "chatgpt",
|
||||
opts: map[string]any{"replay_reasoning": true},
|
||||
wantSent: true,
|
||||
},
|
||||
{
|
||||
// A value that is present but not a boolean must not inherit the
|
||||
// on default: the option is how an operator keeps reasoning away
|
||||
// from an endpoint, so a quoted "false" has to mean off.
|
||||
name: "non-boolean value fails closed on a custom provider",
|
||||
provider: "local_llm",
|
||||
baseURL: "http://127.0.0.1:8080/v1",
|
||||
opts: map[string]any{"api_type": "openai_chatcompletions", "replay_reasoning": "false"},
|
||||
},
|
||||
{
|
||||
name: "non-boolean value fails closed on a hosted alias",
|
||||
provider: "xai",
|
||||
opts: map[string]any{"replay_reasoning": "true"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &Client{Config: base.Config{ModelConfig: latest.ModelConfig{
|
||||
Provider: tt.provider,
|
||||
Model: "qwen3",
|
||||
BaseURL: tt.baseURL,
|
||||
ProviderOpts: tt.opts,
|
||||
}}}
|
||||
|
||||
msg := assistantWire(t, c, reasoningHistory())
|
||||
if tt.wantSent {
|
||||
assert.Equal(t, "I should call the weather tool", msg["reasoning_content"])
|
||||
} else {
|
||||
assert.NotContains(t, msg, "reasoning_content")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Replay also applies before the prompt-cache breakpoint path is chosen, so the
|
||||
// body the cache key is computed from is the body that gets sent.
|
||||
func TestConvertMessages_ReasoningReplayAppliesToBreakpointPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &Client{Config: base.Config{ModelConfig: latest.ModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.6",
|
||||
}}}
|
||||
require.True(t, sendsExplicitCacheBreakpoints(&c.ModelConfig, false),
|
||||
"test is only meaningful on the explicit-breakpoint path")
|
||||
|
||||
msg := assistantWire(t, c, reasoningHistory())
|
||||
assert.Equal(t, "I should call the weather tool", msg["reasoning_content"])
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/agent"
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/model/provider/oaistream"
|
||||
"github.com/docker/docker-agent/pkg/modelsdev"
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
// assistantFromWire marshals a converted request and returns its first
|
||||
// assistant message as a raw map: reasoning_content is a non-standard
|
||||
// extension that only exists on the wire.
|
||||
func assistantFromWire(t *testing.T, msgs any) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(msgs)
|
||||
require.NoError(t, err)
|
||||
|
||||
var out []map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &out))
|
||||
for _, m := range out {
|
||||
if m["role"] == "assistant" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("no assistant message in request: %s", raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestGetMessages_ReasoningReplayedToRequest is the end-to-end regression for
|
||||
// issue #4363: reasoning captured on an assistant turn that also carries a tool
|
||||
// call has to survive session history and then reach the next Chat Completions
|
||||
// request, so the model does not re-derive reasoning it already produced.
|
||||
//
|
||||
// Both halves matter. The history filters (isUsageOnlyMessage,
|
||||
// normalizeMessageContent) key on content and tool calls, so this turn is
|
||||
// retained; the converter is what used to drop the reasoning on the floor.
|
||||
func TestGetMessages_ReasoningReplayedToRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := New()
|
||||
s.AddMessage(UserMessage("what is the weather?"))
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ReasoningContent: "I should call the weather tool",
|
||||
ToolCalls: []tools.ToolCall{{ID: "call_1", Function: tools.FunctionCall{Name: "weather", Arguments: `{"city":"Paris"}`}}},
|
||||
Usage: &chat.Usage{InputTokens: 10, OutputTokens: 5},
|
||||
}))
|
||||
s.AddMessage(&Message{Message: chat.Message{Role: chat.MessageRoleTool, ToolCallID: "call_1", Content: "sunny, 24C"}})
|
||||
|
||||
history := s.GetMessages(agent.New("test-agent", "test instruction"))
|
||||
|
||||
var stored *chat.Message
|
||||
for i := range history {
|
||||
if history[i].Role == chat.MessageRoleAssistant {
|
||||
stored = &history[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, stored, "a reasoning turn that carries a tool call must stay in history")
|
||||
assert.Equal(t, "I should call the weather tool", stored.ReasoningContent)
|
||||
|
||||
converted := oaistream.ConvertMessages(t.Context(), history, modelsdev.NewID("custom", "qwen3"), nil, nil)
|
||||
assistant := assistantFromWire(t, converted)
|
||||
|
||||
assert.Equal(t, "I should call the weather tool", assistant["reasoning_content"])
|
||||
require.Len(t, assistant["tool_calls"], 1)
|
||||
calls, ok := assistant["tool_calls"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "call_1", calls[0].(map[string]any)["id"])
|
||||
}
|
||||
|
||||
// TestGetMessages_ReasoningOnlyTurnNotReplayed pins the boundary of what issue
|
||||
// #4363 asks for: a turn whose only payload is reasoning is dropped by the
|
||||
// history filters before any converter runs, so replaying it is out of scope
|
||||
// here. Retaining such turns is a separate change with context-size
|
||||
// consequences.
|
||||
func TestGetMessages_ReasoningOnlyTurnNotReplayed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := New()
|
||||
s.AddMessage(UserMessage("think about it"))
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ReasoningContent: "pondering",
|
||||
Usage: &chat.Usage{InputTokens: 10, OutputTokens: 5},
|
||||
}))
|
||||
|
||||
history := s.GetMessages(agent.New("test-agent", "test instruction"))
|
||||
for _, m := range history {
|
||||
assert.NotEqual(t, "pondering", m.ReasoningContent,
|
||||
"reasoning-only turns are not retained by session history")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user