@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 +8 -0
- package/package.json +1 -1
- package/src/lib/providers/deepseek-reasoning-chat-openai.ts +305 -0
- package/src/lib/providers/openai.test.ts +73 -1
- package/src/lib/providers/openai.ts +19 -2
- package/src/lib/server/build-llm.test.ts +13 -0
- package/src/lib/server/build-llm.ts +7 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -0
- package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +12 -0
- package/src/lib/server/chat-execution/iteration-event-handler.ts +11 -1
- package/src/lib/server/chat-execution/stream-agent-chat.ts +11 -2
- package/src/types/message.ts +2 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1097
|
+
langchainMessages.push(buildAssistantHistoryMessage(m))
|
|
1089
1098
|
}
|
|
1090
1099
|
}
|
|
1091
1100
|
langchainMessages.push(new HumanMessage({ content: currentContent }))
|
package/src/types/message.ts
CHANGED
|
@@ -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
|