Skip to content

Commit 47e294d

Browse files
committed
feat(Tools): Bucket invalid tool names under a static analytics key
1 parent 5bec044 commit 47e294d

4 files changed

Lines changed: 18 additions & 14 deletions

File tree

packages/types/src/tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const toolNames = [
4646
"skill",
4747
"generate_image",
4848
"custom_tool",
49+
"invalid_tool_call",
4950
] as const
5051

5152
export const toolNamesSchema = z.enum(toolNames)

src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,13 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
101101
// Verify consecutiveMistakeCount was incremented
102102
expect(mockTask.consecutiveMistakeCount).toBe(1)
103103

104-
// Verify recordToolError was called
104+
// Verify recordToolError was called, bucketed under a static key rather than the raw
105+
// model-supplied tool name (which must never become an arbitrary analytics property key).
105106
expect(mockTask.recordToolError).toHaveBeenCalledWith(
106-
"nonexistent_tool",
107+
"invalid_tool_call",
107108
expect.stringContaining("Unknown tool"),
108109
)
110+
expect(mockTask.recordToolError).not.toHaveBeenCalledWith("nonexistent_tool", expect.anything())
109111

110112
// Verify error message was shown to user (uses i18n key)
111113
expect(mockTask.say).toHaveBeenCalledWith("error", "unknownToolError")
@@ -217,21 +219,24 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
217219
it("does not record raw model-supplied tool names in tool usage analytics", async () => {
218220
// block.name comes straight from the model's tool-call output and is only checked
219221
// against isValidToolName() *after* recordToolUsage() would otherwise be called.
220-
// An arbitrary/malicious model-supplied name must never become a toolsUsed key.
222+
// An arbitrary/malicious model-supplied name must never become a toolsUsed key,
223+
// whether via recordToolUsage (success path) or recordToolError (failure path).
221224
const toolCallId = "tool_call_analytics_test"
225+
const maliciousName = "'; DROP TABLE users; --"
222226
mockTask.assistantMessageContent = [
223227
{
224228
type: "tool_use",
225229
id: toolCallId,
226-
name: "'; DROP TABLE users; --",
230+
name: maliciousName,
227231
params: {},
228232
partial: false,
229233
},
230234
]
231235

232236
await presentAssistantMessage(mockTask)
233237

234-
expect(mockTask.recordToolUsage).not.toHaveBeenCalledWith("'; DROP TABLE users; --")
238+
expect(mockTask.recordToolUsage).not.toHaveBeenCalledWith(maliciousName)
239+
expect(mockTask.recordToolError).not.toHaveBeenCalledWith(maliciousName, expect.anything())
235240
})
236241

237242
it("should still work with didRejectTool flag for unknown tool", async () => {

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,10 @@ export async function presentAssistantMessage(cline: Task) {
303303
if (!toolCallId) {
304304
const errorMessage =
305305
"Invalid tool call: missing tool_use.id. XML tool calls are no longer supported. Remove any XML tool markup (e.g. <read_file>...</read_file>) and use native tool calling instead."
306-
// Record a tool error for visibility/telemetry. Use the reported tool name if present.
306+
// Record a tool error for visibility/telemetry. block.name is model-controlled and
307+
// must never reach analytics unvalidated, so bucket it under a static key.
307308
try {
308-
if (
309-
typeof (cline as any).recordToolError === "function" &&
310-
typeof (block as any).name === "string"
311-
) {
312-
;(cline as any).recordToolError((block as any).name as ToolName, errorMessage)
313-
}
309+
cline.recordToolError("invalid_tool_call", errorMessage)
314310
} catch {
315311
// Best-effort only
316312
}
@@ -901,10 +897,11 @@ export async function presentAssistantMessage(cline: Task) {
901897
break
902898
}
903899

904-
// Not a custom tool - handle as unknown tool error
900+
// Not a custom tool - handle as unknown tool error. block.name is model-controlled
901+
// and must never reach analytics unvalidated, so bucket it under a static key.
905902
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
906903
cline.consecutiveMistakeCount++
907-
cline.recordToolError(block.name as ToolName, errorMessage)
904+
cline.recordToolError("invalid_tool_call", errorMessage)
908905
await cline.say("error", t("tools:unknownToolError", { toolName: block.name }))
909906
// Push tool_result directly WITHOUT setting didAlreadyUseTool
910907
// This prevents the stream from being interrupted with "Response interrupted by tool use result"

src/shared/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
290290
skill: "load skill",
291291
generate_image: "generate images",
292292
custom_tool: "use custom tools",
293+
invalid_tool_call: "use an unrecognized tool",
293294
} as const
294295

295296
// Define available tool groups.

0 commit comments

Comments
 (0)