@swarmclawai/swarmclaw 1.8.1 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -399,6 +399,14 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.8.11 Highlights
403
+
404
+ DeepSeek tool-use hotfix for issue [#67](https://github.com/swarmclawai/swarmclaw/issues/67).
405
+
406
+ - **DeepSeek reasoning replay.** Stored assistant turns now keep provider-native `reasoning_content` separately from visible text and send it back to DeepSeek on follow-up tool-use turns.
407
+ - **Streaming parity.** Direct OpenAI-compatible streams and LangGraph agent streams both preserve `reasoning_content` while continuing to show reasoning through SwarmClaw's existing thinking surface.
408
+ - **Regression coverage.** Added tests for DeepSeek history replay and the LangChain bridge selection path.
409
+
402
410
  ### v1.8.1 Highlights
403
411
 
404
412
  Operator evidence release: a focused follow-up that makes release and mission review easier to scan.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.8.1",
3
+ "version": "1.8.11",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -0,0 +1,305 @@
1
+ import {
2
+ ChatOpenAI,
3
+ ChatOpenAICompletions,
4
+ convertMessagesToCompletionsMessageParams,
5
+ type ChatOpenAIFields,
6
+ } from '@langchain/openai'
7
+ import {
8
+ AIMessage,
9
+ AIMessageChunk,
10
+ isAIMessage,
11
+ type BaseMessage,
12
+ type BaseMessageChunk,
13
+ type UsageMetadata,
14
+ } from '@langchain/core/messages'
15
+ import { ChatGenerationChunk, type ChatGeneration, type ChatResult } from '@langchain/core/outputs'
16
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'
17
+ import type { OpenAI as OpenAIClient } from 'openai'
18
+
19
+ export const REASONING_CONTENT_MD_KEY = 'reasoningContentDelta'
20
+
21
+ type ChatCompletionMessageParam = OpenAIClient.Chat.Completions.ChatCompletionMessageParam
22
+
23
+ export function extractReasoningContentDelta(delta: Record<string, unknown> | null | undefined): string {
24
+ if (!delta) return ''
25
+ if (typeof delta.reasoning_content === 'string') return delta.reasoning_content
26
+ if (typeof delta.reasoning === 'string') return delta.reasoning
27
+ return ''
28
+ }
29
+
30
+ export function getReasoningContentFromLangChainMessage(message: Pick<BaseMessage, 'additional_kwargs'>): string {
31
+ const additionalKwargs = message.additional_kwargs || {}
32
+ const reasoningContent = additionalKwargs.reasoning_content
33
+ if (typeof reasoningContent === 'string' && reasoningContent.length > 0) return reasoningContent
34
+ const reasoning = additionalKwargs.reasoning
35
+ return typeof reasoning === 'string' ? reasoning : ''
36
+ }
37
+
38
+ export function attachReasoningContentToCompletionsMessages<T extends ChatCompletionMessageParam>(
39
+ messagesMapped: T[],
40
+ sourceMessages: BaseMessage[],
41
+ ): T[] {
42
+ return messagesMapped.map((message, index) => {
43
+ const reasoningContent = getReasoningContentFromLangChainMessage(sourceMessages[index])
44
+ if (message.role !== 'assistant' || !reasoningContent) return message
45
+ return {
46
+ ...message,
47
+ reasoning_content: reasoningContent,
48
+ } as T
49
+ })
50
+ }
51
+
52
+ export function mergeReasoningContentIntoMessage<T extends { additional_kwargs: Record<string, unknown> }>(
53
+ message: T,
54
+ delta: Record<string, unknown> | null | undefined,
55
+ ): T {
56
+ const reasoningContent = extractReasoningContentDelta(delta)
57
+ if (!reasoningContent) return message
58
+ const existing = message.additional_kwargs.reasoning_content
59
+ const nextReasoningContent = typeof existing === 'string' && existing.length > 0
60
+ ? existing.endsWith(reasoningContent) ? existing : `${existing}${reasoningContent}`
61
+ : reasoningContent
62
+ message.additional_kwargs = {
63
+ ...message.additional_kwargs,
64
+ reasoning_content: nextReasoningContent,
65
+ }
66
+ return message
67
+ }
68
+
69
+ export function shouldUseDeepSeekReasoningBridge(
70
+ provider: string | null | undefined,
71
+ endpoint: string | null | undefined,
72
+ ): boolean {
73
+ if (provider === 'deepseek') return true
74
+ if (!endpoint) return false
75
+ try {
76
+ return new URL(endpoint).hostname === 'api.deepseek.com'
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ export function createReasoningContentMetadata(reasoningContentDelta: string): Record<string, string> {
83
+ return { [REASONING_CONTENT_MD_KEY]: reasoningContentDelta }
84
+ }
85
+
86
+ class DeepSeekReasoningChatOpenAICompletions extends ChatOpenAICompletions {
87
+ override async _generate(
88
+ messages: BaseMessage[],
89
+ options: this['ParsedCallOptions'],
90
+ runManager?: CallbackManagerForLLMRun,
91
+ ): Promise<ChatResult> {
92
+ options.signal?.throwIfAborted()
93
+ const usageMetadata: UsageMetadata = {
94
+ input_tokens: 0,
95
+ output_tokens: 0,
96
+ total_tokens: 0,
97
+ }
98
+ const params = this.invocationParams(options)
99
+ const messagesMapped = attachReasoningContentToCompletionsMessages(
100
+ convertMessagesToCompletionsMessageParams({
101
+ messages,
102
+ model: this.model,
103
+ }),
104
+ messages,
105
+ )
106
+ if (params.stream) {
107
+ const stream = this._streamResponseChunks(messages, options, runManager)
108
+ const finalChunks: Record<string, ChatGenerationChunk> = {}
109
+ for await (const chunk of stream) {
110
+ chunk.message.response_metadata = {
111
+ ...chunk.generationInfo,
112
+ ...chunk.message.response_metadata,
113
+ }
114
+ const index = chunk.generationInfo?.completion ?? 0
115
+ if (finalChunks[index] === undefined) finalChunks[index] = chunk
116
+ else finalChunks[index] = finalChunks[index].concat(chunk)
117
+ }
118
+ const generations = Object.entries(finalChunks)
119
+ .sort(([aKey], [bKey]) => parseInt(aKey, 10) - parseInt(bKey, 10))
120
+ .map(([, value]) => value)
121
+ const { functions, function_call } = this.invocationParams(options)
122
+ const promptTokenUsage = await this._getEstimatedTokenCountFromPrompt(messages, functions, function_call)
123
+ const completionTokenUsage = await this._getNumTokensFromGenerations(generations)
124
+ usageMetadata.input_tokens = promptTokenUsage
125
+ usageMetadata.output_tokens = completionTokenUsage
126
+ usageMetadata.total_tokens = promptTokenUsage + completionTokenUsage
127
+ return {
128
+ generations,
129
+ llmOutput: {
130
+ estimatedTokenUsage: {
131
+ promptTokens: usageMetadata.input_tokens,
132
+ completionTokens: usageMetadata.output_tokens,
133
+ totalTokens: usageMetadata.total_tokens,
134
+ },
135
+ },
136
+ }
137
+ }
138
+
139
+ const data = await this.completionWithRetry({
140
+ ...params,
141
+ stream: false,
142
+ messages: messagesMapped,
143
+ }, {
144
+ signal: options?.signal,
145
+ ...options?.options,
146
+ })
147
+ const {
148
+ completion_tokens: completionTokens,
149
+ prompt_tokens: promptTokens,
150
+ total_tokens: totalTokens,
151
+ prompt_tokens_details: promptTokensDetails,
152
+ completion_tokens_details: completionTokensDetails,
153
+ } = data?.usage ?? {}
154
+ if (completionTokens) usageMetadata.output_tokens = (usageMetadata.output_tokens ?? 0) + completionTokens
155
+ if (promptTokens) usageMetadata.input_tokens = (usageMetadata.input_tokens ?? 0) + promptTokens
156
+ if (totalTokens) usageMetadata.total_tokens = (usageMetadata.total_tokens ?? 0) + totalTokens
157
+ if (promptTokensDetails?.audio_tokens !== null || promptTokensDetails?.cached_tokens !== null) {
158
+ usageMetadata.input_token_details = {
159
+ ...(promptTokensDetails?.audio_tokens !== null && { audio: promptTokensDetails?.audio_tokens }),
160
+ ...(promptTokensDetails?.cached_tokens !== null && { cache_read: promptTokensDetails?.cached_tokens }),
161
+ }
162
+ }
163
+ if (completionTokensDetails?.audio_tokens !== null || completionTokensDetails?.reasoning_tokens !== null) {
164
+ usageMetadata.output_token_details = {
165
+ ...(completionTokensDetails?.audio_tokens !== null && { audio: completionTokensDetails?.audio_tokens }),
166
+ ...(completionTokensDetails?.reasoning_tokens !== null && { reasoning: completionTokensDetails?.reasoning_tokens }),
167
+ }
168
+ }
169
+ const generations = []
170
+ for (const part of data?.choices ?? []) {
171
+ const generation: ChatGeneration = {
172
+ text: part.message?.content ?? '',
173
+ message: this._convertCompletionsMessageToBaseMessage(part.message ?? { role: 'assistant' }, data),
174
+ }
175
+ generation.generationInfo = {
176
+ ...(part.finish_reason ? { finish_reason: part.finish_reason } : {}),
177
+ ...(part.logprobs ? { logprobs: part.logprobs } : {}),
178
+ }
179
+ if (isAIMessage(generation.message)) generation.message.usage_metadata = usageMetadata
180
+ generation.message = new AIMessage(Object.fromEntries(
181
+ Object.entries(generation.message).filter(([key]) => !key.startsWith('lc_')),
182
+ ) as ConstructorParameters<typeof AIMessage>[0])
183
+ generations.push(generation)
184
+ }
185
+ return {
186
+ generations,
187
+ llmOutput: {
188
+ tokenUsage: {
189
+ promptTokens: usageMetadata.input_tokens,
190
+ completionTokens: usageMetadata.output_tokens,
191
+ totalTokens: usageMetadata.total_tokens,
192
+ },
193
+ },
194
+ }
195
+ }
196
+
197
+ override async *_streamResponseChunks(
198
+ messages: BaseMessage[],
199
+ options: this['ParsedCallOptions'],
200
+ runManager?: CallbackManagerForLLMRun,
201
+ ): AsyncGenerator<ChatGenerationChunk> {
202
+ const messagesMapped = attachReasoningContentToCompletionsMessages(
203
+ convertMessagesToCompletionsMessageParams({
204
+ messages,
205
+ model: this.model,
206
+ }),
207
+ messages,
208
+ )
209
+ const params = {
210
+ ...this.invocationParams(options, { streaming: true }),
211
+ messages: messagesMapped,
212
+ stream: true,
213
+ } satisfies OpenAIClient.Chat.Completions.ChatCompletionCreateParamsStreaming
214
+ let defaultRole: OpenAIClient.Chat.ChatCompletionRole | undefined
215
+ const streamIterable = await this.completionWithRetry(params, options)
216
+ let usage: OpenAIClient.Completions.CompletionUsage | undefined
217
+ for await (const data of streamIterable) {
218
+ if (options.signal?.aborted) return
219
+ const choice = data?.choices?.[0]
220
+ if (data.usage) usage = data.usage
221
+ if (!choice) continue
222
+ const { delta } = choice
223
+ if (!delta) continue
224
+ const chunk = this._convertCompletionsDeltaToBaseMessageChunk(delta as unknown as Record<string, unknown>, data, defaultRole)
225
+ defaultRole = delta.role ?? defaultRole
226
+ const newTokenIndices = {
227
+ prompt: options.promptIndex ?? 0,
228
+ completion: choice.index ?? 0,
229
+ }
230
+ if (typeof chunk.content !== 'string') {
231
+ continue
232
+ }
233
+ const generationInfo: Record<string, unknown> = { ...newTokenIndices }
234
+ if (choice.finish_reason != null) {
235
+ generationInfo.finish_reason = choice.finish_reason
236
+ generationInfo.system_fingerprint = data.system_fingerprint
237
+ generationInfo.model_name = data.model
238
+ generationInfo.service_tier = data.service_tier
239
+ }
240
+ if (this.logprobs) generationInfo.logprobs = choice.logprobs
241
+ const generationChunk = new ChatGenerationChunk({
242
+ message: chunk,
243
+ text: chunk.content,
244
+ generationInfo,
245
+ })
246
+ yield generationChunk
247
+ await runManager?.handleLLMNewToken(generationChunk.text ?? '', newTokenIndices, undefined, undefined, undefined, {
248
+ chunk: generationChunk,
249
+ })
250
+ }
251
+ if (usage) {
252
+ const inputTokenDetails = {
253
+ ...(usage.prompt_tokens_details?.audio_tokens !== null && { audio: usage.prompt_tokens_details?.audio_tokens }),
254
+ ...(usage.prompt_tokens_details?.cached_tokens !== null && { cache_read: usage.prompt_tokens_details?.cached_tokens }),
255
+ }
256
+ const outputTokenDetails = {
257
+ ...(usage.completion_tokens_details?.audio_tokens !== null && { audio: usage.completion_tokens_details?.audio_tokens }),
258
+ ...(usage.completion_tokens_details?.reasoning_tokens !== null && { reasoning: usage.completion_tokens_details?.reasoning_tokens }),
259
+ }
260
+ yield new ChatGenerationChunk({
261
+ message: new AIMessageChunk({
262
+ content: '',
263
+ response_metadata: { usage: { ...usage } },
264
+ usage_metadata: {
265
+ input_tokens: usage.prompt_tokens,
266
+ output_tokens: usage.completion_tokens,
267
+ total_tokens: usage.total_tokens,
268
+ ...(Object.keys(inputTokenDetails).length > 0 && { input_token_details: inputTokenDetails }),
269
+ ...(Object.keys(outputTokenDetails).length > 0 && { output_token_details: outputTokenDetails }),
270
+ },
271
+ }),
272
+ text: '',
273
+ })
274
+ }
275
+ if (options.signal?.aborted) throw new Error('AbortError')
276
+ }
277
+
278
+ protected override _convertCompletionsDeltaToBaseMessageChunk(
279
+ delta: Record<string, unknown>,
280
+ rawResponse: OpenAIClient.Chat.Completions.ChatCompletionChunk,
281
+ defaultRole?: OpenAIClient.Chat.ChatCompletionRole,
282
+ ): BaseMessageChunk {
283
+ return mergeReasoningContentIntoMessage(
284
+ super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole),
285
+ delta,
286
+ )
287
+ }
288
+
289
+ protected override _convertCompletionsMessageToBaseMessage(
290
+ message: OpenAIClient.ChatCompletionMessage,
291
+ rawResponse: OpenAIClient.ChatCompletion,
292
+ ): BaseMessage {
293
+ return mergeReasoningContentIntoMessage(
294
+ super._convertCompletionsMessageToBaseMessage(message, rawResponse),
295
+ message as unknown as Record<string, unknown>,
296
+ )
297
+ }
298
+ }
299
+
300
+ export function createDeepSeekReasoningChatOpenAI(fields: ChatOpenAIFields): ChatOpenAI {
301
+ return new ChatOpenAI({
302
+ ...fields,
303
+ completions: new DeepSeekReasoningChatOpenAICompletions(fields),
304
+ })
305
+ }
@@ -1,7 +1,12 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
 
4
+ import { AIMessage } from '@langchain/core/messages'
4
5
  import { streamOpenAiChat } from './openai'
6
+ import {
7
+ attachReasoningContentToCompletionsMessages,
8
+ getReasoningContentFromLangChainMessage,
9
+ } from './deepseek-reasoning-chat-openai'
5
10
 
6
11
  function sseChunk(data: unknown) {
7
12
  return `data: ${JSON.stringify(data)}\n\n`
@@ -44,11 +49,78 @@ test('OpenAI-compatible reasoning deltas stream as thinking instead of visible t
44
49
  } as Parameters<typeof streamOpenAiChat>[0])
45
50
 
46
51
  assert.equal(result, 'visible answer')
47
- assert.deepEqual(parseSseEvents(writes), [
52
+ const events = parseSseEvents(writes)
53
+ assert.deepEqual(events, [
48
54
  { t: 'thinking', text: 'internal reasoning ' },
55
+ { t: 'md', text: JSON.stringify({ reasoningContentDelta: 'internal reasoning ' }) },
49
56
  { t: 'd', text: 'visible answer' },
50
57
  ])
51
58
  } finally {
52
59
  globalThis.fetch = originalFetch
53
60
  }
54
61
  })
62
+
63
+ test('OpenAI-compatible DeepSeek history replays stored assistant reasoning_content', async () => {
64
+ const originalFetch = globalThis.fetch
65
+ const encoded = new TextEncoder()
66
+ const frames = [
67
+ sseChunk({ choices: [{ delta: { content: 'next answer' } }] }),
68
+ 'data: [DONE]\n\n',
69
+ ]
70
+ const writes: string[] = []
71
+ const capture: { requestBody?: { messages?: Array<Record<string, unknown>> } } = {}
72
+
73
+ globalThis.fetch = async (_url, init) => {
74
+ capture.requestBody = JSON.parse(String(init?.body || '{}')) as { messages?: Array<Record<string, unknown>> }
75
+ return new Response(new ReadableStream({
76
+ start(controller) {
77
+ for (const frame of frames) controller.enqueue(encoded.encode(frame))
78
+ controller.close()
79
+ },
80
+ }), {
81
+ status: 200,
82
+ headers: { 'content-type': 'text/event-stream' },
83
+ })
84
+ }
85
+
86
+ try {
87
+ await streamOpenAiChat({
88
+ session: {
89
+ id: 'session-1',
90
+ provider: 'deepseek',
91
+ model: 'deepseek-reasoner',
92
+ apiEndpoint: 'https://api.deepseek.com/v1',
93
+ },
94
+ message: 'next',
95
+ write: (data) => writes.push(data),
96
+ active: new Map(),
97
+ loadHistory: () => [{
98
+ role: 'assistant',
99
+ text: 'visible answer',
100
+ reasoningContent: 'hidden chain',
101
+ }],
102
+ } as Parameters<typeof streamOpenAiChat>[0])
103
+
104
+ const messages = capture.requestBody?.messages
105
+ assert.deepEqual(messages, [
106
+ { role: 'assistant', content: 'visible answer', reasoning_content: 'hidden chain' },
107
+ { role: 'user', content: 'next' },
108
+ ])
109
+ } finally {
110
+ globalThis.fetch = originalFetch
111
+ }
112
+ })
113
+
114
+ test('DeepSeek reasoning helper preserves native reasoning for LangChain replay', () => {
115
+ const assistant = new AIMessage({
116
+ content: 'visible answer',
117
+ additional_kwargs: { reasoning_content: 'hidden chain' },
118
+ })
119
+
120
+ assert.equal(getReasoningContentFromLangChainMessage(assistant), 'hidden chain')
121
+ assert.deepEqual(attachReasoningContentToCompletionsMessages([
122
+ { role: 'assistant', content: 'visible answer' },
123
+ ], [assistant]), [
124
+ { role: 'assistant', content: 'visible answer', reasoning_content: 'hidden chain' },
125
+ ])
126
+ })
@@ -3,6 +3,10 @@ import type { StreamChatOptions } from './index'
3
3
  import { PROVIDER_DEFAULTS, IMAGE_EXTS, TEXT_EXTS, PDF_MAX_CHARS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
4
4
  import { log } from '@/lib/server/logger'
5
5
  import { resolveImagePath } from '@/lib/server/resolve-image'
6
+ import {
7
+ createReasoningContentMetadata,
8
+ shouldUseDeepSeekReasoningBridge,
9
+ } from '@/lib/providers/deepseek-reasoning-chat-openai'
6
10
 
7
11
  const TAG = 'provider-openai'
8
12
 
@@ -173,6 +177,7 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
173
177
  : ''
174
178
  if (reasoningDelta) {
175
179
  writeSSE(write, 'thinking', reasoningDelta)
180
+ writeSSE(write, 'md', JSON.stringify(createReasoningContentMetadata(reasoningDelta)))
176
181
  }
177
182
  if (contentDelta) {
178
183
  fullResponse += contentDelta
@@ -217,7 +222,11 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
217
222
  }
218
223
 
219
224
  async function buildMessages(session: Record<string, unknown>, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => Record<string, unknown>[], imageUrl?: string) {
220
- const msgs: Array<{ role: string; content: unknown }> = []
225
+ const msgs: Array<{ role: string; content: unknown; reasoning_content?: string }> = []
226
+ const includeReasoningContent = shouldUseDeepSeekReasoningBridge(
227
+ typeof session.provider === 'string' ? session.provider : null,
228
+ typeof session.apiEndpoint === 'string' ? session.apiEndpoint : null,
229
+ )
221
230
 
222
231
  if (systemPrompt) {
223
232
  msgs.push({ role: 'system', content: systemPrompt })
@@ -231,7 +240,15 @@ async function buildMessages(session: Record<string, unknown>, message: string,
231
240
  const parts = await fileToContentParts(histImagePath)
232
241
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: m.text }] })
233
242
  } else {
234
- msgs.push({ role: m.role as string, content: m.text })
243
+ const role = m.role as string
244
+ const reasoningContent = includeReasoningContent && role === 'assistant' && typeof m.reasoningContent === 'string'
245
+ ? m.reasoningContent
246
+ : ''
247
+ msgs.push({
248
+ role,
249
+ content: m.text,
250
+ ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
251
+ })
235
252
  }
