diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index fe17b09ee37..782cdc6b0c1 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -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", () => { diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index b8af4d1de24..e99422ac91c 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -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: diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 9263115c60e..c46174e4cd5 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -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 diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 92ea6aa957e..ce0fb394358 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4164,10 +4164,18 @@ export class Task extends EventEmitter 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) } }