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:
Hannes Rudolph 2025-12-19 17:52:34 -07:00
parent f7adc4b1cf
commit 5028d0ca79
4 changed files with 347 additions and 20 deletions

View file

@ -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", () => {

View file

@ -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:

View file

@ -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

View file

@ -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)
}
}