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:
diguaandCursor committed 2026-06-10 22:18:20 +08:00
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)
})
})
+14 -2
View File
@@ -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,
+46 -11
View File
@@ -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))
}
}