Skip to content

Commit 9fe4dd2

Browse files
committed
fix: prevent extension freeze when model calls unknown native tools
When using native tool protocol, if a model attempts to call a tool that doesn't exist (like 'edit_file'), the extension would freeze in a loop instead of returning an error to the model. Root causes: 1. During streaming, partial blocks of unknown tools would reach the default case in the switch statement, showing an error on every streaming chunk 2. validateToolUse was being called for partial blocks, causing repeated validation errors Changes: - Add isValidToolName() function to validateToolUse.ts to explicitly check for unknown tools - Only run tool validation for complete (non-partial) blocks in presentAssistantMessage - Skip partial blocks in the default case to prevent error loops during streaming - Fetch state early in tool_use case so mode/customModes are available throughout This ensures that when an invalid tool is called, the extension waits for the complete tool call before showing a single error and sending a tool_result back to the model.
1 parent 244ab1b commit 9fe4dd2

File tree

3 files changed

+106
-28
lines changed

3 files changed

+106
-28
lines changed

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,11 @@ export async function presentAssistantMessage(cline: Task) {
333333
await cline.say("text", content, undefined, block.partial)
334334
break
335335
}
336-
case "tool_use":
336+
case "tool_use": {
337+
// Fetch state early so it's available for toolDescription and validation
338+
const state = await cline.providerRef.deref()?.getState()
339+
const { mode, customModes, experiments: stateExperiments, apiConfiguration } = state ?? {}
340+
337341
const toolDescription = (): string => {
338342
switch (block.name) {
339343
case "execute_command":
@@ -675,30 +679,46 @@ export async function presentAssistantMessage(cline: Task) {
675679
TelemetryService.instance.captureToolUsage(cline.taskId, block.name, toolProtocol)
676680
}
677681

678-
// Validate tool use before execution.
679-
const {
680-
mode,
681-
customModes,
682-
experiments: stateExperiments,
683-
apiConfiguration,
684-
} = (await cline.providerRef.deref()?.getState()) ?? {}
685-
const modelInfo = cline.api.getModel()
686-
const includedTools = modelInfo?.info?.includedTools
687-
688-
try {
689-
validateToolUse(
690-
block.name as ToolName,
691-
mode ?? defaultModeSlug,
692-
customModes ?? [],
693-
{ apply_diff: cline.diffEnabled },
694-
block.params,
695-
stateExperiments,
696-
includedTools,
697-
)
698-
} catch (error) {
699-
cline.consecutiveMistakeCount++
700-
pushToolResult(formatResponse.toolError(error.message, toolProtocol))
701-
break
682+
// Validate tool use before execution - ONLY for complete (non-partial) blocks.
683+
// Validating partial blocks would cause validation errors to be thrown repeatedly
684+
// during streaming, pushing multiple tool_results for the same tool_use_id and
685+
// potentially causing the stream to appear frozen.
686+
if (!block.partial) {
687+
const modelInfo = cline.api.getModel()
688+
const includedTools = modelInfo?.info?.includedTools
689+
690+
try {
691+
validateToolUse(
692+
block.name as ToolName,
693+
mode ?? defaultModeSlug,
694+
customModes ?? [],
695+
{ apply_diff: cline.diffEnabled },
696+
block.params,
697+
stateExperiments,
698+
includedTools,
699+
)
700+
} catch (error) {
701+
cline.consecutiveMistakeCount++
702+
// For validation errors (unknown tool, tool not allowed for mode), we need to:
703+
// 1. Send a tool_result with the error (required for native protocol)
704+
// 2. NOT set didAlreadyUseTool = true (the tool was never executed, just failed validation)
705+
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
706+
// which would cause the extension to appear to hang
707+
const errorContent = formatResponse.toolError(error.message, toolProtocol)
708+
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
709+
// For native protocol, push tool_result directly without setting didAlreadyUseTool
710+
cline.userMessageContent.push({
711+
type: "tool_result",
712+
tool_use_id: toolCallId,
713+
content: typeof errorContent === "string" ? errorContent : "(validation error)",
714+
is_error: true,
715+
} as Anthropic.ToolResultBlockParam)
716+
} else {
717+
// For XML protocol, use the standard pushToolResult
718+
pushToolResult(errorContent)
719+
}
720+
break
721+
}
702722
}
703723

704724
// Check for identical consecutive tool calls.
@@ -998,16 +1018,37 @@ export async function presentAssistantMessage(cline: Task) {
9981018
default: {
9991019
// Handle unknown/invalid tool names
10001020
// This is critical for native protocol where every tool_use MUST have a tool_result
1021+
// Note: This case should rarely be reached since validateToolUse now checks for unknown tools
1022+
1023+
// CRITICAL: Don't process partial blocks for unknown tools - just let them stream in.
1024+
// If we try to show errors for partial blocks, we'd show the error on every streaming chunk,
1025+
// creating a loop that appears to freeze the extension. Only handle complete blocks.
1026+
if (block.partial) {
1027+
break
1028+
}
1029+
10011030
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
10021031
cline.consecutiveMistakeCount++
10031032
cline.recordToolError(block.name as ToolName, errorMessage)
10041033
await cline.say("error", `Roo tried to use an unknown tool: "${block.name}". Retrying...`)
1005-
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
1034+
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
1035+
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
1036+
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
1037+
cline.userMessageContent.push({
1038+
type: "tool_result",
1039+
tool_use_id: toolCallId,
1040+
content: formatResponse.toolError(errorMessage, toolProtocol),
1041+
is_error: true,
1042+
} as Anthropic.ToolResultBlockParam)
1043+
} else {
1044+
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
1045+
}
10061046
break
10071047
}
10081048
}
10091049

10101050
break
1051+
}
10111052
}
10121053

