diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b26c44868..1d3f229d3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Roo Cline Changelog
+## [2.1.11]
+
+- Fix [bug in parsing of nested tool calls](https://github.com/cline/cline/issues/832)
+
## [2.1.10]
- Incorporate HeavenOSK's [PR](https://github.com/cline/cline/pull/818) to add sound effects to Cline
diff --git a/package-lock.json b/package-lock.json
index 60abf7ff3a..80b49bb486 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "roo-cline",
- "version": "2.1.10",
+ "version": "2.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
- "version": "2.1.10",
+ "version": "2.1.11",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
diff --git a/package.json b/package.json
index 327f357793..36474fd66c 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"displayName": "Roo Cline",
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
"publisher": "RooVeterinaryInc",
- "version": "2.1.10",
+ "version": "2.1.11",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
diff --git a/src/core/assistant-message/__tests__/parse-assistant-message.test.ts b/src/core/assistant-message/__tests__/parse-assistant-message.test.ts
new file mode 100644
index 0000000000..b6a50fd380
--- /dev/null
+++ b/src/core/assistant-message/__tests__/parse-assistant-message.test.ts
@@ -0,0 +1,118 @@
+import { parseAssistantMessage } from '../parse-assistant-message'
+import { ToolUseName } from '../'
+
+describe('parseAssistantMessage', () => {
+ it('should parse plain text', () => {
+ const input = 'Hello, this is a simple message'
+ const result = parseAssistantMessage(input)
+
+ expect(result).toEqual([{
+ type: 'text',
+ content: 'Hello, this is a simple message',
+ partial: true
+ }])
+ })
+
+ it('should parse a tool use with parameters', () => {
+ const input = 'test.txtHello World'
+ const result = parseAssistantMessage(input)
+
+ expect(result).toEqual([{
+ type: 'tool_use',
+ name: 'write_to_file' as ToolUseName,
+ params: {
+ path: 'test.txt',
+ content: 'Hello World'
+ },
+ partial: false
+ }])
+ })
+
+ it('should parse mixed text and tool use', () => {
+ const input = 'Let me write a file for you: test.txtHello World Done!'
+ const result = parseAssistantMessage(input)
+
+ expect(result).toEqual([
+ {
+ type: 'text',
+ content: 'Let me write a file for you: ',
+ partial: false
+ },
+ {
+ type: 'tool_use',
+ name: 'write_to_file' as ToolUseName,
+ params: {
+ path: 'test.txt',
+ content: 'Hello World'
+ },
+ partial: false
+ },
+ {
+ type: 'text',
+ content: 'Done!',
+ partial: true
+ }
+ ])
+ })
+
+ it('should handle nested tags in content parameter', () => {
+ const input = 'test.txtfunction test() { return ; }'
+ const result = parseAssistantMessage(input)
+
+ expect(result).toEqual([{
+ type: 'tool_use',
+ name: 'write_to_file' as ToolUseName,
+ params: {
+ path: 'test.txt',
+ content: 'function test() { return ; }'
+ },
+ partial: false
+ }])
+ })
+
+ it("should handle multiple nested tags correctly", () => {
+ const input = `
+
+ output.txt
+
+ input.txtHello World
+ More content
+
+ `
+ const result = parseAssistantMessage(input)
+
+ expect(result).toEqual([{
+ type: 'tool_use',
+ name: 'write_to_file' as ToolUseName,
+ params: {
+ path: 'output.txt',
+ content:
+`input.txtHello World
+ More content`
+ },
+ partial: false
+ }])
+ });
+
+ it('should handle partial tool use', () => {
+ const input = 'Starting... test.txtpartial'
+ const result = parseAssistantMessage(input)
+
+ expect(result).toEqual([
+ {
+ type: 'text',
+ content: 'Starting...',
+ partial: false
+ },
+ {
+ type: 'tool_use',
+ name: 'write_to_file' as ToolUseName,
+ params: {
+ path: 'test.txt',
+ content: 'partial'
+ },
+ partial: true
+ }
+ ])
+ })
+})
\ No newline at end of file
diff --git a/src/core/assistant-message/parse-assistant-message.ts b/src/core/assistant-message/parse-assistant-message.ts
index e38e8f6458..2c33835607 100644
--- a/src/core/assistant-message/parse-assistant-message.ts
+++ b/src/core/assistant-message/parse-assistant-message.ts
@@ -1,143 +1,140 @@
import {
AssistantMessageContent,
- TextContent,
- ToolUse,
ToolParamName,
toolParamNames,
toolUseNames,
ToolUseName,
} from "."
+/**
+ * Parses an assistant message containing text and tool use blocks.
+ *
+ * Algorithm:
+ * 1. Iteratively processes the message string until no content remains
+ * 2. For each iteration:
+ * - Searches for the next tool tag (format: )
+ * - If no tool found, treats remaining content as text and exits
+ * - Validates the tool name against known tools
+ * - Extracts any text content before the tool as a separate block
+ * - Locates the tool's closing tag () by finding the LAST matching
+ * closing tag in the remaining text. This ensures we match the outermost
+ * tags when there are nested tools of the same type
+ * - Parses tool parameters within the tool block:
+ * * Parameters follow format: value
+ * * Orders parameters by position and extracts values
+ * * Filters out empty parameters
+ * - Creates a tool_use block with parsed parameters
+ * - Continues with remaining text after the tool's closing tag
+ *
+ * Returns an array of content blocks, where each block is either:
+ * - Text content: { type: "text", content: string, partial: boolean }
+ * - Tool use: { type: "tool_use", name: string, params: Record, partial: boolean }
+ */
+
export function parseAssistantMessage(assistantMessage: string) {
- let contentBlocks: AssistantMessageContent[] = []
- let currentTextContent: TextContent | undefined = undefined
- let currentTextContentStartIndex = 0
- let currentToolUse: ToolUse | undefined = undefined
- let currentToolUseStartIndex = 0
- let currentParamName: ToolParamName | undefined = undefined
- let currentParamValueStartIndex = 0
- let accumulator = ""
+ let contentBlocks: AssistantMessageContent[] = []
+ let remainingText = assistantMessage
- for (let i = 0; i < assistantMessage.length; i++) {
- const char = assistantMessage[i]
- accumulator += char
+ while (remainingText.length > 0) {
+ // Look for the next tool use from the start
+ const toolMatch = remainingText.match(/<([\w_]+)>/) as RegExpMatchArray
- // there should not be a param without a tool use
- if (currentToolUse && currentParamName) {
- const currentParamValue = accumulator.slice(currentParamValueStartIndex)
- const paramClosingTag = `${currentParamName}>`
- if (currentParamValue.endsWith(paramClosingTag)) {
- // end of param value
- currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
- currentParamName = undefined
- continue
- } else {
- // partial param value is accumulating
- continue
- }
- }
+ if (!toolMatch) {
+ // No more tools, rest is text
+ if (remainingText.trim()) {
+ contentBlocks.push({
+ type: "text",
+ content: remainingText.trim(),
+ partial: true
+ })
+ }
+ break
+ }
- // no currentParamName
+ const toolName = toolMatch[1] as ToolUseName
+ if (!toolUseNames.includes(toolName)) {
+ // Find the closing tag for this invalid tool
+ const invalidClosingTag = `${toolName}>`
+ const closeIndex = remainingText.indexOf(invalidClosingTag)
+
+ // Take the entire invalid tag block as text
+ const textBlock = closeIndex !== -1
+ ? remainingText.slice(0, closeIndex + invalidClosingTag.length)
+ : remainingText.slice(0, toolMatch.index! + toolMatch[0].length)
+
+ contentBlocks.push({
+ type: "text",
+ content: textBlock,
+ partial: false
+ })
+
+ remainingText = closeIndex !== -1
+ ? remainingText.slice(closeIndex + invalidClosingTag.length)
+ : remainingText.slice(toolMatch.index! + toolMatch[0].length)
+ continue
+ }
- if (currentToolUse) {
- const currentToolValue = accumulator.slice(currentToolUseStartIndex)
- const toolUseClosingTag = `${currentToolUse.name}>`
- if (currentToolValue.endsWith(toolUseClosingTag)) {
- // end of a tool use
- currentToolUse.partial = false
- contentBlocks.push(currentToolUse)
- currentToolUse = undefined
- continue
- } else {
- const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
- for (const paramOpeningTag of possibleParamOpeningTags) {
- if (accumulator.endsWith(paramOpeningTag)) {
- // start of a new parameter
- currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
- currentParamValueStartIndex = accumulator.length
- break
- }
- }
+ // If there's text before the tool, add it as a block
+ const textBeforeTool = remainingText.slice(0, toolMatch.index).trim()
+ if (textBeforeTool) {
+ contentBlocks.push({
+ type: "text",
+ content: textBeforeTool,
+ partial: false
+ })
+ }
- // there's no current param, and not starting a new param
+ // Find the matching closing tag
+ const toolClosingTag = `${toolName}>`
+ const toolCloseIndex = remainingText.lastIndexOf(toolClosingTag)
- // special case for write_to_file where file contents could contain the closing tag, in which case the param would have closed and we end up with the rest of the file contents here. To work around this, we get the string between the starting content tag and the LAST content tag.
- const contentParamName: ToolParamName = "content"
- if (currentToolUse.name === "write_to_file" && accumulator.endsWith(`${contentParamName}>`)) {
- const toolContent = accumulator.slice(currentToolUseStartIndex)
- const contentStartTag = `<${contentParamName}>`
- const contentEndTag = `${contentParamName}>`
- const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
- const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
- if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
- currentToolUse.params[contentParamName] = toolContent
- .slice(contentStartIndex, contentEndIndex)
- .trim()
- }
- }
+ // Extract tool content
+ const matchIndex = toolMatch!.index!
+ const matchLength = toolMatch![0]!.length
+ const toolContent = toolCloseIndex === -1
+ ? remainingText.slice(matchIndex + matchLength)
+ : remainingText.slice(matchIndex + matchLength, toolCloseIndex)
- // partial tool value is accumulating
- continue
- }
- }
+ // Parse parameters
+ const params: Record = Object.fromEntries(
+ toolParamNames.map(name => [name, ""])
+ ) as Record
- // no currentToolUse
+ const paramPositions = toolParamNames.map(name => ({
+ name,
+ start: toolContent.indexOf(`<${name}>`)
+ })).filter(p => p.start !== -1).sort((a, b) => a.start - b.start)
- let didStartToolUse = false
- const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
- for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
- if (accumulator.endsWith(toolUseOpeningTag)) {
- // start of a new tool use
- currentToolUse = {
- type: "tool_use",
- name: toolUseOpeningTag.slice(1, -1) as ToolUseName,
- params: {},
- partial: true,
- }
- currentToolUseStartIndex = accumulator.length
- // this also indicates the end of the current text content
- if (currentTextContent) {
- currentTextContent.partial = false
- // remove the partially accumulated tool use tag from the end of text (`, nextStart)
- didStartToolUse = true
- break
- }
- }
+ if (valueEnd > valueStart) {
+ params[param.name] = toolContent.slice(valueStart, valueEnd).trim()
+ } else {
+ params[param.name] = toolContent.slice(valueStart).trim()
+ }
+ }
- if (!didStartToolUse) {
- // no tool use, so it must be text either at the beginning or between tools
- if (currentTextContent === undefined) {
- currentTextContentStartIndex = i
- }
- currentTextContent = {
- type: "text",
- content: accumulator.slice(currentTextContentStartIndex).trim(),
- partial: true,
- }
- }
- }
+ // Filter out empty parameters
+ const nonEmptyParams = Object.fromEntries(
+ Object.entries(params).filter(([_, value]) => value !== "")
+ ) as Record
- if (currentToolUse) {
- // stream did not complete tool call, add it as partial
- if (currentParamName) {
- // tool call has a parameter that was not completed
- currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
- }
- contentBlocks.push(currentToolUse)
- }
+ contentBlocks.push({
+ type: "tool_use",
+ name: toolName,
+ params: nonEmptyParams,
+ partial: toolCloseIndex === -1
+ })
- // Note: it doesnt matter if check for currentToolUse or currentTextContent, only one of them will be defined since only one can be partial at a time
- if (currentTextContent) {
- // stream did not complete text content, add it as partial
- contentBlocks.push(currentTextContent)
- }
+ // Move past this tool
+ remainingText = toolCloseIndex === -1
+ ? ""
+ : remainingText.slice(toolCloseIndex + toolClosingTag.length)
+ }
- return contentBlocks
+ return contentBlocks
}