Skip to content

Commit 460215b

Browse files
xyOz-devcte
authored andcommitted
Accidental execution of tool syntax fix (RooCodeInc#3456)
Co-authored-by: cte <[email protected]>
1 parent d597eb9 commit 460215b

File tree

8 files changed

+442
-183
lines changed

8 files changed

+442
-183
lines changed

src/core/assistant-message/__tests__/parseAssistantMessage.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,158 @@ const isEmptyTextContent = (block: AssistantMessageContent) =>
335335
expect(result[5].type).toBe("tool_use")
336336
expect((result[5] as ToolUse).name).toBe("execute_command")
337337
})
338+
339+
it("treats inline <read_file> mention as plain text", () => {
340+
const message = "Use the `<read_file>` tool when you need to read a file."
341+
const result = parser(message)
342+
expect(result).toHaveLength(1)
343+
expect(result[0].type).toBe("text")
344+
})
345+
346+
it("treats fenced code block mention as plain text", () => {
347+
const message = [
348+
"Here is an example:",
349+
"```",
350+
"<read_file>",
351+
"<path>demo.txt</path>",
352+
"</read_file>",
353+
"```",
354+
].join("\n")
355+
const result = parser(message)
356+
expect(result).toHaveLength(1)
357+
expect(result[0].type).toBe("text")
358+
})
359+
360+
it("parses real tool invocation with newline correctly", () => {
361+
const invocation = ["<read_file>", "<path>demo.txt</path>", "</read_file>"].join("\n")
362+
const result = parser(invocation)
363+
expect(result).toHaveLength(1)
364+
const tool = result[0] as ToolUse
365+
expect(tool.type).toBe("tool_use")
366+
expect(tool.name).toBe("read_file")
367+
expect(tool.params.path).toBe("demo.txt")
368+
})
369+
})
370+
371+
describe("code block edge cases", () => {
372+
it("handles nested code blocks with tool tags", () => {
373+
const message = [
374+
"Here's a nested code block:",
375+
"> ```",
376+
"> <read_file><path>test.txt</path></read_file>",
377+
"> ```",
378+
].join("\n")
379+
380+
const result = parser(message)
381+
expect(result).toHaveLength(1)
382+
expect(result[0].type).toBe("text")
383+
})
384+
385+
it("handles escaped backticks", () => {
386+
const message = "This has an escaped \\`<read_file>\\` tag"
387+
388+
const result = parser(message)
389+
expect(result).toHaveLength(1)
390+
expect(result[0].type).toBe("text")
391+
})
392+
393+
it("handles incomplete code blocks", () => {
394+
const message = [
395+
"```javascript",
396+
"<read_file><path>test.txt</path></read_file>",
397+
// Missing closing backticks
398+
].join("\n")
399+
400+
const result = parser(message)
401+
expect(result).toHaveLength(1)
402+
expect(result[0].type).toBe("text")
403+
})
404+
})
405+
406+
describe("tool tag edge cases", () => {
407+
it("handles HTML comments containing tool tags", () => {
408+
const message = "<!-- <read_file><path>test.txt</path></read_file> -->"
409+
410+
// Note: Currently both parsers treat tool tags in HTML comments as actual tool invocations
411+
// This test documents the current behavior rather than the ideal behavior
412+
const result = parser(message)
413+
expect(result).toHaveLength(3)
414+
expect(result[0].type).toBe("text")
415+
expect((result[0] as TextContent).content).toBe("<!--")
416+
expect(result[1].type).toBe("tool_use")
417+
expect((result[1] as ToolUse).name).toBe("read_file")
418+
expect(result[2].type).toBe("text")
419+
expect((result[2] as TextContent).content).toBe("-->")
420+
})
421+
422+
it("handles tool tags with HTML-like attributes", () => {
423+
const message = '<read_file class="example"><path>test.txt</path></read_file>'
424+
425+
// This should be treated as plain text since it's not a valid tool format
426+
const result = parser(message)
427+
expect(result).toHaveLength(1)
428+
expect(result[0].type).toBe("text")
429+
})
430+
431+
it("handles malformed tool tags with extra spaces", () => {
432+
const message = "< read_file >< path >test.txt< /path >< /read_file >"
433+
434+
// This should be treated as plain text since it's not a valid tool format
435+
const result = parser(message)
436+
expect(result).toHaveLength(1)
437+
expect(result[0].type).toBe("text")
438+
})
439+
})
440+
441+
describe("mixed formatting edge cases", () => {
442+
it("handles tool tags with markdown formatting", () => {
443+
const message = "**<read_file>**<path>test.txt</path></read_file>"
444+
445+
// The opening tag is malformed due to the bold formatting
446+
const result = parser(message)
447+
expect(result).toHaveLength(1)
448+
expect(result[0].type).toBe("text")
449+
})
450+
451+
it("handles tool tags in markdown links", () => {
452+
const message = "[Example](<read_file><path>test.txt</path></read_file>)"
453+
454+
// Note: Currently both parsers treat tool tags in markdown links as actual tool invocations
455+
// This test documents the current behavior rather than the ideal behavior
456+
const result = parser(message)
457+
expect(result).toHaveLength(3)
458+
expect(result[0].type).toBe("text")
459+
expect((result[0] as TextContent).content).toBe("[Example](")
460+
expect(result[1].type).toBe("tool_use")
461+
expect((result[1] as ToolUse).name).toBe("read_file")
462+
expect(result[2].type).toBe("text")
463+
expect((result[2] as TextContent).content).toBe(")")
464+
})
465+
})
466+
467+
describe("performance edge cases", () => {
468+
it("handles very long text with no tool uses", () => {
469+
const message = "A".repeat(10000)
470+
471+
const result = parser(message)
472+
expect(result).toHaveLength(1)
473+
expect(result[0].type).toBe("text")
474+
expect((result[0] as TextContent).content.length).toBe(10000)
475+
})
476+
477+
it("handles deeply nested tool parameters", () => {
478+
// Create a message with a tool that has many nested parameters
479+
let message = "<execute_command><command>"
480+
for (let i = 0; i < 10; i++) {
481+
message += `echo "Level ${i}" && `
482+
}
483+
message += 'echo "Done"</command></execute_command>'
484+
485+
const result = parser(message).filter((block) => !isEmptyTextContent(block))
486+
expect(result).toHaveLength(1)
487+
expect(result[0].type).toBe("tool_use")
488+
expect((result[0] as ToolUse).name).toBe("execute_command")
489+
})
338490
})
339491
})
340492
})

