Skip to content

Commit dea9cd8

Browse files
committed
fix(oaistream): replay reasoning content with opt-out
Fixes #4363 Signed-off-by: Jay <12496124+MysticalMount@users.noreply.github.com>
1 parent f412343 commit dea9cd8

14 files changed

Lines changed: 847 additions & 9 deletions

File tree

‎agent-schema.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1756,7 +1756,7 @@
17561756
"additionalProperties": true
17571757
}
17581758
},
1759-
"description": "Provider-specific options. Anthropic: thinking_prefix_mismatch (error or drop_block; defaults to drop_block on Fable/Mythos 5.1), 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 requests refused by safety classifiers, e.g. on Claude Fable 5, are retried with the listed models in a single round trip). 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-string values are ignored; cost estimates are not tier-adjusted, set the model's cost override).",
1759+
"description": "Provider-specific options. Anthropic: thinking_prefix_mismatch (error or drop_block; defaults to drop_block on Fable/Mythos 5.1), 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 requests refused by safety classifiers, e.g. on Claude Fable 5, are retried with the listed models in a single round trip). 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 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-string values are ignored; cost estimates are not tier-adjusted, set the model's cost override).",
17601760
"additionalProperties": true
17611761
},
17621762
"track_usage": {

‎docs/providers/custom/index.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,46 @@ agents:
159159
>
160160
> 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.
161161

162+
### Replaying reasoning on later turns
163+
164+
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).
165+
166+
Chat Completions reasoning replay defaults on. Because `reasoning_content` is a
167+
non-standard extension, set `provider_opts.replay_reasoning: false` for a model
168+
or provider whose endpoint rejects or must not receive it. An invalid,
169+
non-boolean value also fails closed and disables replay.
170+
171+
Set the opt-out on a model, or once on a provider and let its models inherit it:
172+
173+
```yaml
174+
providers:
175+
local_llm:
176+
base_url: http://localhost:8000/v1
177+
provider_opts:
178+
replay_reasoning: false # opt out every local_llm model
179+
180+
models:
181+
qwen_default:
182+
provider: local_llm
183+
model: qwen3
184+
provider_opts:
185+
replay_reasoning: true # optional: replay is already the default
186+
187+
agents:
188+
root:
189+
model: qwen_default
190+
```
191+
192+
This option is specific to the Chat Completions API and its `reasoning_content`
193+
field. It is not the same as `preserve_reasoning`, which replays the encrypted
194+
reasoning items of first-party OpenAI Responses sessions; neither option
195+
applies to the other API. See [Reasoning state preservation](../openai/index.md#reasoning-state-preservation).
196+
197+
The field is worth a look if you switch models mid-session or use fallbacks:
198+
Docker Agent stores reasoning from every provider in one place, so a later Chat
199+
Completions model may otherwise receive reasoning captured from another vendor.
200+
Set `replay_reasoning: false` on that destination when this is undesirable.
201+
162202
### API Router (Requesty, LiteLLM)
163203

164204
```yaml

‎docs/providers/openai/index.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@ generated values. This option does not apply to the Responses API.
218218

219219
## Reasoning State Preservation
220220

221-
Reasoning replay is opt-in, so existing configurations keep their request and
222-
session-storage behavior. Enable it for first-party OpenAI Responses sessions,
223-
including the `chatgpt` provider:
221+
Responses reasoning replay is opt-in, so existing configurations keep their
222+
request and session-storage behavior. Enable it for first-party OpenAI
223+
Responses sessions, including the `chatgpt` provider:
224224

225225
```yaml
226226
provider_opts:
@@ -247,3 +247,9 @@ WebSocket requests replay the full history without also chaining it through `pre
247247

248248
See [OpenAI's reasoning guide](https://developers.openai.com/api/docs/guides/reasoning)
249249
and [cache diagnostics guide](https://developers.openai.com/api/docs/guides/prompt-caching/diagnostics).
250+
251+
`preserve_reasoning` is unrelated to `provider_opts.replay_reasoning`, which
252+
replays plain reasoning text as a `reasoning_content` field on Chat
253+
Completions requests to OpenAI-compatible servers. It does not apply to the
254+
Responses API, and `preserve_reasoning` does not apply to Chat Completions.
255+
See [Replaying reasoning on later turns](../custom/index.md#replaying-reasoning-on-later-turns).

‎examples/reasoning_replay.yaml‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Example: Replaying reasoning to an OpenAI-compatible model
2+
#
3+
# Docker Agent captures reasoning from reasoning models (delta.reasoning_content
4+
# or delta.reasoning on OpenAI-compatible streams) and replays it on later turns,
5+
# so the model sees its own earlier reasoning instead of re-deriving it after
6+
# every tool call. The replay is sent as a reasoning_content field on the
7+
# assistant message.
8+
#
9+
# Replay defaults on for Chat Completions. Set
10+
# provider_opts.replay_reasoning: false when an endpoint rejects or must not
11+
# receive the non-standard field. Replayed reasoning counts against the context
12+
# window, so a model that reasons at length is also the case where opting out
13+
# saves the most tokens.
14+
#
15+
# This option is Chat Completions only. The Responses API has a separate
16+
# preserve_reasoning option for its encrypted reasoning items; neither option
17+
# applies to the other API.
18+
19+
providers:
20+
# A local server fronting an open-weight reasoning model. Replay is already
21+
# on for this destination; the explicit setting is shown for contrast.
22+
local_vllm:
23+
provider: openai
24+
api_type: openai_chatcompletions
25+
base_url: http://localhost:8000/v1
26+
27+
models:
28+
qwen_replay:
29+
provider: local_vllm
30+
model: qwen3-30b-a3b
31+
provider_opts:
32+
replay_reasoning: true
33+
34+
# Same server, but this model should never receive stored reasoning.
35+
qwen_no_replay:
36+
provider: local_vllm
37+
model: qwen3-30b-a3b
38+
provider_opts:
39+
replay_reasoning: false
40+
41+
agents:
42+
root:
43+
model: qwen_replay
44+
description: Main assistant, replaying its own reasoning
45+
instruction: |
46+
You are a helpful AI assistant. Be concise and helpful in your responses.
47+
48+
analyst:
49+
model: qwen_no_replay
50+
description: Sub-agent that starts each turn from scratch
51+
instruction: |
52+
You are a specialized assistant for specific tasks.

‎pkg/chat/chat.go‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,17 @@ type Message struct {
5656
Content string `json:"content"`
5757
MultiContent []MessagePart `json:"multi_content,omitempty"`
5858

59-
// This property is used for the "reasoning" feature supported by deepseek-reasoner
60-
// which is not in the official documentation.
61-
// the doc from deepseek:
62-
// - https://api-docs.deepseek.com/api/create-chat-completion#responses
59+
// ReasoningContent carries the reasoning text of a reasoning model. It is a
60+
// generic internal field, not one wire field: it is filled from
61+
// delta.reasoning_content / delta.reasoning on OpenAI-compatible streams
62+
// (DeepSeek, Qwen, vLLM, llama.cpp, ...), from Anthropic and Bedrock thinking
63+
// blocks, from Gemini reasoning parts, and from the OpenAI Responses API.
64+
// Providers that keep reasoning signed or encrypted of their own store that
65+
// alongside in ThinkingSignature or ThoughtSignature.
66+
//
67+
// Replaying it on later turns stops a reasoning model from re-deriving work it
68+
// already produced. Chat Completions replay defaults on and can be disabled
69+
// with provider_opts.replay_reasoning for incompatible endpoints.
6370
ReasoningContent string `json:"reasoning_content,omitempty"`
6471

6572
// ThinkingSignature is used for Anthropic's extended thinking feature

‎pkg/model/provider/dmr/client.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) [
195195
if override := c.CapsOverride(); override != nil {
196196
caps = modelinfo.CapsWith(override.Image, override.PDF, override.Audio, override.Video)
197197
}
198+
// Chat Completions reasoning replay defaults on;
199+
// provider_opts.replay_reasoning: false opts out.
200+
if !oaistream.ReplayReasoning(c.ModelConfig.ProviderOpts) {
201+
messages = oaistream.WithoutReplayedReasoning(messages)
202+
}
198203
openaiMessages := oaistream.ConvertMessagesWithCaps(ctx, messages, caps)
199204
return oaistream.MergeConsecutiveMessages(openaiMessages)
200205
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package dmr
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/docker-agent/pkg/chat"
11+
"github.com/docker/docker-agent/pkg/config/latest"
12+
"github.com/docker/docker-agent/pkg/model/provider/base"
13+
"github.com/docker/docker-agent/pkg/tools"
14+
)
15+
16+
// Chat Completions reasoning replay defaults on and
17+
// provider_opts.replay_reasoning: false turns it off.
18+
func TestConvertMessages_ReasoningReplay(t *testing.T) {
19+
t.Parallel()
20+
21+
tests := []struct {
22+
name string
23+
opts map[string]any
24+
wantSent bool
25+
}{
26+
{name: "on by default", wantSent: true},
27+
{name: "explicit opt-out", opts: map[string]any{"replay_reasoning": false}, wantSent: false},
28+
{name: "explicit true keeps replay enabled", opts: map[string]any{"replay_reasoning": true}, wantSent: true},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
t.Parallel()
34+
35+
c := &Client{Config: base.Config{ModelConfig: latest.ModelConfig{
36+
Provider: "dmr",
37+
Model: "ai/qwen3",
38+
ProviderOpts: tt.opts,
39+
}}}
40+
41+
converted := c.convertMessages(t.Context(), []chat.Message{
42+
{Role: chat.MessageRoleUser, Content: "weather?"},
43+
{
44+
Role: chat.MessageRoleAssistant,
45+
Content: "checking",
46+
ReasoningContent: "I should call the weather tool",
47+
ToolCalls: []tools.ToolCall{{ID: "c1", Function: tools.FunctionCall{Name: "weather", Arguments: `{}`}}},
48+
},
49+
})
50+
51+
raw, err := json.Marshal(converted)
52+
require.NoError(t, err)
53+
54+
var msgs []map[string]any
55+
require.NoError(t, json.Unmarshal(raw, &msgs))
56+
57+
var assistant map[string]any
58+
for _, m := range msgs {
59+
if m["role"] == "assistant" {
60+
assistant = m
61+
}
62+
}
63+
require.NotNil(t, assistant, "assistant turn must survive conversion: %s", raw)
64+
65+
if tt.wantSent {
66+
assert.Equal(t, "I should call the weather tool", assistant["reasoning_content"])
67+
} else {
68+
assert.NotContains(t, assistant, "reasoning_content")
69+
}
70+
assert.Len(t, assistant["tool_calls"], 1)
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)