diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index dc0e3dfff5..b995d646a3 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -170,6 +170,7 @@ export const clineSays = [ "codebase_search_result", "user_edit_todos", "too_many_tools_warning", + "mode_switch_compatibility_warning", "tool", ] as const diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index ef7839ad34..bca6d344f1 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,6 +1,8 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import { logger } from "../../utils/logging" + import { deepSeekModels, deepSeekDefaultModelId, @@ -14,6 +16,8 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { convertToR1Format } from "../transform/r1-format" +import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard" +import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard" import { OpenAiHandler } from "./openai" import { extractReasoningFromDelta } from "./utils/extract-reasoning" @@ -96,12 +100,6 @@ export class DeepSeekHandler extends OpenAiHandler { const { info: modelInfo, temperature, reasoningEffort, maxTokens } = this.getModel() const isThinkingModel = isDeepSeekThinkingEnabled(modelId, this.options) - const thinking = supportsDeepSeekThinkingToggle(modelId) - ? ({ type: isThinkingModel ? "enabled" : "disabled" } as const) - : isThinkingModel - ? ({ type: "enabled" } as const) - : undefined - const deepSeekReasoningEffort = isThinkingModel ? normalizeDeepSeekReasoningEffort(reasoningEffort) : undefined // Convert messages to R1 format (merges consecutive same-role messages) // This is required for DeepSeek which does not support successive messages with the same role @@ -113,9 +111,30 @@ export class DeepSeekHandler extends OpenAiHandler { mergeToolResultText: isThinkingModel, }) + // Layer 1 Guard: detect incompatible history (tool_calls without reasoning_content) + // and disable thinking mode for this request to prevent a 400 error from the API. + const hasIncompatibleHistory = historyHasToolCallsWithoutReasoning(convertedMessages) + const effectiveThinkingEnabled = isThinkingModel && !hasIncompatibleHistory + + if (hasIncompatibleHistory) { + logger.warn("provider_reasoning_guard_triggered", { + ctx: "deepseek", + provider: "deepseek", + modelId, + taskId: metadata?.taskId, + }) + } + + const thinking = supportsDeepSeekThinkingToggle(modelId) + ? ({ type: effectiveThinkingEnabled ? "enabled" : "disabled" } as const) + : effectiveThinkingEnabled + ? ({ type: "enabled" } as const) + : undefined + const deepSeekReasoningEffort = effectiveThinkingEnabled ? normalizeDeepSeekReasoningEffort(reasoningEffort) : undefined + const requestOptions: DeepSeekChatCompletionParams = { model: modelId, - ...(!isThinkingModel && { temperature: temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE }), + ...(!effectiveThinkingEnabled && { temperature: temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE }), messages: convertedMessages, stream: true as const, stream_options: { include_usage: true }, diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 2901c2e926..caf4ccbf2f 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -14,6 +14,8 @@ import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { OpenAiHandler } from "./openai" import type { ApiHandlerCreateMessageMetadata } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" +import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard" +import { logger } from "../../utils/logging" /** * MiMoHandler extends OpenAiHandler with MiMo-specific adaptations. @@ -81,6 +83,20 @@ export class MimoHandler extends OpenAiHandler { const tools = metadata?.tools + // Layer 1 Guard: detect incompatible history (tool_calls without reasoning_content) + // and disable thinking mode for this request to prevent a 400 error from the API. + // MiMo previously had NO disable path — this closes that gap. + const hasIncompatibleHistory = historyHasToolCallsWithoutReasoning(convertedMessages) + + if (hasIncompatibleHistory) { + logger.warn("provider_reasoning_guard_triggered", { + ctx: "mimo", + provider: "mimo", + modelId, + taskId: metadata?.taskId, + }) + } + // Build request per MiMo's OpenAI-compatible API // https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ // Note: temperature is omitted because MiMo forces it to 1.0 when thinking mode @@ -91,7 +107,7 @@ export class MimoHandler extends OpenAiHandler { stream: true, stream_options: { include_usage: true }, // MiMo requires thinking to be enabled via extra_body - extra_body: { thinking: { type: "enabled" } }, + extra_body: { thinking: { type: hasIncompatibleHistory ? "disabled" : "enabled" } }, } if (tools && tools.length > 0) { diff --git a/src/api/providers/utils/__tests__/reasoning-history-guard.spec.ts b/src/api/providers/utils/__tests__/reasoning-history-guard.spec.ts new file mode 100644 index 0000000000..3601bd7c41 --- /dev/null +++ b/src/api/providers/utils/__tests__/reasoning-history-guard.spec.ts @@ -0,0 +1,107 @@ +// npx vitest run api/providers/utils/__tests__/reasoning-history-guard.spec.ts + +import { historyHasToolCallsWithoutReasoning } from "../reasoning-history-guard" + +describe("historyHasToolCallsWithoutReasoning", () => { + it("returns false for empty messages", () => { + expect(historyHasToolCallsWithoutReasoning([])).toBe(false) + }) + + it("returns false when no assistant messages have tool_calls", () => { + const messages = [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi there" }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false) + }) + + it("returns false when assistant messages have tool_calls with reasoning_content", () => { + const messages = [ + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", function: { name: "test" } }], + reasoning_content: "I should call test because...", + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false) + }) + + it("returns true when assistant messages have tool_calls but no reasoning_content field", () => { + const messages = [ + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", function: { name: "test" } }], + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(true) + }) + + it("returns true when assistant messages have tool_calls with empty reasoning_content", () => { + const messages = [ + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", function: { name: "test" } }], + reasoning_content: "", + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(true) + }) + + it("returns true when assistant messages have empty tool_calls array", () => { + const messages = [ + { + role: "assistant", + content: "hello", + tool_calls: [], + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false) + }) + + it("returns false for non-assistant messages with tool_calls", () => { + const messages = [ + { + role: "user", + content: "hello", + tool_calls: [{ id: "call_1" }], + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false) + }) + + it("returns true when at least one assistant message is missing reasoning_content despite tool_calls", () => { + const messages = [ + { + role: "assistant", + content: "Let me think...", + reasoning_content: "thinking step 1", + }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", function: { name: "read_file" } }], + // no reasoning_content — this is the problematic message + }, + { + role: "tool", + content: "file content", + tool_call_id: "call_1", + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(true) + }) + + it("handles non-array tool_calls gracefully", () => { + const messages = [ + { + role: "assistant", + content: null, + tool_calls: "not-an-array" as unknown as unknown[], + }, + ] + expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false) + }) +}) diff --git a/src/api/providers/utils/reasoning-history-guard.ts b/src/api/providers/utils/reasoning-history-guard.ts new file mode 100644 index 0000000000..e44f3a1081 --- /dev/null +++ b/src/api/providers/utils/reasoning-history-guard.ts @@ -0,0 +1,26 @@ +/** + * Detects whether a converted OpenAI-format message history contains any + * assistant message with `tool_calls` but no non-empty `reasoning_content`. + * Used as a guard before enabling strict provider "thinking" modes that + * require reasoning_content to accompany every tool-call turn. + * + * This function operates on messages *after* conversion to the provider's + * OpenAI-compatible format (e.g. `convertToR1Format`, `convertToZAiFormat`), + * because it is only in the converted format that `reasoning_content` + * presence can be reliably determined. + * + * @param messages - Array of converted messages in OpenAI-compatible format + * @returns `true` if any assistant message has tool_calls but lacks + * non-empty reasoning_content + */ +export function historyHasToolCallsWithoutReasoning( + messages: Array<{ role?: string; tool_calls?: unknown[]; reasoning_content?: unknown }>, +): boolean { + return messages.some( + (m) => + m.role === "assistant" && + Array.isArray(m.tool_calls) && + m.tool_calls.length > 0 && + (typeof m.reasoning_content !== "string" || m.reasoning_content.length === 0), + ) +} diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index 4854c814fd..910557fd1d 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -13,10 +13,12 @@ import { import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api" import { convertToZAiFormat } from "../transform/zai-format" +import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard" import type { ApiHandlerCreateMessageMetadata } from "../index" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" import { handleOpenAIError } from "./utils/error-handler" +import { logger } from "../../utils/logging" // Custom interface for Z.ai params to support thinking mode and reasoning effort tiers. // Z.ai accepts the standard `reasoning_effort` ladder (none/minimal/low/medium/high/xhigh/max) @@ -107,6 +109,22 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { // Use Z.ai format to preserve reasoning_content and merge post-tool text into tool messages const convertedMessages = convertToZAiFormat(messages, { mergeToolResultText: true }) + // Layer 1 Guard: detect incompatible history (tool_calls without reasoning_content) + // and disable thinking mode for this request to prevent a 400 error from the API. + const hasIncompatibleHistory = historyHasToolCallsWithoutReasoning(convertedMessages) + + if (hasIncompatibleHistory) { + logger.warn("provider_reasoning_guard_triggered", { + ctx: "zai", + provider: "zai", + model, + taskId: metadata?.taskId, + }) + } + + // When incompatible history is detected, force reasoning off regardless of user settings. + const effectiveReasoning = hasIncompatibleHistory ? false : useReasoning + const params: ZAiChatCompletionParams = { model, max_tokens, @@ -115,8 +133,8 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { stream: true, stream_options: { include_usage: true }, // Thinking is ON by default for these models, so explicitly disable it when needed. - thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, - reasoning_effort: reasoningEffort, + thinking: effectiveReasoning ? { type: "enabled" } : { type: "disabled" }, + reasoning_effort: effectiveReasoning ? reasoningEffort : undefined, tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts index a60ce63bde..ebeeda45e6 100644 --- a/src/core/tools/SwitchModeTool.ts +++ b/src/core/tools/SwitchModeTool.ts @@ -56,7 +56,8 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { } // Switch the mode using shared handler - await task.providerRef.deref()?.handleModeSwitch(mode_slug) + // via: "switch_mode" — explicit tool call + await task.providerRef.deref()?.handleModeSwitch(mode_slug, "switch_mode") pushToolResult( `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 613a5c1c1d..d00c62e915 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -95,6 +95,8 @@ import { t } from "../../i18n" import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" +import { logger } from "../../utils/logging" +import { modeSwitchRisksReasoningIncompatibility } from "../../shared/reasoning-mode-compatibility" import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" @@ -1481,14 +1483,68 @@ export class ClineProvider /** * Handle switching to a new mode, including updating the associated API configuration * @param newMode The mode to switch to + * @param via - The origin of the mode switch: + * "switch_mode" — explicit switch_mode tool call + * "new_task_delegation" — delegation via new_task + * "unknown_bypass" — cannot be attributed to explicit tool call (default) */ - public async handleModeSwitch(newMode: Mode) { + public async handleModeSwitch(newMode: Mode, via: "switch_mode" | "new_task_delegation" | "unknown_bypass" = "unknown_bypass") { const task = this.getCurrentTask() + const fromMode = (await this.getState())?.mode ?? "unknown" if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Layer 2 — Orchestration safety: check for reasoning-mode incompatibility + // when switching modes without proper delegation (only relevant for bypass switches). + if (via !== "new_task_delegation") { + try { + const fromProviderName = task.apiConfiguration?.apiProvider + const toConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const listApiConfig = await this.providerSettingsManager.listConfig() + const toProfile = toConfigId + ? listApiConfig.find((c) => c.id === toConfigId) + : undefined + const toProviderName = toProfile + ? (await this.providerSettingsManager.getProfile({ name: toProfile.name })).apiProvider + : fromProviderName + + if ( + toProviderName && + modeSwitchRisksReasoningIncompatibility(fromProviderName, toProviderName) + ) { + // Behavior A — always show visible warning in chat + await task.say( + "mode_switch_compatibility_warning", + JSON.stringify({ + fromMode, + toMode: newMode, + fromProvider: fromProviderName, + toProvider: toProviderName, + via, + }), + undefined, + undefined, + undefined, + undefined, + { isNonInteractive: true }, + ) + + // Behavior B — optional auto-condense when setting is enabled + const autoCondense = this.context.workspaceState.get("autoCondenseOnRiskyModeSwitch", false) + if (autoCondense) { + await task.condenseContext() + } + } + } catch (innerError) { + // Non-fatal: log but don't block the mode switch if the check fails. + this.log( + \`Mode-switch compatibility check failed: ${innerError instanceof Error ? innerError.message : String(innerError)}\`, + ) + } + } + try { // Update the task history with the new mode first. const taskHistoryItem = diff --git a/src/shared/__tests__/reasoning-mode-compatibility.spec.ts b/src/shared/__tests__/reasoning-mode-compatibility.spec.ts new file mode 100644 index 0000000000..cea46cbd9f --- /dev/null +++ b/src/shared/__tests__/reasoning-mode-compatibility.spec.ts @@ -0,0 +1,71 @@ +// npx vitest run shared/__tests__/reasoning-mode-compatibility.spec.ts + +import { isStrictReasoningModeProvider, modeSwitchRisksReasoningIncompatibility } from "../reasoning-mode-compatibility" + +describe("isStrictReasoningModeProvider", () => { + it("returns true for deepseek", () => { + expect(isStrictReasoningModeProvider("deepseek")).toBe(true) + }) + + it("returns true for zai", () => { + expect(isStrictReasoningModeProvider("zai")).toBe(true) + }) + + it("returns true for mimo", () => { + expect(isStrictReasoningModeProvider("mimo")).toBe(true) + }) + + it("returns false for anthropic", () => { + expect(isStrictReasoningModeProvider("anthropic")).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isStrictReasoningModeProvider(undefined)).toBe(false) + }) + + it("returns false for a non-strict provider", () => { + expect(isStrictReasoningModeProvider("openai-native")).toBe(false) + }) + + it("returns false for gemini", () => { + expect(isStrictReasoningModeProvider("gemini")).toBe(false) + }) +}) + +describe("modeSwitchRisksReasoningIncompatibility", () => { + it("returns false when from and to are the same provider", () => { + expect(modeSwitchRisksReasoningIncompatibility("deepseek", "deepseek")).toBe(false) + expect(modeSwitchRisksReasoningIncompatibility("anthropic", "anthropic")).toBe(false) + expect(modeSwitchRisksReasoningIncompatibility("zai", "zai")).toBe(false) + }) + + it("returns true when switching from non-strict to strict provider", () => { + expect(modeSwitchRisksReasoningIncompatibility("anthropic", "deepseek")).toBe(true) + expect(modeSwitchRisksReasoningIncompatibility("openai-native", "zai")).toBe(true) + expect(modeSwitchRisksReasoningIncompatibility("gemini", "mimo")).toBe(true) + }) + + it("returns false when switching from strict to non-strict provider", () => { + expect(modeSwitchRisksReasoningIncompatibility("deepseek", "anthropic")).toBe(false) + expect(modeSwitchRisksReasoningIncompatibility("zai", "openai-native")).toBe(false) + expect(modeSwitchRisksReasoningIncompatibility("mimo", "gemini")).toBe(false) + }) + + it("returns false when switching between two strict providers", () => { + expect(modeSwitchRisksReasoningIncompatibility("deepseek", "zai")).toBe(false) + expect(modeSwitchRisksReasoningIncompatibility("zai", "mimo")).toBe(false) + expect(modeSwitchRisksReasoningIncompatibility("mimo", "deepseek")).toBe(false) + }) + + it("returns true when going from undefined (unknown) provider to a strict provider (fail-safe)", () => { + expect(modeSwitchRisksReasoningIncompatibility(undefined, "deepseek")).toBe(true) + }) + + it("returns false when going from strict to undefined", () => { + expect(modeSwitchRisksReasoningIncompatibility("deepseek", undefined)).toBe(false) + }) + + it("returns false when both are undefined", () => { + expect(modeSwitchRisksReasoningIncompatibility(undefined, undefined)).toBe(false) + }) +}) diff --git a/src/shared/reasoning-mode-compatibility.ts b/src/shared/reasoning-mode-compatibility.ts new file mode 100644 index 0000000000..8b8ec11b13 --- /dev/null +++ b/src/shared/reasoning-mode-compatibility.ts @@ -0,0 +1,37 @@ +/** + * Providers/models known to require reasoning_content on every tool-call + * turn once their "thinking" mode is active. Mirrors preserveReasoning + * in @roo-code/types model definitions — kept here as an explicit allowlist + * so orchestration-level checks don't need to resolve full ModelInfo. + * + * Must be kept in sync with providers that have at least one model with + * `preserveReasoning: true` and are covered by the Layer 1 guard in + * `src/api/providers/utils/reasoning-history-guard.ts`. + */ +const STRICT_REASONING_MODE_PROVIDERS = new Set(["deepseek", "zai", "mimo"]) + +/** + * Returns `true` if the given provider name is one of the known strict- + * reasoning providers that enforce `reasoning_content` on tool-call turns. + */ +export function isStrictReasoningModeProvider(provider: string | undefined): boolean { + return !!provider && STRICT_REASONING_MODE_PROVIDERS.has(provider) +} + +/** + * True when switching from `fromProvider` to `toProvider` risks carrying + * over a conversation history that is incompatible with the target + * provider's strict reasoning-mode formatting requirements — i.e. the + * target enforces reasoning_content on tool-call turns but the source + * provider's history was never built with that field. + * + * Returns `false` when switching within the same provider family (no + * format mismatch) or when neither provider is strict. + */ +export function modeSwitchRisksReasoningIncompatibility( + fromProvider: string | undefined, + toProvider: string | undefined, +): boolean { + if (fromProvider === toProvider) return false + return isStrictReasoningModeProvider(toProvider) && !isStrictReasoningModeProvider(fromProvider) +} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f973c7929f..256a158b42 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1577,6 +1577,26 @@ export const ChatRowContent = ({ /> ) } + case "mode_switch_compatibility_warning": { + const warningData = safeJsonParse<{ + fromMode?: string + toMode?: string + fromProvider?: string + toProvider?: string + via?: string + }>(message.text || "{}") + if (!warningData) return null + return ( + + ) + } default: return ( <> diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 00438c3bfc..d10a38d640 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -394,6 +394,10 @@ } }, "profileViolationWarning": "The current profile isn't compatible with your organization's settings", + "modeSwitchCompatibilityWarning": { + "title": "Mode switch without formal delegation", + "message": "\u26a0\ufe0f Mode switched from {{fromMode}} to {{toMode}} without task delegation \u2014 conversation history may be incompatible with the new model ({{toProvider}})." + }, "shellIntegration": { "title": "Command Execution Warning", "description": "Your command is being executed without VSCode terminal shell integration. To suppress this warning you can disable shell integration in the Terminal section of the Zoo Code settings or troubleshoot VSCode terminal integration using the link below.",