mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-08 22:21:23 +00:00
fix: preserve DeepSeek reasoning_content through condense and API transmission
DeepSeek's interleaved thinking mode requires reasoning_content to be passed back during multi-turn tool call sequences. After context condensing, this field was being lost, causing 400 API errors. Root cause: buildCleanConversationHistory() was stripping reasoning_content from assistant messages before sending to the API. Changes: - Add reasoning_content field to ApiMessage type - Preserve reasoning_content during condense operations - Fix turn alternation to prevent consecutive assistant messages after summary - Pass reasoning_content through buildCleanConversationHistory() to API - Add comprehensive tests for the fix See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
This commit is contained in:
parent
f7adc4b1cf
commit
5028d0ca79
4 changed files with 347 additions and 20 deletions
|
|
@ -104,7 +104,7 @@ describe("getKeepMessagesWithToolBlocks", () => {
|
|||
expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
|
||||
})
|
||||
|
||||
it("should not preserve tool_use blocks when first kept message is assistant role", () => {
|
||||
it("should extend keep range backwards to ensure first kept message is user role", () => {
|
||||
const toolUseBlock = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_123",
|
||||
|
|
@ -127,9 +127,12 @@ describe("getKeepMessagesWithToolBlocks", () => {
|
|||
|
||||
const result = getKeepMessagesWithToolBlocks(messages, 3)
|
||||
|
||||
// First kept message is assistant, not user with tool_result
|
||||
expect(result.keepMessages).toHaveLength(3)
|
||||
expect(result.keepMessages[0].role).toBe("assistant")
|
||||
// The function now extends backwards to ensure first kept message is a user message
|
||||
// This prevents consecutive assistant messages (summary + first kept message)
|
||||
// which would cause DeepSeek API 400 errors
|
||||
expect(result.keepMessages).toHaveLength(4) // Extended from 3 to 4 to include user message
|
||||
expect(result.keepMessages[0].role).toBe("user") // Now starts with user
|
||||
expect(result.keepMessages[0].content).toBe("Please read")
|
||||
expect(result.toolUseBlocksToPreserve).toHaveLength(0)
|
||||
})
|
||||
|
||||
|
|
@ -246,6 +249,174 @@ describe("getKeepMessagesWithToolBlocks", () => {
|
|||
expect(result.keepMessages).toEqual(messages)
|
||||
expect(result.toolUseBlocksToPreserve).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should preserve reasoning_content from preceding assistant message (top-level property)", () => {
|
||||
const toolUseBlock = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_deepseek",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
}
|
||||
const toolResultBlock = {
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: "toolu_deepseek",
|
||||
content: "file contents",
|
||||
}
|
||||
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Hello", ts: 1 },
|
||||
{ role: "assistant", content: "Let me think", ts: 2 },
|
||||
{ role: "user", content: "Continue", ts: 3 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
|
||||
ts: 4,
|
||||
reasoning_content: "I need to read this file to understand the context.", // DeepSeek interleaved thinking
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [toolResultBlock, { type: "text" as const, text: "Thanks" }],
|
||||
ts: 5,
|
||||
},
|
||||
{ role: "assistant", content: "Got it", ts: 6 },
|
||||
{ role: "user", content: "Done", ts: 7 },
|
||||
]
|
||||
|
||||
const result = getKeepMessagesWithToolBlocks(messages, 3)
|
||||
|
||||
expect(result.keepMessages).toHaveLength(3)
|
||||
expect(result.toolUseBlocksToPreserve).toHaveLength(1)
|
||||
expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
|
||||
expect(result.reasoningContentToPreserve).toBe("I need to read this file to understand the context.")
|
||||
})
|
||||
|
||||
it("should preserve reasoning_content from content blocks when not at top level", () => {
|
||||
const toolUseBlock = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_reasoning_block",
|
||||
name: "search_files",
|
||||
input: { query: "test" },
|
||||
}
|
||||
const toolResultBlock = {
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: "toolu_reasoning_block",
|
||||
content: "search results",
|
||||
}
|
||||
const reasoningBlock = {
|
||||
type: "reasoning",
|
||||
text: "Reasoning from content block",
|
||||
}
|
||||
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Search for something", ts: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
reasoningBlock as any, // Reasoning stored in content blocks
|
||||
{ type: "text" as const, text: "Searching..." },
|
||||
toolUseBlock,
|
||||
],
|
||||
ts: 2,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [toolResultBlock],
|
||||
ts: 3,
|
||||
},
|
||||
{ role: "assistant", content: "Found it", ts: 4 },
|
||||
{ role: "user", content: "Thanks", ts: 5 },
|
||||
]
|
||||
|
||||
const result = getKeepMessagesWithToolBlocks(messages, 3)
|
||||
|
||||
expect(result.keepMessages).toHaveLength(3)
|
||||
expect(result.toolUseBlocksToPreserve).toHaveLength(1)
|
||||
expect(result.reasoningContentToPreserve).toBe("Reasoning from content block")
|
||||
})
|
||||
|
||||
it("should prefer top-level reasoning_content over content block reasoning", () => {
|
||||
const toolUseBlock = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_both",
|
||||
name: "list_files",
|
||||
input: { path: "." },
|
||||
}
|
||||
const toolResultBlock = {
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: "toolu_both",
|
||||
content: "file list",
|
||||
}
|
||||
const reasoningBlock = {
|
||||
type: "reasoning",
|
||||
text: "Reasoning in content block (should be ignored)",
|
||||
}
|
||||
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "List files", ts: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [reasoningBlock as any, { type: "text" as const, text: "Listing..." }, toolUseBlock],
|
||||
ts: 2,
|
||||
reasoning_content: "Top-level reasoning (should be used)", // Top-level takes priority
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [toolResultBlock],
|
||||
ts: 3,
|
||||
},
|
||||
{ role: "assistant", content: "Listed", ts: 4 },
|
||||
{ role: "user", content: "Done", ts: 5 },
|
||||
]
|
||||
|
||||
const result = getKeepMessagesWithToolBlocks(messages, 3)
|
||||
|
||||
expect(result.reasoningContentToPreserve).toBe("Top-level reasoning (should be used)")
|
||||
})
|
||||
|
||||
it("should not return reasoning_content when no tool_use blocks need preserving", () => {
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Hello", ts: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Thinking about it...",
|
||||
ts: 2,
|
||||
reasoning_content: "Some deep thoughts",
|
||||
},
|
||||
{ role: "user", content: "Continue", ts: 3 },
|
||||
{ role: "assistant", content: "Done", ts: 4 },
|
||||
{ role: "user", content: "Thanks", ts: 5 },
|
||||
]
|
||||
|
||||
const result = getKeepMessagesWithToolBlocks(messages, 3)
|
||||
|
||||
expect(result.toolUseBlocksToPreserve).toHaveLength(0)
|
||||
expect(result.reasoningContentToPreserve).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return correct actualStartIndex when extended backwards for turn alternation", () => {
|
||||
// This test validates the fix for DeepSeek 400 error when condensing creates consecutive assistant messages
|
||||
// The scenario: last N_MESSAGES_TO_KEEP would start with an assistant message
|
||||
// The fix: extend backwards to include the preceding user message
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Task", ts: 1 },
|
||||
{ role: "assistant", content: "Working on it", ts: 2 },
|
||||
{ role: "user", content: "Continue", ts: 3 },
|
||||
{ role: "assistant", content: "Still working", ts: 4 },
|
||||
{ role: "user", content: "More", ts: 5 },
|
||||
{ role: "assistant", content: "Almost done", ts: 6 }, // Without fix, this would be first kept message
|
||||
{ role: "user", content: "Thanks", ts: 7 },
|
||||
{ role: "assistant", content: "Complete", ts: 8 },
|
||||
]
|
||||
|
||||
const result = getKeepMessagesWithToolBlocks(messages, 3)
|
||||
|
||||
// With keepCount=3, original startIndex would be 5 (messages[5] = "Almost done", assistant)
|
||||
// The fix extends backwards to find a user message, so startIndex becomes 4 (messages[4] = "More", user)
|
||||
expect(result.keepMessages).toHaveLength(4) // Extended from 3 to 4
|
||||
expect(result.keepMessages[0].role).toBe("user")
|
||||
expect(result.keepMessages[0].content).toBe("More")
|
||||
expect(result.actualStartIndex).toBe(4) // Moved back from 5 to 4
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMessagesSinceLastSummary", () => {
|
||||
|
|
@ -989,6 +1160,79 @@ describe("summarizeConversation", () => {
|
|||
expect(preservedToolUses).toHaveLength(2)
|
||||
expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"])
|
||||
})
|
||||
|
||||
it("should preserve reasoning_content on summary message for DeepSeek interleaved thinking", async () => {
|
||||
const toolUseBlock = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_deepseek_reasoning",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
}
|
||||
const toolResultBlock = {
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: "toolu_deepseek_reasoning",
|
||||
content: "file contents",
|
||||
}
|
||||
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Hello", ts: 1 },
|
||||
{ role: "assistant", content: "Let me think about this", ts: 2 },
|
||||
{ role: "user", content: "Please continue", ts: 3 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
|
||||
ts: 4,
|
||||
reasoning_content: "I need to read this file to understand the user's request.", // DeepSeek reasoning
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
|
||||
ts: 5,
|
||||
},
|
||||
{ role: "assistant", content: "Got the file contents", ts: 6 },
|
||||
{ role: "user", content: "Thanks", ts: 7 },
|
||||
]
|
||||
|
||||
// Create a stream with usage information
|
||||
const streamWithUsage = (async function* () {
|
||||
yield { type: "text" as const, text: "Summary with reasoning preserved" }
|
||||
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
|
||||
})()
|
||||
|
||||
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
|
||||
mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any
|
||||
|
||||
const result = await summarizeConversation(
|
||||
messages,
|
||||
mockApiHandler,
|
||||
defaultSystemPrompt,
|
||||
taskId,
|
||||
DEFAULT_PREV_CONTEXT_TOKENS,
|
||||
false, // isAutomaticTrigger
|
||||
undefined, // customCondensingPrompt
|
||||
undefined, // condensingApiHandler
|
||||
true, // useNativeTools - required for tool_use block preservation
|
||||
)
|
||||
|
||||
// Find the summary message
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
expect(summaryMessage!.role).toBe("assistant")
|
||||
expect(summaryMessage!.isSummary).toBe(true)
|
||||
|
||||
// Verify reasoning_content is preserved on the summary message (critical for DeepSeek)
|
||||
expect(summaryMessage!.reasoning_content).toBe("I need to read this file to understand the user's request.")
|
||||
|
||||
// Also verify tool_use blocks are preserved
|
||||
expect(Array.isArray(summaryMessage!.content)).toBe(true)
|
||||
const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
|
||||
expect(content).toHaveLength(2)
|
||||
expect(content[0].type).toBe("text")
|
||||
expect(content[1].type).toBe("tool_use")
|
||||
expect((content[1] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_deepseek_reasoning")
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("summarizeConversation with custom settings", () => {
|
||||
|
|
|
|||
|
|
@ -31,24 +31,76 @@ function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[
|
|||
}
|
||||
|
||||
/**
|
||||
* Extracts tool_use blocks that need to be preserved to match tool_result blocks in keepMessages.
|
||||
* Extracts reasoning_content from an assistant message.
|
||||
* DeepSeek's interleaved thinking mode stores reasoning in two possible locations:
|
||||
* 1. Top-level `reasoning_content` property on the message
|
||||
* 2. Inside content array as `type: "reasoning"` blocks
|
||||
*
|
||||
* This is critical for DeepSeek's thinking mode with tool calls - if reasoning_content
|
||||
* is not passed back during multi-turn tool sequences, the API returns a 400 error.
|
||||
* See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
|
||||
*/
|
||||
function getReasoningContent(message: ApiMessage): string | undefined {
|
||||
if (message.role !== "assistant") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// First check for top-level reasoning_content property
|
||||
if (message.reasoning_content) {
|
||||
return message.reasoning_content
|
||||
}
|
||||
|
||||
// Then check for reasoning blocks in content
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if ((block as any).type === "reasoning" && (block as any).text) {
|
||||
return (block as any).text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool_use blocks and reasoning_content that need to be preserved to match tool_result blocks in keepMessages.
|
||||
* When the first kept message is a user message with tool_result blocks,
|
||||
* we need to find the corresponding tool_use blocks from the preceding assistant message.
|
||||
* These tool_use blocks will be appended to the summary message to maintain proper pairing.
|
||||
*
|
||||
* For DeepSeek's interleaved thinking mode, we also preserve reasoning_content from the
|
||||
* preceding assistant message. This is required because DeepSeek's API returns a 400 error
|
||||
* if reasoning_content is not passed back during multi-turn tool call sequences.
|
||||
* See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
|
||||
*
|
||||
* @param messages - The full conversation messages
|
||||
* @param keepCount - The number of messages to keep from the end
|
||||
* @returns Object containing keepMessages and any tool_use blocks to preserve
|
||||
* @returns Object containing keepMessages, tool_use blocks to preserve, and reasoning_content to preserve
|
||||
*/
|
||||
export function getKeepMessagesWithToolBlocks(
|
||||
messages: ApiMessage[],
|
||||
keepCount: number,
|
||||
): { keepMessages: ApiMessage[]; toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] } {
|
||||
): {
|
||||
keepMessages: ApiMessage[]
|
||||
toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[]
|
||||
reasoningContentToPreserve?: string
|
||||
actualStartIndex: number
|
||||
} {
|
||||
if (messages.length <= keepCount) {
|
||||
return { keepMessages: messages, toolUseBlocksToPreserve: [] }
|
||||
return { keepMessages: messages, toolUseBlocksToPreserve: [], actualStartIndex: 0 }
|
||||
}
|
||||
|
||||
let startIndex = messages.length - keepCount
|
||||
|
||||
// Ensure first kept message is a user message to maintain proper turn alternation.
|
||||
// This is critical for DeepSeek and other APIs that require alternating user/assistant turns.
|
||||
// Without this, inserting an assistant summary before an assistant message creates
|
||||
// consecutive assistant messages which causes API errors.
|
||||
// See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
|
||||
while (startIndex > 1 && messages[startIndex].role !== "user") {
|
||||
startIndex--
|
||||
}
|
||||
|
||||
const startIndex = messages.length - keepCount
|
||||
const keepMessages = messages.slice(startIndex)
|
||||
|
||||
// Check if the first kept message is a user message with tool_result blocks
|
||||
|
|
@ -59,13 +111,20 @@ export function getKeepMessagesWithToolBlocks(
|
|||
const precedingMessage = messages[precedingIndex]
|
||||
const toolUseBlocks = getToolUseBlocks(precedingMessage)
|
||||
if (toolUseBlocks.length > 0) {
|
||||
// Return the tool_use blocks to be merged into the summary message
|
||||
return { keepMessages, toolUseBlocksToPreserve: toolUseBlocks }
|
||||
// Also extract reasoning_content for DeepSeek interleaved thinking
|
||||
const reasoningContent = getReasoningContent(precedingMessage)
|
||||
// Return the tool_use blocks and reasoning_content to be merged into the summary message
|
||||
return {
|
||||
keepMessages,
|
||||
toolUseBlocksToPreserve: toolUseBlocks,
|
||||
reasoningContentToPreserve: reasoningContent,
|
||||
actualStartIndex: startIndex,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { keepMessages, toolUseBlocksToPreserve: [] }
|
||||
return { keepMessages, toolUseBlocksToPreserve: [], actualStartIndex: startIndex }
|
||||
}
|
||||
|
||||
export const N_MESSAGES_TO_KEEP = 3
|
||||
|
|
@ -168,13 +227,19 @@ export async function summarizeConversation(
|
|||
// Always preserve the first message (which may contain slash command content)
|
||||
const firstMessage = messages[0]
|
||||
|
||||
// Get keepMessages and any tool_use blocks that need to be preserved for tool_result pairing
|
||||
// Only preserve tool_use blocks when using native tools protocol (XML protocol doesn't need them)
|
||||
const { keepMessages, toolUseBlocksToPreserve } = useNativeTools
|
||||
? getKeepMessagesWithToolBlocks(messages, N_MESSAGES_TO_KEEP)
|
||||
: { keepMessages: messages.slice(-N_MESSAGES_TO_KEEP), toolUseBlocksToPreserve: [] }
|
||||
// Get keepMessages, tool_use blocks, and reasoning_content that need to be preserved.
|
||||
// getKeepMessagesWithToolBlocks handles:
|
||||
// 1. Turn alternation: Ensures first kept message is a user message (required by ALL providers)
|
||||
// 2. Tool_use block preservation: Only needed when using native tools protocol
|
||||
// 3. reasoning_content preservation: For DeepSeek interleaved thinking mode
|
||||
//
|
||||
// We ALWAYS call getKeepMessagesWithToolBlocks for proper turn alternation, regardless of useNativeTools.
|
||||
// When useNativeTools is false, tool_use blocks won't exist in messages, so toolUseBlocksToPreserve
|
||||
// will be empty, but we still get the critical turn alternation fix.
|
||||
const { keepMessages, toolUseBlocksToPreserve, reasoningContentToPreserve, actualStartIndex } =
|
||||
getKeepMessagesWithToolBlocks(messages, N_MESSAGES_TO_KEEP)
|
||||
|
||||
const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0)
|
||||
const keepStartIndex = actualStartIndex
|
||||
const includeFirstKeptMessageInSummary = toolUseBlocksToPreserve.length > 0
|
||||
const summarySliceEnd = includeFirstKeptMessageInSummary ? keepStartIndex + 1 : keepStartIndex
|
||||
const messagesBeforeKeep = summarySliceEnd > 0 ? messages.slice(0, summarySliceEnd) : []
|
||||
|
|
@ -281,6 +346,13 @@ export async function summarizeConversation(
|
|||
ts: firstKeptTs - 1, // Unique timestamp before first kept message to avoid collision
|
||||
isSummary: true,
|
||||
condenseId, // Unique ID for this summary, used to track which messages it replaces
|
||||
// Preserve reasoning_content for DeepSeek interleaved thinking mode.
|
||||
// This is critical: when condensing splits a tool call sequence, the summary
|
||||
// message replaces the assistant message that had tool_use blocks. If that
|
||||
// message also had reasoning_content, it must be preserved on the summary
|
||||
// to avoid DeepSeek API 400 errors during multi-turn tool sequences.
|
||||
// See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
|
||||
...(reasoningContentToPreserve && { reasoning_content: reasoningContentToPreserve }),
|
||||
}
|
||||
|
||||
// NON-DESTRUCTIVE CONDENSE:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export type ApiMessage = Anthropic.MessageParam & {
|
|||
text?: string
|
||||
// For OpenRouter reasoning_details array format (used by Gemini 3, etc.)
|
||||
reasoning_details?: any[]
|
||||
// For DeepSeek interleaved thinking mode: reasoning content that must be passed back
|
||||
// during tool call sequences. See: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
reasoning_content?: string
|
||||
// For non-destructive condense: unique identifier for summary messages
|
||||
condenseId?: string
|
||||
// For non-destructive condense: points to the condenseId of the summary that replaces this message
|
||||
|
|
|
|||
|
|
@ -4164,10 +4164,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
// Default path for regular messages (no embedded reasoning)
|
||||
if (msg.role) {
|
||||
cleanConversationHistory.push({
|
||||
const cleanMessage: Anthropic.Messages.MessageParam & { reasoning_content?: string } = {
|
||||
role: msg.role,
|
||||
content: msg.content as Anthropic.Messages.ContentBlockParam[] | string,
|
||||
})
|
||||
}
|
||||
// Preserve reasoning_content for DeepSeek interleaved thinking mode
|
||||
// This is required for tool call sequences where the model needs its previous
|
||||
// reasoning content passed back to continue the thinking chain
|
||||
// See: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
if (msg.role === "assistant" && msg.reasoning_content) {
|
||||
cleanMessage.reasoning_content = msg.reasoning_content
|
||||
}
|
||||
cleanConversationHistory.push(cleanMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue