mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(ai): replay reasoning_content for DeepSeek tool-call turns
DeepSeek API requires reasoning_content to be retransmitted on assistant messages that precede tool results. Add ReplayOptions to history replay so thinking blocks are included as ThinkingContent when the model uses DeepSeek-format thinking, preventing 400 errors in multi-turn tool-call conversations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
42649d835d
commit
2b6aed52c9
3 files changed
+178
-15
No files matched your search
@@ -9,7 +9,7 @@
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { toPiHistoryMessages } from '../history'
|
||||
import { toPiHistoryMessages, type ReplayOptions } from '../history'
|
||||
import type { SimpleHistoryMessage } from '../types'
|
||||
import type { ContentBlock } from '../../chats'
|
||||
|
||||
@@ -235,7 +235,7 @@ describe('toPiHistoryMessages — tool call replay', () => {
|
||||
assert.equal(emptyAssistant, false)
|
||||
})
|
||||
|
||||
it('does not replay think/chart/error blocks', () => {
|
||||
it('does not replay think/chart/error blocks without replay options', () => {
|
||||
const history: SimpleHistoryMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -258,3 +258,119 @@ describe('toPiHistoryMessages — tool call replay', () => {
|
||||
assert.ok(!allText.includes('boom'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('toPiHistoryMessages — thinking replay for tool-call turns', () => {
|
||||
const replayOptions: ReplayOptions = {
|
||||
modelInfo: { api: 'openai-completions', provider: 'deepseek', id: 'deepseek-v4-pro' },
|
||||
thinkingSignature: 'reasoning_content',
|
||||
}
|
||||
|
||||
it('replays thinking blocks alongside tool calls when thinkingSignature is set', () => {
|
||||
const history: SimpleHistoryMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'result',
|
||||
contentBlocks: [
|
||||
{ type: 'think', tag: 'thinking', text: 'I should search for birthday info' },
|
||||
toolBlock(),
|
||||
{ type: 'text', text: 'result' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const out = toPiHistoryMessages(history, replayOptions)
|
||||
const assistantToolUse = out.find((m) => m.role === 'assistant' && m.stopReason === 'toolUse')
|
||||
assert.ok(assistantToolUse && assistantToolUse.role === 'assistant')
|
||||
const thinkingBlock = assistantToolUse.content.find((c) => c.type === 'thinking')
|
||||
assert.ok(thinkingBlock && thinkingBlock.type === 'thinking')
|
||||
assert.equal(thinkingBlock.thinking, 'I should search for birthday info')
|
||||
assert.equal(thinkingBlock.thinkingSignature, 'reasoning_content')
|
||||
})
|
||||
|
||||
it('sets model info on replayed assistant messages', () => {
|
||||
const history: SimpleHistoryMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'x',
|
||||
contentBlocks: [toolBlock(), { type: 'text', text: 'x' }],
|
||||
},
|
||||
]
|
||||
const out = toPiHistoryMessages(history, replayOptions)
|
||||
const assistantMsg = out.find((m) => m.role === 'assistant')
|
||||
assert.ok(assistantMsg && assistantMsg.role === 'assistant')
|
||||
assert.equal(assistantMsg.provider, 'deepseek')
|
||||
assert.equal(assistantMsg.model, 'deepseek-v4-pro')
|
||||
})
|
||||
|
||||
it('does not replay thinking for messages without tool calls', () => {
|
||||
const history: SimpleHistoryMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'just text',
|
||||
contentBlocks: [
|
||||
{ type: 'think', tag: 'thinking', text: 'some reasoning' },
|
||||
{ type: 'text', text: 'just text' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const out = toPiHistoryMessages(history, replayOptions)
|
||||
const allText = JSON.stringify(out)
|
||||
assert.ok(!allText.includes('some reasoning'))
|
||||
})
|
||||
|
||||
it('replays multiple thinking blocks across multi-step tool calls', () => {
|
||||
const history: SimpleHistoryMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'final',
|
||||
contentBlocks: [
|
||||
{ type: 'think', tag: 'thinking', text: 'step 1 reasoning' },
|
||||
toolBlock({ toolCallId: 'call_1', result: 'r1' }),
|
||||
{ type: 'think', tag: 'thinking', text: 'step 2 reasoning' },
|
||||
toolBlock({ toolCallId: 'call_2', name: 'get_recent_messages', result: 'r2' }),
|
||||
{ type: 'think', tag: 'thinking', text: 'final reasoning' },
|
||||
{ type: 'text', text: 'final' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const out = toPiHistoryMessages(history, replayOptions)
|
||||
|
||||
const assistantMsgs = out.filter((m) => m.role === 'assistant')
|
||||
assert.equal(assistantMsgs.length, 3)
|
||||
|
||||
// First tool-call assistant has step 1 thinking
|
||||
const first = assistantMsgs[0]
|
||||
assert.ok(first.role === 'assistant' && first.stopReason === 'toolUse')
|
||||
const think1 = first.content.find((c) => c.type === 'thinking')
|
||||
assert.ok(think1 && think1.type === 'thinking')
|
||||
assert.equal(think1.thinking, 'step 1 reasoning')
|
||||
|
||||
// Second tool-call assistant has step 2 thinking
|
||||
const second = assistantMsgs[1]
|
||||
assert.ok(second.role === 'assistant' && second.stopReason === 'toolUse')
|
||||
const think2 = second.content.find((c) => c.type === 'thinking')
|
||||
assert.ok(think2 && think2.type === 'thinking')
|
||||
assert.equal(think2.thinking, 'step 2 reasoning')
|
||||
|
||||
// Final assistant has step 3 thinking
|
||||
const third = assistantMsgs[2]
|
||||
assert.ok(third.role === 'assistant' && third.stopReason === 'stop')
|
||||
const think3 = third.content.find((c) => c.type === 'thinking')
|
||||
assert.ok(think3 && think3.type === 'thinking')
|
||||
assert.equal(think3.thinking, 'final reasoning')
|
||||
})
|
||||
|
||||
it('skips empty thinking blocks during replay', () => {
|
||||
const history: SimpleHistoryMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'x',
|
||||
contentBlocks: [{ type: 'think', tag: 'thinking', text: ' ' }, toolBlock(), { type: 'text', text: 'x' }],
|
||||
},
|
||||
]
|
||||
const out = toPiHistoryMessages(history, replayOptions)
|
||||
const assistantToolUse = out.find((m) => m.role === 'assistant' && m.stopReason === 'toolUse')
|
||||
assert.ok(assistantToolUse && assistantToolUse.role === 'assistant')
|
||||
const hasThinking = assistantToolUse.content.some((c) => c.type === 'thinking')
|
||||
assert.equal(hasThinking, false)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import { StreamingThinkTagParser, needsStreamingThinkParsing } from '@openchatla
|
||||
import type { AgentCoreOptions, AgentCoreResult, AgentTokenUsage } from './types'
|
||||
import { initTokenizer } from '../tokenizer'
|
||||
import { DEFAULT_MAX_TOOL_ROUNDS } from './constants'
|
||||
import { toPiHistoryMessages } from './history'
|
||||
import { toPiHistoryMessages, type ReplayOptions } from './history'
|
||||
|
||||
function isPiMessage(message: PiAgentMessage): message is PiMessage {
|
||||
return message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult'
|
||||
@@ -92,13 +92,25 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
|
||||
|
||||
const finalThinkingLevel = resolvedThinkingLevel ?? (piModel.reasoning ? undefined : 'off')
|
||||
|
||||
// DeepSeek-format APIs require reasoning_content on assistant messages that
|
||||
// precede tool results; build replay options so toPiHistoryMessages includes
|
||||
// persisted thinking blocks in those messages.
|
||||
const thinkingFormat = (piModel.compat as Record<string, unknown> | undefined)?.thinkingFormat
|
||||
const replayOptions: ReplayOptions | undefined =
|
||||
piModel.reasoning && thinkingFormat === 'deepseek'
|
||||
? {
|
||||
modelInfo: { api: piModel.api, provider: piModel.provider, id: piModel.id },
|
||||
thinkingSignature: 'reasoning_content',
|
||||
}
|
||||
: undefined
|
||||
|
||||
const coreAgent = new PiAgentCore({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model: effectiveModel,
|
||||
thinkingLevel: finalThinkingLevel,
|
||||
tools: maxToolRounds > 0 ? tools : [],
|
||||
messages: toPiHistoryMessages(history),
|
||||
messages: toPiHistoryMessages(history, replayOptions),
|
||||
},
|
||||
getApiKey: () => apiKey,
|
||||
streamFn: resolvedStreamFn,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* Persisted toolCallIds are replayed verbatim to keep requests byte-stable
|
||||
* across turns (prompt cache friendly). Legacy messages without tool data
|
||||
* fall back to plain text replay.
|
||||
*
|
||||
* When model info is provided, thinking blocks are replayed alongside tool
|
||||
* calls so APIs that require reasoning_content on tool-call turns (e.g.
|
||||
* DeepSeek) receive the original reasoning chain instead of an empty string.
|
||||
*/
|
||||
|
||||
import type { Message as PiMessage, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai'
|
||||
@@ -18,6 +22,28 @@ import type { ContentBlock } from '../chats'
|
||||
type ToolBlock = Extract<ContentBlock, { type: 'tool' }>
|
||||
export type ReplayableToolBlock = ToolBlock & { tool: ToolBlock['tool'] & { toolCallId: string; result: string } }
|
||||
|
||||
/**
|
||||
* Model metadata attached to replayed assistant messages so pi-ai's
|
||||
* transform-messages layer treats them as same-model (preserving thinking
|
||||
* blocks in their native format instead of converting to plain text).
|
||||
*/
|
||||
export interface ReplayModelInfo {
|
||||
api: string
|
||||
provider: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface ReplayOptions {
|
||||
modelInfo?: ReplayModelInfo
|
||||
/**
|
||||
* Field name used by the provider's streaming API for reasoning content
|
||||
* (e.g. "reasoning_content" for DeepSeek). When set and the assistant
|
||||
* message contains tool calls, persisted think blocks are replayed as
|
||||
* pi-ai ThinkingContent blocks with this signature.
|
||||
*/
|
||||
thinkingSignature?: string
|
||||
}
|
||||
|
||||
function createEmptyPiUsage(): PiUsage {
|
||||
return {
|
||||
input: 0,
|
||||
@@ -50,25 +76,34 @@ function stripInternalParams(params?: Record<string, unknown>): Record<string, u
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function makeAssistantMessage(content: AssistantMessage['content'], stopReason: 'stop' | 'toolUse'): AssistantMessage {
|
||||
function makeAssistantMessage(
|
||||
content: AssistantMessage['content'],
|
||||
stopReason: 'stop' | 'toolUse',
|
||||
modelInfo?: ReplayModelInfo
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content,
|
||||
api: 'openai-completions',
|
||||
provider: 'chatlab',
|
||||
model: 'unknown',
|
||||
api: (modelInfo?.api ?? 'openai-completions') as AssistantMessage['api'],
|
||||
provider: modelInfo?.provider ?? 'chatlab',
|
||||
model: modelInfo?.id ?? 'unknown',
|
||||
usage: createEmptyPiUsage(),
|
||||
stopReason,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function replayAssistantBlocks(blocks: ContentBlock[], out: PiMessage[]): void {
|
||||
function replayAssistantBlocks(blocks: ContentBlock[], out: PiMessage[], options?: ReplayOptions): void {
|
||||
const { modelInfo, thinkingSignature } = options ?? {}
|
||||
const replayThinking = !!thinkingSignature && blocks.some(isReplayableToolBlock)
|
||||
|
||||
let content: AssistantMessage['content'] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
if (block.text.trim()) content.push({ type: 'text', text: block.text })
|
||||
} else if (block.type === 'think' && replayThinking && block.text.trim()) {
|
||||
content.push({ type: 'thinking', thinking: block.text, thinkingSignature })
|
||||
} else if (isReplayableToolBlock(block)) {
|
||||
content.push({
|
||||
type: 'toolCall',
|
||||
@@ -76,7 +111,7 @@ function replayAssistantBlocks(blocks: ContentBlock[], out: PiMessage[]): void {
|
||||
name: block.tool.name,
|
||||
arguments: stripInternalParams(block.tool.params),
|
||||
})
|
||||
out.push(makeAssistantMessage(content, 'toolUse'))
|
||||
out.push(makeAssistantMessage(content, 'toolUse', modelInfo))
|
||||
out.push({
|
||||
role: 'toolResult',
|
||||
toolCallId: block.tool.toolCallId,
|
||||
@@ -87,15 +122,15 @@ function replayAssistantBlocks(blocks: ContentBlock[], out: PiMessage[]): void {
|
||||
})
|
||||
content = []
|
||||
}
|
||||
// think/chart/plan/error/summary_meta blocks and unfinished/legacy tool blocks are not replayed
|
||||
// chart/plan/error/summary_meta blocks and unfinished/legacy tool blocks are not replayed
|
||||
}
|
||||
|
||||
if (content.length > 0) {
|
||||
out.push(makeAssistantMessage(content, 'stop'))
|
||||
out.push(makeAssistantMessage(content, 'stop', modelInfo))
|
||||
}
|
||||
}
|
||||
|
||||
export function toPiHistoryMessages(messages: SimpleHistoryMessage[]): PiMessage[] {
|
||||
export function toPiHistoryMessages(messages: SimpleHistoryMessage[], options?: ReplayOptions): PiMessage[] {
|
||||
const out: PiMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
@@ -111,9 +146,9 @@ export function toPiHistoryMessages(messages: SimpleHistoryMessage[]): PiMessage
|
||||
// summary 作为 assistant 消息传给 LLM(它是压缩后的上下文总结)
|
||||
const replayable = msg.role === 'assistant' && msg.contentBlocks?.some(isReplayableToolBlock)
|
||||
if (replayable) {
|
||||
replayAssistantBlocks(msg.contentBlocks!, out)
|
||||
replayAssistantBlocks(msg.contentBlocks!, out, options)
|
||||
} else {
|
||||
out.push(makeAssistantMessage([{ type: 'text', text: msg.content || '' }], 'stop'))
|
||||
out.push(makeAssistantMessage([{ type: 'text', text: msg.content || '' }], 'stop', options?.modelInfo))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user