Skip to content

Commit eafb4cb

Browse files
authored
Merge pull request #238 from editor-code-assistant/fix-deepseek-reasoner-flow
Fix deepseek reasoner flow
2 parents 4815248 + 1369e26 commit eafb4cb

File tree

4 files changed

+81
-31
lines changed

4 files changed

+81
-31
lines changed

integration-test/integration/chat/github_copilot_test.clj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,7 @@
168168
(match-content chat-id "system" {:type "progress" :state "finished"})
169169
(is (match?
170170
{:input [{:role "user" :content [{:type "input_text" :text "hello!"}]}
171-
{:role "assistant" :content [{:type "output_text" :text "<think>I should say hello</think>"}]}
172-
{:role "assistant" :content [{:type "output_text" :text "hello there!"}]}
171+
{:role "assistant" :content [{:type "output_text" :text "<think>I should say hello</think>\nhello there!"}]}
173172
{:role "user" :content [{:type "input_text" :text "how are you?"}]}]
174173
:instructions (m/pred string?)}
175174
(llm.mocks/get-req-body :reasoning-1)))))))

integration-test/integration/chat/google_test.clj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,7 @@
167167
(match-content chat-id "system" {:type "progress" :state "finished"})
168168
(is (match?
169169
{:input [{:role "user" :content [{:type "input_text" :text "hello!"}]}
170-
{:role "assistant" :content [{:type "output_text" :text "<thought>I should say hello</thought>"}]}
171-
{:role "assistant" :content [{:type "output_text" :text "hello there!"}]}
170+
{:role "assistant" :content [{:type "output_text" :text "<thought>I should say hello</thought>\nhello there!"}]}
172171
{:role "user" :content [{:type "input_text" :text "how are you?"}]}]
173172
:instructions (m/pred string?)}
174173
(llm.mocks/get-req-body :reasoning-1)))))))

src/eca/llm_providers/openai_chat.clj

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@
158158
"reason" (if (:delta-reasoning? content)
159159
;; DeepSeek-style: reasoning_content must be passed back to API
160160
{:role "assistant"
161+
:content ""
161162
:reasoning_content (:text content)}
162163
;; Fallback: wrap in thinking tags for models that use text-based reasoning
163164
{:role "assistant"
@@ -169,39 +170,66 @@
169170
:content (extract-content content supports-image?)}
170171
nil))
171172

172-
(defn ^:private merge-reasoning-with-response
173-
"Merge reasoning_content from prev with tool_calls/content from msg.
174-
Required for DeepSeek which needs reasoning_content on same message as the response."
173+
(defn ^:private merge-assistant-messages
174+
"Merge two assistant messages into one.
175+
Concatenates contents and tool_calls, and preserves reasoning_content."
175176
[prev msg]
176-
(cond-> {:role "assistant"
177-
:reasoning_content (:reasoning_content prev)}
178-
;; content: from msg (DeepSeek's final answer)
179-
(:content msg)
180-
(assoc :content (:content msg))
181-
;; tool_calls: concatenate (prev may have some from earlier merge, msg adds more)
182-
(or (:tool_calls prev) (:tool_calls msg))
183-
(assoc :tool_calls (into (or (:tool_calls prev) []) (:tool_calls msg)))))
177+
(let [prev-content (:content prev)
178+
msg-content (:content msg)
179+
blank-string? (fn [s] (and (string? s) (string/blank? s)))
180+
combined-content (cond
181+
(nil? prev-content)
182+
msg-content
183+
184+
(nil? msg-content)
185+
prev-content
186+
187+
(blank-string? prev-content)
188+
msg-content
189+
190+
(blank-string? msg-content)
191+
prev-content
192+
193+
(and (string? prev-content) (string? msg-content))
194+
(if (or (string/ends-with? prev-content "\n")
195+
(string/starts-with? msg-content "\n"))
196+
(str prev-content msg-content)
197+
(str prev-content "\n" msg-content))
198+
199+
(and (sequential? prev-content) (sequential? msg-content))
200+
(vec (concat prev-content msg-content))
201+
202+
:else
203+
(let [as-seq (fn [c] (if (sequential? c) c [{:type "text" :text (str c)}]))]
204+
(vec (concat (as-seq prev-content) (as-seq msg-content)))))]
205+
(cond-> {:role "assistant"
206+
:content (or combined-content "")}
207+
(or (:reasoning_content prev) (:reasoning_content msg))
208+
(assoc :reasoning_content (str (:reasoning_content prev) (:reasoning_content msg)))
209+
210+
(or (:tool_calls prev) (:tool_calls msg))
211+
(assoc :tool_calls (vec (concat (:tool_calls prev)
212+
(:tool_calls msg)))))))
184213

185214
(defn ^:private merge-adjacent-assistants
186-
"Merge adjacent assistant messages only when prev has reasoning_content.
187-
This is required for DeepSeek which needs reasoning_content on same message as tool_calls/content."
215+
"Merge all adjacent assistant messages.
216+
This is required by many OpenAI-compatible APIs (including DeepSeek)
217+
which do not allow multiple consecutive assistant messages."
188218
[messages]
189219
(reduce
190220
(fn [acc msg]
191221
(let [prev (peek acc)]
192222
(if (and (= "assistant" (:role prev))
193-
(= "assistant" (:role msg))
194-
(:reasoning_content prev))
195-
(conj (pop acc) (merge-reasoning-with-response prev msg))
223+
(= "assistant" (:role msg)))
224+
(conj (pop acc) (merge-assistant-messages prev msg))
196225
(conj acc msg))))
197226
[]
198227
messages))
199228

