fix: round-trip reasoning_content for DeepSeek thinking models

DeepSeek requires reasoning_content to be echoed back on assistant
messages. Store reasoning from stream events on Message and serialize
it as reasoning_content in the OpenAI wire format.
This commit is contained in:
ollie 2026-05-28 19:04:03 +02:00
parent 10cbded305
commit bc7a536ced
3 changed files with 21 additions and 12 deletions

View File

@ -101,11 +101,13 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// Stream the assistant's response, retrying on rate limits, transient // Stream the assistant's response, retrying on rate limits, transient
// backend errors (5xx, network), and mid-stream drops. // backend errors (5xx, network), and mid-stream drops.
var content strings.Builder var content strings.Builder
var reasoning strings.Builder
var toolCalls []backend.ToolCall var toolCalls []backend.ToolCall
var stopReason string var stopReason string
for attempt := range maxTransientRetries + 1 { for attempt := range maxTransientRetries + 1 {
content.Reset() content.Reset()
reasoning.Reset()
toolCalls = nil toolCalls = nil
ch, err := cfg.Backend.ChatStream(ctx, history, cfg.Tools, cfg.GenerationParams) ch, err := cfg.Backend.ChatStream(ctx, history, cfg.Tools, cfg.GenerationParams)
@ -144,6 +146,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
emit(cfg, Event{Role: "reasoning", Content: "<think>\n"}) emit(cfg, Event{Role: "reasoning", Content: "<think>\n"})
hadReasoning = true hadReasoning = true
} }
reasoning.WriteString(ev.Reasoning)
emit(cfg, Event{Role: "reasoning", Content: ev.Reasoning}) emit(cfg, Event{Role: "reasoning", Content: ev.Reasoning})
} }
if ev.Content != "" { if ev.Content != "" {
@ -194,7 +197,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
if hadReasoning { if hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"}) emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
} }
msg := backend.Message{Role: "assistant", Content: content.String(), ToolCalls: toolCalls} msg := backend.Message{Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
var results []toolResult var results []toolResult
for _, tc := range toolCalls { for _, tc := range toolCalls {
results = append(results, toolResult{ results = append(results, toolResult{
@ -241,7 +244,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
// Execute tool calls, running consecutive parallel-read-safe tools concurrently. // Execute tool calls, running consecutive parallel-read-safe tools concurrently.
msg := backend.Message{Role: "assistant", Content: content.String(), ToolCalls: toolCalls} msg := backend.Message{Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
results := make([]toolResult, 0, len(toolCalls)) results := make([]toolResult, 0, len(toolCalls))
interrupted := false interrupted := false

View File

@ -32,6 +32,7 @@ type ImageSource struct {
type Message struct { type Message struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool" Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"` Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"` // thinking/reasoning content (DeepSeek, OpenAI o-series)
ContentBlocks []ContentBlock `json:"content_blocks,omitempty"` // when set, overrides Content ContentBlocks []ContentBlock `json:"content_blocks,omitempty"` // when set, overrides Content
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant when calling tools ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant when calling tools
ToolCallID string `json:"tool_call_id,omitempty"` // set on role=tool replies (required by OpenAI) ToolCallID string `json:"tool_call_id,omitempty"` // set on role=tool replies (required by OpenAI)

View File

@ -132,11 +132,12 @@ type openAIContentBlock struct {
} }
type openAIMessage struct { type openAIMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content *string `json:"content"` Content *string `json:"content"`
ContentBlocks []openAIContentBlock `json:"-"` // non-nil overrides Content during marshaling ReasoningContent *string `json:"reasoning_content,omitempty"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` ContentBlocks []openAIContentBlock `json:"-"` // non-nil overrides Content during marshaling
ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
} }
// MarshalJSON encodes content as an array of blocks when ContentBlocks is set // MarshalJSON encodes content as an array of blocks when ContentBlocks is set
@ -145,11 +146,12 @@ type openAIMessage struct {
func (m openAIMessage) MarshalJSON() ([]byte, error) { func (m openAIMessage) MarshalJSON() ([]byte, error) {
if len(m.ContentBlocks) > 0 { if len(m.ContentBlocks) > 0 {
return json.Marshal(struct { return json.Marshal(struct {
Role string `json:"role"` Role string `json:"role"`
Content []openAIContentBlock `json:"content"` ReasoningContent *string `json:"reasoning_content,omitempty"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` Content []openAIContentBlock `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
}{m.Role, m.ContentBlocks, m.ToolCalls, m.ToolCallID}) ToolCallID string `json:"tool_call_id,omitempty"`
}{m.Role, m.ReasoningContent, m.ContentBlocks, m.ToolCalls, m.ToolCallID})
} }
type plain openAIMessage type plain openAIMessage
return json.Marshal(plain(m)) return json.Marshal(plain(m))
@ -243,6 +245,9 @@ func encodeOpenAIMessages(messages []Message) []openAIMessage {
Role: m.Role, Role: m.Role,
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
} }
if m.Reasoning != "" {
wm.ReasoningContent = &m.Reasoning
}
for _, tc := range m.ToolCalls { for _, tc := range m.ToolCalls {
wm.ToolCalls = append(wm.ToolCalls, openAIToolCall{ wm.ToolCalls = append(wm.ToolCalls, openAIToolCall{
ID: tc.ID, ID: tc.ID,