src/core/assistant-message/__tests__/parseAssistantMessageBenchmark.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,13 @@ const runBenchmark = () => {
8181
const namePadding = maxNameLength + 2
8282

8383
console.log(
84-
`| ${"Test Case".padEnd(namePadding)} | V1 Time (ms) | V2 Time (ms) | V1/V2 Ratio | V1 Heap (bytes) | V2 Heap (bytes) |`,
85-
)
86-
console.log(
87-
`| ${"-".repeat(namePadding)} | ------------ | ------------ | ----------- | ---------------- | ---------------- |`,
84+
`| ${"Test Case".padEnd(namePadding)} | V1 Time (ms) | V2 Time (ms) | V1 Heap (bytes) | V2 Heap (bytes) |`,
8885
)
86+
console.log(`| ${"-".repeat(namePadding)} | ------------ | ------------ | ---------------- | ---------------- |`)
8987

9088
for (const testCase of testCases) {
9189
const v1Time = measureExecutionTime(parseAssistantMessageV1, testCase.input)
9290
const v2Time = measureExecutionTime(parseAssistantMessageV2, testCase.input)
93-
const timeRatio = v1Time / v2Time
9491

9592
const v1Memory = measureMemoryUsage(parseAssistantMessageV1, testCase.input)
9693
const v2Memory = measureMemoryUsage(parseAssistantMessageV2, testCase.input)
@@ -99,7 +96,6 @@ const runBenchmark = () => {
9996
`| ${testCase.name.padEnd(namePadding)} | ` +
10097
`${v1Time.toFixed(4).padStart(12)} | ` +
10198
`${v2Time.toFixed(4).padStart(12)} | ` +
102-
`${timeRatio.toFixed(2).padStart(11)} | ` +
10399
`${formatNumber(Math.round(v1Memory.heapUsed)).padStart(16)} | ` +
104100
`${formatNumber(Math.round(v2Memory.heapUsed)).padStart(16)} |`,
105101
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { type AssistantMessageContent, parseAssistantMessage } from "./parseAssistantMessage"
22
export { presentAssistantMessage } from "./presentAssistantMessage"
3+
export { parseAssistantMessageV2 } from "./parseAssistantMessageV2"

src/core/assistant-message/parseAssistantMessage.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,52 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
1313
let currentParamValueStartIndex = 0
1414
let accumulator = ""
1515

16+
// Track whether we are inside markdown code blocks or inline code to avoid treating textual mentions
17+
// of tool tags (e.g. <read_file>) as actual tool invocations.
18+
let insideCodeBlock = false // ``` fenced code block
19+
let insideInlineCode = false // `inline code`
20+
21+
// Helper to decide if we should parse for tool-related tags at the current position
22+
const shouldParseToolTags = () => !insideCodeBlock && !insideInlineCode
23+
1624
for (let i = 0; i < assistantMessage.length; i++) {
1725
const char = assistantMessage[i]
26+
27+
// Detect fenced code block (```).
28+
if (!insideInlineCode && assistantMessage.slice(i, i + 3) === "```") {
29+
insideCodeBlock = !insideCodeBlock
30+
// Append the full trio of backticks to accumulator.
31+
accumulator += "```"
32+
i += 2 // Skip the two extra backticks we just added.
33+
34+
// When toggling code block state, continue to next iteration as
35+
// these chars are text.
36+
continue
37+
}
38+
39+
// Detect inline code (`) when not inside a fenced code block and not part of triple backticks
40+
if (!insideCodeBlock && char === "`") {
41+
insideInlineCode = !insideInlineCode
42+
}
43+
1844
accumulator += char
1945

46+
// If we are in any kind of code context, treat everything as plain text.
47+
if (!shouldParseToolTags()) {
48+
// Handle accumulating text content block.
49+
if (currentTextContent === undefined) {
50+
currentTextContentStartIndex = i
51+
}
52+
53+
currentTextContent = {
54+
type: "text",
55+
content: accumulator.slice(currentTextContentStartIndex).trim(),
56+
partial: true,
57+
}
58+
59+
continue
60+
}
61+
2062
// There should not be a param without a tool use.
2163
if (currentToolUse && currentParamName) {
2264
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
@@ -45,6 +87,7 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
4587
continue
4688
} else {
4789
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
90+
4891
for (const paramOpeningTag of possibleParamOpeningTags) {
4992
if (accumulator.endsWith(paramOpeningTag)) {
5093
// Start of a new parameter.
@@ -89,6 +132,25 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
89132

90133
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
91134
if (accumulator.endsWith(toolUseOpeningTag)) {
135+
// Check that this is likely an actual tool invocation and not
136+
// an inline textual reference.
137+
// We consider it an invocation only if the next non-whitespace
138+
// character is a newline (\n or \r) or an opening angle bracket
139+
// '<' (which would start the first parameter tag).
140+
141+
let j = i + 1 // Position after the closing '>' of the opening tag.
142+
143+
while (j < assistantMessage.length && assistantMessage[j] === " ") {
144+
j++
145+
}
146+
147+
const nextChar = assistantMessage[j] ?? ""
148+
149+
if (nextChar && nextChar !== "<" && nextChar !== "\n" && nextChar !== "\r") {
150+
// Treat as plain text, not a tool invocation.
151+
continue
152+
}
153+
92154
// Start of a new tool use.
93155
currentToolUse = {
94156
type: "tool_use",
@@ -151,5 +213,6 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
151213
contentBlocks.push(currentTextContent)
152214
}
153215

154-
return contentBlocks
216+
// Remove any empty text blocks that may have been created by whitespace or newlines before/after tool calls
217+
return contentBlocks.filter((block) => !(block.type === "text" && block.content.trim().length === 0))
155218
}

src/core/assistant-message/parseAssistantMessageV2.ts

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
4646
let currentParamValueStart = 0 // Index *after* the opening tag of the current param.
4747
let currentParamName: ToolParamName | undefined = undefined
4848

49+
// Track whether we are inside markdown code blocks or inline code to avoid treating textual mentions
50+
// of tool tags (e.g. <read_file>) as actual tool invocations.
51+
let insideCodeBlock = false // ``` fenced code block
52+
let insideInlineCode = false // `inline code`
53+
54+
// Helper to decide if we should parse for tool-related tags at the current position
55+
const shouldParseToolTags = () => !insideCodeBlock && !insideInlineCode
56+
4957
// Precompute tags for faster lookups.
5058
const toolUseOpenTags = new Map<string, ToolName>()
5159
const toolParamOpenTags = new Map<string, ToolParamName>()
@@ -63,16 +71,39 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
6371
for (let i = 0; i < len; i++) {
6472
const currentCharIndex = i
6573

74+
// Detect fenced code block (```).
75+
if (!insideInlineCode && i + 2 < len && assistantMessage.slice(i, i + 3) === "```") {
76+
insideCodeBlock = !insideCodeBlock
77+
i += 2 // Skip the two extra backticks.
78+
continue
79+
}
80+
81+
// Detect inline code (`) when not inside a fenced code block and not
82+
// part of triple backticks.
83+
if (!insideCodeBlock && assistantMessage[i] === "`") {
84+
insideInlineCode = !insideInlineCode
85+
}
86+
87+
// If we are in any kind of code context, treat everything as plain text.
88+
if (!shouldParseToolTags()) {
89+
// If we're not already tracking text content, start now.
90+
if (!currentTextContent) {
91+
currentTextContentStart = currentCharIndex
92+
currentTextContent = { type: "text", content: "", partial: true }
93+
}
94+
95+
continue
96+
}
97+
6698
// Parsing a tool parameter
6799
if (currentToolUse && currentParamName) {
68100
const closeTag = `</${currentParamName}>`
69-
// Check if the string *ending* at index `i` matches the closing tag
101+
102+
// Check if the string *ending* at index `i` matches the closing tag.
70103
if (
71104
currentCharIndex >= closeTag.length - 1 &&
72-
assistantMessage.startsWith(
73-
closeTag,
74-
currentCharIndex - closeTag.length + 1, // Start checking from potential start of tag.
75-
)
105+
// Start checking from potential start of tag.
106+
assistantMessage.startsWith(closeTag, currentCharIndex - closeTag.length + 1)
76107
) {
77108
// Found the closing tag for the parameter.
78109
const value = assistantMessage
@@ -175,6 +206,23 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
175206
currentCharIndex >= tag.length - 1 &&
176207
assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)
177208
) {
209+
// Check that this is likely an actual tool invocation and not
210+
// an inline textual reference.
211+
// We consider it an invocation only if the next non-whitespace
212+
// character is a newline (\n or \r) or an opening angle bracket
213+
// '<' (which would start the first parameter tag).
214+
let j = currentCharIndex + 1 // Position after the closing '>' of the opening tag.
215+
216+
while (j < len && assistantMessage[j] === " ") {
217+
j++
218+
}
219+
220+
const nextChar = assistantMessage[j] ?? ""
221+
222+
if (nextChar && nextChar !== "<" && nextChar !== "\n" && nextChar !== "\r") {
223+
// Treat as plain text, not a tool invocation.
224+
continue
225+
}
178226
// End current text block if one was active.
179227
if (currentTextContent) {
180228
currentTextContent.content = assistantMessage
@@ -201,22 +249,13 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
201249
.trim()
202250

203251
if (potentialText.length > 0) {
204-
contentBlocks.push({
205-
type: "text",
206-
content: potentialText,
207-
partial: false,
208-
})
252+
contentBlocks.push({ type: "text", content: potentialText, partial: false })
209253
}
210254
}
211255

212256
// Start the new tool use.
213-
currentToolUse = {
214-
type: "tool_use",
215-
name: toolName,
216-
params: {},
217-
partial: true, // Assume partial until closing tag is found.
218-
}
219-
257+
// Assume partial until closing tag is found.
258+
currentToolUse = { type: "tool_use", name: toolName, params: {}, partial: true }
220259
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag.
221260
startedNewTool = true
222261

0 commit comments

Comments
 (0)