200229
(defn ^:private valid-message?
201230
"Check if a message should be included in the final output."
202-
[{:keys [role content tool_calls reasoning_content] :as msg}]
203-
(and msg
204-
(or (= role "tool") ; Never remove tool messages
231+
[{:keys [role content tool_calls reasoning_content] :as _msg}]
232+
(and (or (= role "tool") ; Never remove tool messages
205233
(seq tool_calls) ; Keep messages with tool calls
206234
(seq reasoning_content) ; Keep messages with reasoning_content (DeepSeek)
207235
(and (string? content)

test/eca/llm_providers/openai_chat_test.clj

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
(def thinking-end-tag "</think>")
99

1010
(deftest normalize-messages-test
11-
(testing "With tool_call history - tool calls stay separate from preceding assistant (no reasoning_content)"
11+
(testing "With tool_call history - assistant text and tool calls are merged"
1212
(is (match?
1313
[{:role "user" :content [{:type "text" :text "List the files"}]}
14-
{:role "assistant" :content [{:type "text" :text "I'll list the files for you"}]}
1514
{:role "assistant"
15+
:content [{:type "text" :text "I'll list the files for you"}]
1616
:tool_calls [{:id "call-1"
1717
:type "function"
1818
:function {:name "eca__list_files"
@@ -36,11 +36,11 @@
3636
thinking-start-tag
3737
thinking-end-tag))))
3838

39-
(testing "Reason messages without reasoning-content use think tags, stay separate from following assistant"
39+
(testing "Reason messages without reasoning-content use think tags, merged with following assistant"
4040
(is (match?
4141
[{:role "user" :content [{:type "text" :text "Hello"}]}
42-
{:role "assistant" :content [{:type "text" :text "<think>Thinking...</think>"}]}
43-
{:role "assistant" :content [{:type "text" :text "Hi"}]}]
42+
{:role "assistant" :content [{:type "text" :text "<think>Thinking...</think>"}
43+
{:type "text" :text "Hi"}]}]
4444
(#'llm-providers.openai-chat/normalize-messages
4545
[{:role "user" :content "Hello"}
4646
{:role "reason" :content {:text "Thinking..."}}
@@ -149,18 +149,42 @@
149149
thinking-end-tag)))))
150150

151151
(deftest merge-adjacent-assistants-test
152-
(testing "Without reasoning_content, adjacent assistants are NOT merged"
152+
(testing "All adjacent assistant messages are merged (even without reasoning_content)"
153153
(is (match?
154154
[{:role "user" :content "What's the weather?"}
155-
{:role "assistant" :tool_calls [{:id "call-1" :function {:name "get_weather"}}]}
156-
{:role "assistant" :tool_calls [{:id "call-2" :function {:name "get_location"}}]}
155+
{:role "assistant"
156+
:content ""
157+
:tool_calls [{:id "call-1" :function {:name "get_weather"}}
158+
{:id "call-2" :function {:name "get_location"}}]}
157159
{:role "user" :content "Thanks"}]
158160
(#'llm-providers.openai-chat/merge-adjacent-assistants
159161
[{:role "user" :content "What's the weather?"}
160162
{:role "assistant" :tool_calls [{:id "call-1" :function {:name "get_weather"}}]}
161163
{:role "assistant" :tool_calls [{:id "call-2" :function {:name "get_location"}}]}
162164
{:role "user" :content "Thanks"}]))))
163165

166+
(testing "Blank string content does not introduce leading newlines"
167+
(is (match?
168+
[{:role "user" :content "Hi"}
169+
{:role "assistant" :content "Hello"}
170+
{:role "user" :content "Thanks"}]
171+
(#'llm-providers.openai-chat/merge-adjacent-assistants
172+
[{:role "user" :content "Hi"}
173+
{:role "assistant" :content ""}
174+
{:role "assistant" :content "Hello"}
175+
{:role "user" :content "Thanks"}]))))
176+
177+
(testing "DeepSeek: empty assistant content from reasoning does not add newlines to final content"
178+
(is (match?
179+
[{:role "user" :content "Q"}
180+
{:role "assistant"
181+
:reasoning_content "Thinking..."
182+
:content "A"}]
183+
(#'llm-providers.openai-chat/merge-adjacent-assistants
184+
[{:role "user" :content "Q"}
185+
{:role "assistant" :reasoning_content "Thinking..." :content ""}
186+
{:role "assistant" :content "A"}]))))
187+
164188
(testing "DeepSeek: Reasoning content merges with tool calls"
165189
(is (match?
166190
[{:role "user" :content "Calc"}

0 commit comments

Comments
 (0)