feat: add automatic temperature reduction and retry for tool failures

- Add temperature error detection utility to identify temperature-related failures
- Add new "temperature_tool_error" ClineAsk type for UI interaction
- Modify writeToFileTool and applyDiffTool to detect temperature errors
- Add UI handler in ChatView to show temperature reduction option
- Implement retry mechanism that reduces temperature to 0.2 and retries
- Add comprehensive unit and integration tests

Fixes #6156
This commit is contained in:
Roo Code 2025-07-24 09:10:51 +00:00
parent 1e17b3b3bb
commit 17a378aff3
9 changed files with 733 additions and 8 deletions

View file

@ -24,6 +24,7 @@ import { z } from "zod"
* - `browser_action_launch`: Permission to open or interact with a browser
* - `use_mcp_server`: Permission to use Model Context Protocol (MCP) server functionality
* - `auto_approval_max_req_reached`: Auto-approval limit has been reached, manual approval required
* - `temperature_tool_error`: Tool failed due to high temperature setting, asking user to reduce temperature and retry
*/
export const clineAsks = [
"followup",
@ -38,6 +39,7 @@ export const clineAsks = [
"browser_action_launch",
"use_mcp_server",
"auto_approval_max_req_reached",
"temperature_tool_error",
] as const
export const clineAskSchema = z.enum(clineAsks)

View file

@ -12,6 +12,7 @@ import { formatResponse } from "../prompts/responses"
import { fileExistsAtPath } from "../../utils/fs"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { isTemperatureRelatedError, getTemperatureErrorMessage } from "./utils/temperatureErrorDetection"
export async function applyDiffToolLegacy(
cline: Task,
@ -133,6 +134,25 @@ export async function applyDiffToolLegacy(
await cline.say("diff_error", formattedError)
}
// Check if this is a temperature-related error
if (isTemperatureRelatedError("apply_diff", formattedError, cline)) {
const currentTemperature = cline.apiConfiguration?.modelTemperature ?? 0.0
const temperatureMessage = getTemperatureErrorMessage(currentTemperature)
// Ask user if they want to reduce temperature and retry
const askMessage = JSON.stringify({
tool: "apply_diff",
path: getReadablePath(cline.cwd, relPath),
error: formattedError,
temperatureMessage,
currentTemperature,
})
await cline.ask("temperature_tool_error", askMessage)
cline.recordToolError("apply_diff", formattedError)
return
}
cline.recordToolError("apply_diff", formattedError)
pushToolResult(formattedError)

View file

@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { isTemperatureRelatedError } from "../temperatureErrorDetection"
import type { Task } from "../../../task/Task"
import type { ProviderSettings } from "@roo-code/types"
describe("temperatureErrorDetection", () => {
let mockTask: Partial<Task>
let mockApiConfiguration: ProviderSettings
beforeEach(() => {
mockApiConfiguration = {
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
modelTemperature: 0.7,
} as ProviderSettings
mockTask = {
apiConfiguration: mockApiConfiguration,
}
})
describe("isTemperatureRelatedError", () => {
it("should return false for non-temperature related errors", () => {
const result = isTemperatureRelatedError("write_to_file", "Permission denied", mockTask as Task)
expect(result).toBe(false)
})
it("should return false when temperature is already low (0.2 or below)", () => {
mockApiConfiguration.modelTemperature = 0.2
const result = isTemperatureRelatedError(
"write_to_file",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(false)
})
it("should return false when temperature is undefined (using default)", () => {
mockApiConfiguration.modelTemperature = undefined
const result = isTemperatureRelatedError(
"write_to_file",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(false)
})
it('should return true for "content appears to be truncated" error with high temperature', () => {
const result = isTemperatureRelatedError(
"write_to_file",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(true)
})
it('should return true for "rest of code unchanged" error with high temperature', () => {
const result = isTemperatureRelatedError(
"apply_diff",
'Error: Found "// rest of code unchanged" in the content',
mockTask as Task,
)
expect(result).toBe(true)
})
it('should return true for "previous code" error with high temperature', () => {
const result = isTemperatureRelatedError(
"write_to_file",
'Error: Content contains "// ... previous code ..." placeholder',
mockTask as Task,
)
expect(result).toBe(true)
})
it('should return true for "existing code" error with high temperature', () => {
const result = isTemperatureRelatedError(
"apply_diff",
'Error: Found "// ... existing code ..." in the diff',
mockTask as Task,
)
expect(result).toBe(true)
})
it('should return true for "keep the rest" error with high temperature', () => {
const result = isTemperatureRelatedError(
"write_to_file",
'Error: Content includes "// keep the rest of the file" comment',
mockTask as Task,
)
expect(result).toBe(true)
})
it('should return true for "remaining code" error with high temperature', () => {
const result = isTemperatureRelatedError(
"apply_diff",
'Error: Found "// ... remaining code ..." in the content',
mockTask as Task,
)
expect(result).toBe(true)
})
it("should handle Error objects as well as strings", () => {
const error = new Error("content appears to be truncated")
const result = isTemperatureRelatedError("write_to_file", error, mockTask as Task)
expect(result).toBe(true)
})
it("should be case insensitive when checking error patterns", () => {
const result = isTemperatureRelatedError(
"write_to_file",
"ERROR: CONTENT APPEARS TO BE TRUNCATED",
mockTask as Task,
)
expect(result).toBe(true)
})
it("should return false when temperature is 0", () => {
mockApiConfiguration.modelTemperature = 0
const result = isTemperatureRelatedError(
"write_to_file",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(false)
})
it("should return true when temperature is exactly 0.3", () => {
mockApiConfiguration.modelTemperature = 0.3
const result = isTemperatureRelatedError(
"write_to_file",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(true)
})
it("should work with apply_diff tool", () => {
const result = isTemperatureRelatedError(
"apply_diff",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(true)
})
it("should return false for other tools even with temperature error patterns", () => {
const result = isTemperatureRelatedError(
"read_file",
"Error: content appears to be truncated",
mockTask as Task,
)
expect(result).toBe(false)
})
})
})

View file

@ -0,0 +1,93 @@
/**
* Utility functions for detecting temperature-related tool errors
*/
import { Task } from "../../task/Task"
/**
* Error patterns that indicate temperature-related issues
*/
const TEMPERATURE_ERROR_PATTERNS = [
// Direct truncation indicators
/content appears to be truncated/i,
/found comments indicating omitted code/i,
/rest of code unchanged/i,
/previous code/i,
/code omitted/i,
/truncated after \d+ lines/i,
/keep the rest/i,
// Common AI placeholder patterns when temperature is high
/\/\/\s*\.\.\./,
/\/\*\s*\.\.\.\s*\*\//,
/\[\s*\.\.\.\s*\]/,
/\{\s*\.\.\.\s*\}/,
// Incomplete content indicators
/incomplete file content/i,
/partial content/i,
/content was cut off/i,
]
/**
* Tool names that commonly experience temperature-related failures
*/
const TEMPERATURE_SENSITIVE_TOOLS = ["write_to_file", "apply_diff"]
/**
* Checks if an error is likely caused by high temperature settings
* @param toolName The name of the tool that failed
* @param error The error message or Error object
* @param task The current task instance to check temperature settings
* @returns True if the error appears to be temperature-related
*/
export function isTemperatureRelatedError(toolName: string, error: string | Error, task: Task): boolean {
// Only check for temperature errors on specific tools
if (!TEMPERATURE_SENSITIVE_TOOLS.includes(toolName)) {
return false
}
// Get current temperature from API configuration
const currentTemperature = task.apiConfiguration?.modelTemperature ?? 0.0
// Only consider it a temperature issue if temperature is above 0.2
if (currentTemperature <= 0.2) {
return false
}
// Check if the user has customized the temperature (not using default)
// Most providers default to 0.0 or 1.0, so anything else is likely custom
const isCustomTemperature = currentTemperature !== 0.0 && currentTemperature !== 1.0
if (!isCustomTemperature) {
return false
}
// Convert error to string for pattern matching
const errorMessage = typeof error === "string" ? error : error.message || ""
// Check if error matches any temperature-related patterns
return TEMPERATURE_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage))
}
/**
* Gets a user-friendly message explaining the temperature issue
* @param currentTemperature The current temperature setting
* @returns A message explaining the issue
*/
export function getTemperatureErrorMessage(currentTemperature: number): string {
return (
`It looks like the tool failed due to your current temperature setting (${currentTemperature.toFixed(1)}). ` +
`Higher temperature values can cause the AI to generate incomplete or malformed outputs. ` +
`Reducing the temperature to 0.2 often resolves these issues.`
)
}
/**
* Checks if the temperature can be reduced further
* @param currentTemperature The current temperature setting
* @returns True if temperature can be reduced to 0.2
*/
export function canReduceTemperature(currentTemperature: number): boolean {
return currentTemperature > 0.2
}

View file

@ -14,6 +14,7 @@ import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { detectCodeOmission } from "../../integrations/editor/detect-omission"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
import { isTemperatureRelatedError, getTemperatureErrorMessage } from "./utils/temperatureErrorDetection"
export async function writeToFileTool(
cline: Task,
@ -172,13 +173,30 @@ export async function writeToFileTool(
if (cline.diffStrategy) {
await cline.diffViewProvider.revertChanges()
pushToolResult(
formatResponse.toolError(
`Content appears to be truncated (file has ${
newContent.split("\n").length
} lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
),
)
const errorMessage = `Content appears to be truncated (file has ${
newContent.split("\n").length
} lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`
// Check if this is a temperature-related error
if (isTemperatureRelatedError("write_to_file", errorMessage, cline)) {
const currentTemperature = cline.apiConfiguration?.modelTemperature ?? 0.0
const temperatureMessage = getTemperatureErrorMessage(currentTemperature)
// Ask user if they want to reduce temperature and retry
const askMessage = JSON.stringify({
tool: "write_to_file",
path: getReadablePath(cline.cwd, relPath),
error: errorMessage,
temperatureMessage,
currentTemperature,
})
await cline.ask("temperature_tool_error", askMessage)
cline.recordToolError("write_to_file", errorMessage)
return
}
pushToolResult(formatResponse.toolError(errorMessage))
return
} else {
vscode.window

View file

@ -0,0 +1,359 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { ClineProvider } from "../ClineProvider"
import { webviewMessageHandler } from "../webviewMessageHandler"
import { Task } from "../../task/Task"
import { ProviderSettings } from "@roo-code/types"
import { ClineAskResponse, WebviewMessage } from "../../../shared/WebviewMessage"
import * as vscode from "vscode"
// Mock vscode
vi.mock("vscode", () => ({
window: {
showErrorMessage: vi.fn(),
showInformationMessage: vi.fn(),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn(),
update: vi.fn(),
}),
},
env: {
uriScheme: "vscode",
language: "en",
},
ExtensionContext: vi.fn(),
OutputChannel: vi.fn(),
Uri: {
parse: vi.fn(),
},
}))
// Mock other dependencies
vi.mock("../../api", () => ({
buildApiHandler: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue({ id: "test-model" }),
}),
}))
vi.mock("../../utils/path", () => ({
getWorkspacePath: vi.fn().mockReturnValue("/test/workspace"),
}))
vi.mock("../../i18n", () => ({
t: vi.fn((key: string) => key),
changeLanguage: vi.fn(),
}))
vi.mock("../../services/marketplace", () => ({
MarketplaceManager: vi.fn().mockImplementation(() => ({
getCurrentItems: vi.fn().mockResolvedValue([]),
getInstallationMetadata: vi.fn().mockResolvedValue({ project: {}, global: {} }),
})),
}))
describe("Temperature Error Retry Integration", () => {
let mockProvider: any
let mockTask: any
let mockProviderSettingsManager: any
let mockContextProxy: any
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Create mock task
mockTask = {
taskId: "test-task-123",
instanceId: "instance-123",
apiConfiguration: {
apiProvider: "anthropic",
modelTemperature: 0.7,
},
clineMessages: [],
apiConversationHistory: [],
overwriteClineMessages: vi.fn(),
overwriteApiConversationHistory: vi.fn(),
recursivelyMakeClineRequests: vi.fn(),
handleWebviewAskResponse: vi.fn(),
}
// Create mock provider settings manager
mockProviderSettingsManager = {
saveConfig: vi.fn().mockResolvedValue(undefined),
listConfig: vi.fn().mockResolvedValue([{ id: "config-1", name: "default", apiProvider: "anthropic" }]),
}
// Create mock context proxy
mockContextProxy = {
getValue: vi.fn((key: string) => {
const values: Record<string, any> = {
currentApiConfigName: "default",
listApiConfigMeta: [{ id: "config-1", name: "default", apiProvider: "anthropic" }],
}
return values[key]
}),
setValue: vi.fn(),
getValues: vi.fn().mockReturnValue({
currentApiConfigName: "default",
listApiConfigMeta: [{ id: "config-1", name: "default", apiProvider: "anthropic" }],
}),
}
// Create mock provider
mockProvider = {
getCurrentCline: vi.fn().mockReturnValue(mockTask),
getState: vi.fn().mockResolvedValue({
apiConfiguration: {
apiProvider: "anthropic",
modelTemperature: 0.7,
},
currentApiConfigName: "default",
}),
upsertProviderProfile: vi.fn().mockImplementation(async () => {
// Simulate the internal call to postStateToWebview
await mockProvider.postStateToWebview()
return "config-1"
}),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
log: vi.fn(),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should handle temperature error retry flow correctly", async () => {
// Setup: Add a temperature error message to the task
mockTask.clineMessages = [
{
ts: Date.now() - 2000,
type: "say",
say: "assistant",
text: "I'll help you with that.",
},
{
ts: Date.now() - 1000,
type: "ask",
ask: "temperature_tool_error",
text: "It looks like the tool failed due to your current temperature setting (0.7).",
},
]
// Setup: Add conversation history with user message
mockTask.apiConversationHistory = [
{
role: "user",
content: "Please write a function to calculate fibonacci numbers",
},
{
role: "assistant",
content: [
{
type: "text",
text: "I'll write a fibonacci function for you.",
},
{
type: "tool_use",
id: "tool-123",
name: "write_to_file",
input: {
path: "fibonacci.js",
content: "function fibonacci(n) {\n // ... rest of code unchanged\n}",
},
},
],
},
]
// Simulate user clicking "Reduce Temperature to 0.2 & Retry"
const message: WebviewMessage = {
type: "askResponse",
askResponse: "yesButtonClicked" as ClineAskResponse,
}
// Execute the handler
await webviewMessageHandler(mockProvider, message)
// Verify temperature was updated
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith(
"default",
expect.objectContaining({
apiProvider: "anthropic",
modelTemperature: 0.2,
}),
)
// Verify messages were removed
expect(mockTask.overwriteClineMessages).toHaveBeenCalledWith([
{
ts: expect.any(Number),
type: "say",
say: "assistant",
text: "I'll help you with that.",
},
])
// Verify API conversation history was trimmed
expect(mockTask.overwriteApiConversationHistory).toHaveBeenCalledWith([
{
role: "user",
content: "Please write a function to calculate fibonacci numbers",
},
])
// Verify the request was retried
expect(mockTask.recursivelyMakeClineRequests).toHaveBeenCalledWith([
{
type: "text",
text: "Please write a function to calculate fibonacci numbers",
},
])
// Verify state was posted to webview
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})
it("should handle cancel button click without making changes", async () => {
// Setup: Add a temperature error message
mockTask.clineMessages = [
{
ts: Date.now(),
type: "ask",
ask: "temperature_tool_error",
text: "Temperature error detected",
},
]
// Simulate user clicking cancel
const message: WebviewMessage = {
type: "askResponse",
askResponse: "noButtonClicked" as ClineAskResponse,
}
// Execute the handler
await webviewMessageHandler(mockProvider, message)
// Verify no temperature update was made
expect(mockProvider.upsertProviderProfile).not.toHaveBeenCalled()
// Verify no messages were removed
expect(mockTask.overwriteClineMessages).not.toHaveBeenCalled()
expect(mockTask.overwriteApiConversationHistory).not.toHaveBeenCalled()
// Verify no retry was attempted
expect(mockTask.recursivelyMakeClineRequests).not.toHaveBeenCalled()
})
it("should handle complex content blocks correctly", async () => {
// Setup: Add conversation history with complex content blocks
mockTask.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text",
text: "Here's an image to analyze:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "base64data...",
},
},
{
type: "text",
text: "Please describe what you see.",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-456",
name: "write_to_file",
input: {
path: "description.txt",
content: "The image shows... [rest of content]",
},
},
],
},
]
mockTask.clineMessages = [
{
ts: Date.now(),
type: "ask",
ask: "temperature_tool_error",
text: "Temperature error",
},
]
// Simulate retry
const message: WebviewMessage = {
type: "askResponse",
askResponse: "yesButtonClicked" as ClineAskResponse,
}
await webviewMessageHandler(mockProvider, message)
// Verify the content blocks were properly converted
expect(mockTask.recursivelyMakeClineRequests).toHaveBeenCalledWith([
{
type: "text",
text: "Here's an image to analyze:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "base64data...",
},
},
{
type: "text",
text: "Please describe what you see.",
},
])
})
it("should not trigger temperature retry for non-temperature errors", async () => {
// Setup: Add a different type of error message
mockTask.clineMessages = [
{
ts: Date.now(),
type: "ask",
ask: "tool",
text: "Regular tool error",
},
]
// Simulate user response
const message: WebviewMessage = {
type: "askResponse",
askResponse: "yesButtonClicked" as ClineAskResponse,
}
// Mock the regular ask response handler
mockTask.handleWebviewAskResponse = vi.fn()
mockProvider.getCurrentCline.mockReturnValue(mockTask)
await webviewMessageHandler(mockProvider, message)
// Verify temperature update was NOT called
expect(mockProvider.upsertProviderProfile).not.toHaveBeenCalled()
// Verify regular handler was called instead
expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("yesButtonClicked", undefined, undefined)
})
})

View file

@ -341,6 +341,61 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
break
case "askResponse":
// Handle temperature error retry with temperature reduction
if (message.askResponse === "yesButtonClicked") {
const currentCline = provider.getCurrentCline()
if (currentCline) {
const lastMessage = currentCline.clineMessages.at(-1)
if (lastMessage?.ask === "temperature_tool_error") {
// User clicked "Reduce Temperature to 0.2 & Retry"
// 1. Update temperature to 0.2 for current API profile
const { apiConfiguration, currentApiConfigName } = await provider.getState()
if (apiConfiguration) {
const updatedConfig = {
...apiConfiguration,
modelTemperature: 0.2,
}
// Update the current configuration using the provider's method
await provider.upsertProviderProfile(currentApiConfigName, updatedConfig)
}
// 2. Remove the temperature error message and the failed tool message before it
const messageIndex = currentCline.clineMessages.length - 1
const apiConversationHistoryIndex = currentCline.apiConversationHistory.length - 1
// Remove the temperature_tool_error ask message
await currentCline.overwriteClineMessages(currentCline.clineMessages.slice(0, messageIndex))
// Remove the last assistant message from API history (the one with the failed tool)
if (apiConversationHistoryIndex >= 0) {
await currentCline.overwriteApiConversationHistory(
currentCline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
)
}
// 3. Re-send the last user message to retry with lower temperature
const lastUserMessage = currentCline.apiConversationHistory
.slice()
.reverse()
.find((msg) => msg.role === "user")
if (lastUserMessage && lastUserMessage.content) {
// Convert content to ContentBlockParam[] format
const contentBlocks =
typeof lastUserMessage.content === "string"
? [{ type: "text" as const, text: lastUserMessage.content }]
: lastUserMessage.content
// Re-initiate the task loop with the last user content
await currentCline.recursivelyMakeClineRequests(contentBlocks)
}
break
}
}
}
provider.getCurrentCline()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
case "autoCondenseContext":

View file

@ -384,6 +384,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSecondaryButtonText(undefined)
setDidClickCancel(false)
break
case "temperature_tool_error":
if (!isPartial) {
playSound("notification")
}
setSendingDisabled(isPartial)
setClineAsk("temperature_tool_error")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:temperatureError.reduceAndRetry"))
setSecondaryButtonText(t("chat:cancel.title"))
break
}
break
case "say":
@ -614,6 +624,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "use_mcp_server":
case "resume_task":
case "mistake_limit_reached":
case "temperature_tool_error":
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
vscode.postMessage({
@ -669,6 +680,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "tool":
case "browser_action_launch":
case "use_mcp_server":
case "temperature_tool_error":
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
vscode.postMessage({
@ -1796,7 +1808,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
: primaryButtonText ===
t(
"chat:temperatureError.reduceAndRetry",
)
? t(
"chat:temperatureError.reduceAndRetryTooltip",
)
: undefined
}>
<VSCodeButton
appearance="primary"

View file

@ -29,6 +29,10 @@
"title": "Retry",
"tooltip": "Try the operation again"
},
"temperatureError": {
"reduceAndRetry": "Reduce Temperature to 0.2 & Retry",
"reduceAndRetryTooltip": "Lower the temperature setting to 0.2 and retry the operation"
},
"startNewTask": {
"title": "Start New Task",
"tooltip": "Begin a new task"