10131054
// Seeing out of bounds is fine, it means that the next too call is being

src/core/tools/__tests__/validateToolUse.spec.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,17 @@ describe("mode-validator", () => {
167167
})
168168

169169
describe("validateToolUse", () => {
170-
it("throws error for disallowed tools in architect mode", () => {
170+
it("throws error for unknown/invalid tools", () => {
171+
// Unknown tools should throw with a specific "Unknown tool" error
171172
expect(() => validateToolUse("unknown_tool" as any, "architect", [])).toThrow(
172-
'Tool "unknown_tool" is not allowed in architect mode.',
173+
'Unknown tool "unknown_tool". This tool does not exist.',
174+
)
175+
})
176+
177+
it("throws error for disallowed tools in architect mode", () => {
178+
// execute_command is a valid tool but not allowed in architect mode
179+
expect(() => validateToolUse("execute_command", "architect", [])).toThrow(
180+
'Tool "execute_command" is not allowed in architect mode.',
173181
)
174182
})
175183

src/core/tools/validateToolUse.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
11
import type { ToolName, ModeConfig } from "@roo-code/types"
2+
import { toolNames as validToolNames } from "@roo-code/types"
23

34
import { Mode, isToolAllowedForMode } from "../../shared/modes"
45

6+
/**
7+
* Checks if a tool name is a valid, known tool.
8+
* Note: This does NOT check if the tool is allowed for a specific mode,
9+
* only that the tool actually exists.
10+
*/
11+
export function isValidToolName(toolName: string): toolName is ToolName {
12+
// Check if it's a valid static tool
13+
if ((validToolNames as readonly string[]).includes(toolName)) {
14+
return true
15+
}
16+
17+
// Check if it's a dynamic MCP tool (mcp_serverName_toolName format)
18+
if (toolName.startsWith("mcp_")) {
19+
return true
20+
}
21+
22+
return false
23+
}
24+
525
export function validateToolUse(
626
toolName: ToolName,
727
mode: Mode,
@@ -11,6 +31,15 @@ export function validateToolUse(
1131
experiments?: Record<string, boolean>,
1232
includedTools?: string[],
1333
): void {
34+
// First, check if the tool name is actually a valid/known tool
35+
// This catches completely invalid tool names like "edit_file" that don't exist
36+
if (!isValidToolName(toolName)) {
37+
throw new Error(
38+
`Unknown tool "${toolName}". This tool does not exist. Please use one of the available tools: ${validToolNames.join(", ")}.`,
39+
)
40+
}
41+
42+
// Then check if the tool is allowed for the current mode
1443
if (
1544
!isToolAllowedForMode(
1645
toolName,

0 commit comments

Comments
 (0)