mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
* Fix: Allow write_to_file to handle newline-only and empty content * fix: update writeToFileTool to return early without error on missing or empty parameters * fix: preserve newlines in content parameters and update error handling in writeToFileTool tests * fix: update parseAssistantMessage and parseAssistantMessageV2 to preserve newlines in content parameters while stripping leading and trailing newlines --------- Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { type ToolName, toolNames } from "@roo-code/types"
|
|
|
|
import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
|
|
|
|
export type AssistantMessageContent = TextContent | ToolUse
|
|
|
|
/**
|
|
* Parses an assistant message string potentially containing mixed text and tool
|
|
* usage blocks marked with XML-like tags into an array of structured content
|
|
* objects.
|
|
*
|
|
* This version aims for efficiency by avoiding the character-by-character
|
|
* accumulator of V1. It iterates through the string using an index `i`. At each
|
|
* position, it checks if the substring *ending* at `i` matches any known
|
|
* opening or closing tags for tools or parameters using `startsWith` with an
|
|
* offset.
|
|
* It uses pre-computed Maps (`toolUseOpenTags`, `toolParamOpenTags`) for quick
|
|
* tag lookups.
|
|
* State is managed using indices (`currentTextContentStart`,
|
|
* `currentToolUseStart`, `currentParamValueStart`) pointing to the start of the
|
|
* current block within the original `assistantMessage` string.
|
|
*
|
|
* Slicing is used to extract content only when a block (text, parameter, or
|
|
* tool use) is completed.
|
|
*
|
|
* Special handling for `write_to_file` and `new_rule` content parameters is
|
|
* included, using `indexOf` and `lastIndexOf` on the relevant slice to handle
|
|
* potentially nested closing tags.
|
|
*
|
|
* If the input string ends mid-block, the last open block is added and marked
|
|
* as partial.
|
|
*
|
|
* @param assistantMessage The raw string output from the assistant.
|
|
* @returns An array of `AssistantMessageContent` objects, which can be
|
|
* `TextContent` or `ToolUse`. Blocks that were not fully closed by the
|
|
* end of the input string will have their `partial` flag set to
|
|
* `true`.
|
|
*/
|
|
|
|
export function parseAssistantMessageV2(assistantMessage: string): AssistantMessageContent[] {
|
|
const contentBlocks: AssistantMessageContent[] = []
|
|
|
|
let currentTextContentStart = 0 // Index where the current text block started.
|
|
let currentTextContent: TextContent | undefined = undefined
|
|
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use.
|
|
let currentToolUse: ToolUse | undefined = undefined
|
|
let currentParamValueStart = 0 // Index *after* the opening tag of the current param.
|
|
let currentParamName: ToolParamName | undefined = undefined
|
|
|
|
// Precompute tags for faster lookups.
|
|
const toolUseOpenTags = new Map<string, ToolName>()
|
|
const toolParamOpenTags = new Map<string, ToolParamName>()
|
|
|
|
for (const name of toolNames) {
|
|
toolUseOpenTags.set(`<${name}>`, name)
|
|
}
|
|
|
|
for (const name of toolParamNames) {
|
|
toolParamOpenTags.set(`<${name}>`, name)
|
|
}
|
|
|
|
const len = assistantMessage.length
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const currentCharIndex = i
|
|
|
|
// Parsing a tool parameter
|
|
if (currentToolUse && currentParamName) {
|
|
const closeTag = `</${currentParamName}>`
|
|
// Check if the string *ending* at index `i` matches the closing tag
|
|
if (
|
|
currentCharIndex >= closeTag.length - 1 &&
|
|
assistantMessage.startsWith(
|
|
closeTag,
|
|
currentCharIndex - closeTag.length + 1, // Start checking from potential start of tag.
|
|
)
|
|
) {
|
|
// Found the closing tag for the parameter.
|
|
const value = assistantMessage.slice(
|
|
currentParamValueStart, // Start after the opening tag.
|
|
currentCharIndex - closeTag.length + 1, // End before the closing tag.
|
|
)
|
|
// Don't trim content parameters to preserve newlines, but strip first and last newline only
|
|
currentToolUse.params[currentParamName] =
|
|
currentParamName === "content" ? value.replace(/^\n/, "").replace(/\n$/, "") : value.trim()
|
|
currentParamName = undefined // Go back to parsing tool content.
|
|
// We don't continue loop here, need to check for tool close or other params at index i.
|
|
} else {
|
|
continue // Still inside param value, move to next char.
|
|
}
|
|
}
|
|
|
|
// Parsing a tool use (but not a specific parameter).
|
|
if (currentToolUse && !currentParamName) {
|
|
// Ensure we are not inside a parameter already.
|
|
// Check if starting a new parameter.
|
|
let startedNewParam = false
|
|
|
|
for (const [tag, paramName] of toolParamOpenTags.entries()) {
|
|
if (
|
|
currentCharIndex >= tag.length - 1 &&
|
|
assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)
|
|
) {
|
|
currentParamName = paramName
|
|
currentParamValueStart = currentCharIndex + 1 // Value starts after the tag.
|
|
startedNewParam = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (startedNewParam) {
|
|
continue // Handled start of param, move to next char.
|
|
}
|
|
|
|
// Check if closing the current tool use.
|
|
const toolCloseTag = `</${currentToolUse.name}>`
|
|
|
|
if (
|
|
currentCharIndex >= toolCloseTag.length - 1 &&
|
|
assistantMessage.startsWith(toolCloseTag, currentCharIndex - toolCloseTag.length + 1)
|
|
) {
|
|
// End of the tool use found.
|
|
// Special handling for content params *before* finalizing the
|
|
// tool.
|
|
const toolContentSlice = assistantMessage.slice(
|
|
currentToolUseStart, // From after the tool opening tag.
|
|
currentCharIndex - toolCloseTag.length + 1, // To before the tool closing tag.
|
|
)
|
|
|
|
// Check if content parameter needs special handling
|
|
// (write_to_file/new_rule).
|
|
// This check is important if the closing </content> tag was
|
|
// missed by the parameter parsing logic (e.g., if content is
|
|
// empty or parsing logic prioritizes tool close).
|
|
const contentParamName: ToolParamName = "content"
|
|
if (
|
|
currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */ &&
|
|
// !(contentParamName in currentToolUse.params) && // Only if not already parsed.
|
|
toolContentSlice.includes(`<${contentParamName}>`) // Check if tag exists.
|
|
) {
|
|
const contentStartTag = `<${contentParamName}>`
|
|
const contentEndTag = `</${contentParamName}>`
|
|
const contentStart = toolContentSlice.indexOf(contentStartTag)
|
|
|
|
// Use `lastIndexOf` for robustness against nested tags.
|
|
const contentEnd = toolContentSlice.lastIndexOf(contentEndTag)
|
|
|
|
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) {
|
|
// Don't trim content to preserve newlines, but strip first and last newline only
|
|
const contentValue = toolContentSlice
|
|
.slice(contentStart + contentStartTag.length, contentEnd)
|
|
.replace(/^\n/, "")
|
|
.replace(/\n$/, "")
|
|
currentToolUse.params[contentParamName] = contentValue
|
|
}
|
|
}
|
|
|
|
currentToolUse.partial = false // Mark as complete.
|
|
contentBlocks.push(currentToolUse)
|
|
currentToolUse = undefined // Reset state.
|
|
currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag.
|
|
continue // Move to next char.
|
|
}
|
|
|
|
// If not starting a param and not closing the tool, continue
|
|
// accumulating tool content implicitly.
|
|
continue
|
|
}
|
|
|
|
// Parsing text / looking for tool start.
|
|
if (!currentToolUse) {
|
|
// Check if starting a new tool use.
|
|
let startedNewTool = false
|
|
|
|
for (const [tag, toolName] of toolUseOpenTags.entries()) {
|
|
if (
|
|
currentCharIndex >= tag.length - 1 &&
|
|
assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)
|
|
) {
|
|
// End current text block if one was active.
|
|
if (currentTextContent) {
|
|
currentTextContent.content = assistantMessage
|
|
.slice(
|
|
currentTextContentStart, // From where text started.
|
|
currentCharIndex - tag.length + 1, // To before the tool tag starts.
|
|
)
|
|
.trim()
|
|
|
|
currentTextContent.partial = false // Ended because tool started.
|
|
|
|
if (currentTextContent.content.length > 0) {
|
|
contentBlocks.push(currentTextContent)
|
|
}
|
|
|
|
currentTextContent = undefined
|
|
} else {
|
|
// Check for any text between the last block and this tag.
|
|
const potentialText = assistantMessage
|
|
.slice(
|
|
currentTextContentStart, // From where text *might* have started.
|
|
currentCharIndex - tag.length + 1, // To before the tool tag starts.
|
|
)
|
|
.trim()
|
|
|
|
if (potentialText.length > 0) {
|
|
contentBlocks.push({
|
|
type: "text",
|
|
content: potentialText,
|
|
partial: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Start the new tool use.
|
|
currentToolUse = {
|
|
type: "tool_use",
|
|
name: toolName,
|
|
params: {},
|
|
partial: true, // Assume partial until closing tag is found.
|
|
}
|
|
|
|
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag.
|
|
startedNewTool = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if (startedNewTool) {
|
|
continue // Handled start of tool, move to next char.
|
|
}
|
|
|
|
// If not starting a tool, it must be text content.
|
|
if (!currentTextContent) {
|
|
// Start a new text block if we aren't already in one.
|
|
currentTextContentStart = currentCharIndex // Text starts at the current character.
|
|
|
|
// Check if the current char is the start of potential text *immediately* after a tag.
|
|
// This needs the previous state - simpler to let slicing handle it later.
|
|
// Resetting start index accurately is key.
|
|
// It should be the index *after* the last processed tag.
|
|
// The logic managing currentTextContentStart after closing tags handles this.
|
|
currentTextContent = {
|
|
type: "text",
|
|
content: "", // Will be determined by slicing at the end or when a tool starts
|
|
partial: true,
|
|
}
|
|
}
|
|
// Continue accumulating text implicitly; content is extracted later.
|
|
}
|
|
}
|
|
|
|
// Finalize any open parameter within an open tool use.
|
|
if (currentToolUse && currentParamName) {
|
|
const value = assistantMessage.slice(currentParamValueStart) // From param start to end of string.
|
|
// Don't trim content parameters to preserve newlines, but strip first and last newline only
|
|
currentToolUse.params[currentParamName] =
|
|
currentParamName === "content" ? value.replace(/^\n/, "").replace(/\n$/, "") : value.trim()
|
|
// Tool use remains partial.
|
|
}
|
|
|
|
// Finalize any open tool use (which might contain the finalized partial param).
|
|
if (currentToolUse) {
|
|
// Tool use is partial because the loop finished before its closing tag.
|
|
contentBlocks.push(currentToolUse)
|
|
}
|
|
// Finalize any trailing text content.
|
|
// Only possible if a tool use wasn't open at the very end.
|
|
else if (currentTextContent) {
|
|
currentTextContent.content = assistantMessage
|
|
.slice(currentTextContentStart) // From text start to end of string.
|
|
.trim()
|
|
|
|
// Text is partial because the loop finished.
|
|
if (currentTextContent.content.length > 0) {
|
|
contentBlocks.push(currentTextContent)
|
|
}
|
|
}
|
|
|
|
return contentBlocks
|
|
}
|