/
outbreak
/
kilocode
Обзор
Документация
Войти
/
outbreak
/
kilocode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/core/assistant-message/parseAssistantMessageV2.ts
281 строка
10 KB
Thomas Brugman
Fix: Allow write_to_file to handle newline-only and empty content (#3550)
24 июн 2025, 01:43
Не верифицирован
24 июн 2025, 01:43
245c8f3
Код
Авторство
О чём код?
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 }