236
253
  }
237
254
  }
@@ -250,6 +250,19 @@ test('buildChatModel disables parallel_tool_calls for Ollama local to avoid dupl
250
250
  assert.equal(llm.clientConfig?.baseURL, 'http://localhost:11434/v1')
251
251
  })
252
252
 
253
+ test('buildChatModel uses a reasoning_content-preserving bridge for DeepSeek', () => {
254
+ const llm = buildChatModel({
255
+ provider: 'deepseek',
256
+ model: 'deepseek-reasoner',
257
+ apiKey: 'deepseek-key',
258
+ }) as ChatOpenAI
259
+ const completionBridge = llm as unknown as { completions?: { constructor?: { name?: string } } }
260
+
261
+ assert.equal(llm.model, 'deepseek-reasoner')
262
+ assert.equal(llm.clientConfig?.baseURL, 'https://api.deepseek.com/v1')
263
+ assert.equal(completionBridge.completions?.constructor?.name, 'DeepSeekReasoningChatOpenAICompletions')
264
+ })
265
+
253
266
  test('buildChatModel uses Ollama Cloud only when explicit cloud mode is selected', () => {
254
267
  saveCredentials({
255
268
  'cred-1': {
@@ -2,6 +2,10 @@ import { ChatAnthropic } from '@langchain/anthropic'
2
2
  import { ChatOpenAI } from '@langchain/openai'
3
3
  import { getProviderList } from '../providers'
4
4
  import { normalizeOpenClawEndpoint } from '@/lib/openclaw/openclaw-endpoint'
5
+ import {
6
+ createDeepSeekReasoningChatOpenAI,
7
+ shouldUseDeepSeekReasoningBridge,
8
+ } from '@/lib/providers/deepseek-reasoning-chat-openai'
5
9
  import { NON_LANGGRAPH_PROVIDER_IDS } from '../provider-sets'
6
10
  import { resolveOllamaRuntimeConfig } from './ollama-runtime'
7
11
  import { resolveProviderApiEndpoint, resolveProviderCredentialId } from './provider-endpoint'
@@ -136,6 +140,9 @@ export function buildChatModel(opts: {
136
140
  config.configuration.defaultHeaders = { 'Content-Type': 'text/plain' }
137
141
  }
138
142
  }
143
+ if (shouldUseDeepSeekReasoningBridge(provider, endpoint)) {
144
+ return createDeepSeekReasoningChatOpenAI(config)
145
+ }
139
146
  return new ChatOpenAI(config)
140
147
  }
141
148
 
@@ -278,6 +278,7 @@ export async function finalizeChatTurn(params: {
278
278
 
279
279
  const {
280
280
  thinkingText,
281
+ reasoningContent,
281
282
  streamErrors,
282
283
  accumulatedUsage,
283
284
  } = partialPersistence.getSnapshot()
@@ -487,6 +488,7 @@ export async function finalizeChatTurn(params: {
487
488
  text: persistedText,
488
489
  time: nowTs,
489
490
  thinking: thinkingText || undefined,
491
+ reasoningContent: reasoningContent || undefined,
490
492
  toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
491
493
  kind: persistedKind,
492
494
  citations: grounding.citations.length > 0 ? grounding.citations : undefined,
@@ -19,9 +19,11 @@ import {
19
19
  applyMessageLifecycleHooks,
20
20
  type PreparedExecutableChatTurn,
21
21
  } from '@/lib/server/chat-execution/chat-turn-preparation'
22
+ import { REASONING_CONTENT_MD_KEY } from '@/lib/providers/deepseek-reasoning-chat-openai'
22
23
 
23
24
  export interface PartialAssistantSnapshot {
24
25
  thinkingText: string
26
+ reasoningContent: string
25
27
  toolEvents: MessageToolEvent[]
26
28
  streamErrors: string[]
27
29
  accumulatedUsage: {
@@ -53,6 +55,7 @@ export function createPartialAssistantPersistence(input: {
53
55
  const accumulatedUsage = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }
54
56
 
55
57
  let thinkingText = ''
58
+ let reasoningContent = ''
56
59
  let streamingPartialText = ''
57
60
  let lastPartialSaveAt = 0
58
61
  let lastPartialSnapshotKey = ''
@@ -82,6 +85,7 @@ export function createPartialAssistantPersistence(input: {
82
85
  streaming: true,
83
86
  runId: prepared.lifecycleRunId,
84
87
  thinking: thinkingText || undefined,
88
+ reasoningContent: reasoningContent || undefined,
85
89
  toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
86
90
  },
87
91
  enabledIds: prepared.extensionsForRun,
@@ -93,6 +97,7 @@ export function createPartialAssistantPersistence(input: {
93
97
  const snapshotKey = JSON.stringify([
94
98
  partialMsg.text,
95
99
  partialMsg.thinking || '',
100
+ partialMsg.reasoningContent || '',
96
101
  getToolEventsSnapshotKey(partialMsg.toolEvents || []),
97
102
  ])
98
103
  if (snapshotKey === lastPartialSnapshotKey) return
@@ -140,6 +145,7 @@ export function createPartialAssistantPersistence(input: {
140
145
  if (event.t === 'reset') {
141
146
  streamingPartialText = event.text || ''
142
147
  thinkingText = ''
148
+ reasoningContent = ''
143
149
  toolEvents.length = 0
144
150
  shouldPersistPartial = true
145
151
  immediatePartialPersist = true
@@ -169,6 +175,11 @@ export function createPartialAssistantPersistence(input: {
169
175
  if (typeof usage.outputTokens === 'number') accumulatedUsage.outputTokens += usage.outputTokens
170
176
  if (typeof usage.estimatedCost === 'number') accumulatedUsage.estimatedCost += usage.estimatedCost
171
177
  }
178
+ const reasoningContentDelta = mdPayload[REASONING_CONTENT_MD_KEY]
179
+ if (typeof reasoningContentDelta === 'string' && reasoningContentDelta.length > 0) {
180
+ reasoningContent += reasoningContentDelta
181
+ shouldPersistPartial = true
182
+ }
172
183
  } catch {
173
184
  // Ignore non-JSON md events.
174
185
  }
@@ -212,6 +223,7 @@ export function createPartialAssistantPersistence(input: {
212
223
  getSnapshot() {
213
224
  return {
214
225
  thinkingText,
226
+ reasoningContent,
215
227
  toolEvents: [...toolEvents],
216
228
  streamErrors: [...streamErrors],
217
229
  accumulatedUsage: { ...accumulatedUsage },
@@ -26,6 +26,10 @@ import { truncateToolResultText, calculateMaxToolResultChars } from '@/lib/serve
26
26
  import { notifyWithPayload } from '@/lib/server/ws-hub'
27
27
  import { resolveExclusiveMemoryWriteTerminalAllowance } from '@/lib/server/chat-execution/chat-streaming-utils'
28
28
  import { getContextWindowSize } from '@/lib/server/context-manager'
29
+ import {
30
+ createReasoningContentMetadata,
31
+ extractReasoningContentDelta,
32
+ } from '@/lib/providers/deepseek-reasoning-chat-openai'
29
33
 
30
34
  // ---------------------------------------------------------------------------
31
35
  // LangGraph event kind constants
@@ -82,7 +86,7 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
82
86
  } = opts
83
87
 
84
88
  let waitingForToolResult = false
85
- let reachedExecutionBoundary = false
89
+ const reachedExecutionBoundary = false
86
90
  let executionFollowthroughReason: 'research_limit' | 'post_simulation' | null = null
87
91
  let loopBroken = false
88
92
  let toolEndCount = 0
@@ -98,6 +102,12 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
98
102
  if (kind === EVENT_CHAT_MODEL_STREAM) {
99
103
  timers.armIdleWatchdog(waitingForToolResult)
100
104
  const chunk = event.data?.chunk
105
+ const reasoningDelta = extractReasoningContentDelta(chunk?.additional_kwargs as Record<string, unknown> | undefined)
106
+ if (reasoningDelta) {
107
+ state.accumulatedThinking += reasoningDelta
108
+ write(`data: ${JSON.stringify({ t: 'thinking', text: reasoningDelta })}\n\n`)
109
+ write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify(createReasoningContentMetadata(reasoningDelta)) })}\n\n`)
110
+ }
101
111
  if (chunk?.content) {
102
112
  if (Array.isArray(chunk.content)) {
103
113
  for (const block of chunk.content) {
@@ -171,6 +171,15 @@ function extractProviderErrorInfo(err: unknown): { statusCode: number; retryAfte
171
171
  return { statusCode, retryAfterMs }
172
172
  }
173
173
 
174
+ function buildAssistantHistoryMessage(message: Message): AIMessage {
175
+ return new AIMessage({
176
+ content: message.text,
177
+ ...(message.reasoningContent
178
+ ? { additional_kwargs: { reasoning_content: message.reasoningContent } }
179
+ : {}),
180
+ })
181
+ }
182
+
174
183
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
175
184
  interface StreamAgentChatOpts {
176
185
  session: Session
@@ -732,7 +741,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
732
741
  const resolvedImg = resolveImagePath(m.imagePath, m.imageUrl)
733
742
  langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, resolvedImg ?? undefined, m.attachedFiles) }))
734
743
  } else {
735
- langchainMessages.push(new AIMessage({ content: m.text }))
744
+ langchainMessages.push(buildAssistantHistoryMessage(m))
736
745
  }
737
746
  }
738
747
 
@@ -1085,7 +1094,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1085
1094
  if (m.role === 'user') {
1086
1095
  langchainMessages.push(new HumanMessage({ content: m.text }))
1087
1096
  } else {
1088
- langchainMessages.push(new AIMessage({ content: m.text }))
1097
+ langchainMessages.push(buildAssistantHistoryMessage(m))
1089
1098
  }
1090
1099
  }
1091
1100
  langchainMessages.push(new HumanMessage({ content: currentContent }))
@@ -40,6 +40,8 @@ export interface Message {
40
40
  attachedFiles?: string[]
41
41
  toolEvents?: MessageToolEvent[]
42
42
  thinking?: string
43
+ /** Provider-native assistant reasoning used only when replaying model history. */
44
+ reasoningContent?: string
43
45
  kind?: 'chat' | 'heartbeat' | 'system' | 'context-clear' | 'extension-ui' | 'connector-delivery'
44
46
  suppressed?: boolean
45
47
  bookmarked?: boolean