parseAssistantMessageV2: segmentador del flujo de texto del LLM
Responsabilidades
parseAssistantMessageV2 es el parser de Cline que recorta la cadena cruda que el LLM emite en streaming en bloques estructurados. Vive en apps/vscode/src/core/assistant-message/parse-assistant-message.ts. Su entrada es el texto acumulado del asistente; la salida es AssistantMessageContent[], donde cada elemento es uno de tres tipos: TextStreamContent, ToolUse o ReasoningStreamContent. Cline no depende de la API nativa tool_use del LLM; en su lugar usa un protocolo de etiquetas estilo XML (<read_file>...</read_file>, <write_to_file><content>...</content></write_to_file>) para que el modelo embeda llamadas a herramienta en texto plano, y este parser es quien desmonta ese texto semi-estructurado.
Su posición en el agent loop (bucle del agente) es clara: recursivelyMakeClineRequests lanza attemptApiRequest para obtener el stream; el callback del stream, cada vez que recibe un fragmento de texto, hace assistantMessage += chunk.text y llama enseguida a parseAssistantMessageV2(assistantMessage) para re-parsear todo el texto (parseAssistantMessageV2 call:3508). El arreglo de bloques resultante se guarda en taskState.assistantMessageContent; cuando su longitud crece, se invoca scheduleAssistantPresentation para que presentAssistantMessage avance. Así que esta función puede llamarse decenas de veces por segundo: debe ser O(n) en un único pase.
Motivación de diseño
- Re-parseo completo en vez de incremental: cada fragmento de texto recibido dispara un re-run de todo
assistantMessage. Parece desperdicio, pero como el stream del LLM puede reparar etiquetas a mitad de camino (p. ej. cerrar</parameter>), una máquina de estados incremental se rompería con esas reparaciones. Re-parsear todo es lo más robusto, y como el texto del asistente suele ser de unos pocos KB, un escaneo O(n) es suficientemente rápido. - Detección de etiquetas estilo
endsWith: no se tokeniza previamente; se avanza desde i=0 carácter por carácter y, en cada posición, se comprueba «¿la subcadena que termina en i matchea alguna etiqueta de apertura o cierre?» (close tag check:56). Esta forma tolera bien que el LLM emita caracteres de más o de menos: solo importa que la etiqueta cierre completa. - Mapa de etiquetas de apertura precalculado:
toolUseOpenTagsytoolParamOpenTagssonMap<string, name>que se construyen una sola vez fuera del bucle (precompute maps:38); dentro del bucle solo se consulta el Map, no se recorre un arreglo. Los nombres de herramientas y parámetros provienen de la lista blancagetToolUseNames()/toolParamNames; etiquetas fuera de la lista se tratan como texto. - Flag partial transitivo: los bloques cuyo stream aún no vio etiqueta de cierre se marcan
partial: true(partial true:178); el downstreampresentAssistantMessage, al ver partial, sabe que no está completo y solo hace actualización incremental de UI sin ejecutar la herramienta. Al terminar el stream,partialse fuerza a false para que el downstream pueda finalizar. - Manejo especial de content en write_to_file: el parámetro
<content>dewrite_to_filepuede contener código que parece etiqueta de cierre (p. ej. si el modelo escribe un archivo XML);indexOf+lastIndexOfcon anclas primera/última localiza la posición real de cierre (content lastIndexOf:119), evitando que una etiqueta de cierre falsa en el medio trunque el contenido. - Finalización al final del stream: al salir del bucle normalmente, si quedan bloques tool use o texto sin cerrar, se tratan como partial y se empujan a contentBlocks (
finalize partial:223). Así, si el stream se interrumpe, el downstream también obtiene un producto a medio hacer.
Archivos clave
parseAssistantMessageV2:28— entrada de la función, con firma(assistantMessage: string) => AssistantMessageContent[].precompute maps:38— precalcula como Map las etiquetas<tool_name>y<param_name>; consulta O(1) dentro del bucle.param state:52— en estado valor de parámetro, comprueba si la posición actual es etiqueta de cierre</param_name>.tool use state:78— en tool use pero no en valor de parámetro, comprueba si empieza un parámetro nuevo o si se cierra la herramienta.tool close tag:95— al matchear</tool_name>, marca el tool comopartial: falsey lo empuja a contentBlocks.content special:111— para el parámetro content de write_to_file, usalastIndexOfpara hallar el cierre real, evitando falsos cierres intermedios.text state:138— ni en tool use ni en parámetro; escanea en estado texto, comprobando si empieza una herramienta nueva.new tool use:174— al matchear etiqueta de apertura, primero finaliza el text block previo, luego crea{ type: "tool_use", partial: true }.finalize partial:215— al salir del bucle, los bloques tool use / text residuales se empujan como partial.AssistantMessageContent:3— uniónTextStreamContent | ToolUse | ReasoningStreamContent.toolParamNames:13— lista blanca de nombres de parámetros (command, path, content, diff, etc.); etiquetas fuera de la lista se tratan como texto.ToolUse:63— estructura de la llamada a herramienta, conname,params,partial,call_id,isNativeToolCall,signature.parseAssistantMessageV2 call:3508— punto de llamada; el callback del stream re-parsea todo en cada fragmento de texto.
Flujo de datos
El bucle principal del parser es «escanear carácter por carácter; al ver etiqueta de apertura, cambia a estado tool use; al ver etiqueta de cierre, vuelve a estado text». Este fragmento es el núcleo que, estando en tool use pero no en valor de parámetro, comprueba si empieza un parámetro nuevo o si se cierra la herramienta:
// apps/vscode/src/core/assistant-message/parse-assistant-message.ts
// --- State: Parsing a Tool Use (but not a specific parameter) ---
if (currentToolUse && !currentParamName) {
// 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
// ... write_to_file content 特殊处理 ...
currentToolUse.partial = false // Mark as complete
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Reset state
currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
continue
}
continue
}Esto está cerca de tool use state:78. Ojo: la comprobación de etiqueta se hace con assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1), es decir, «la subcadena que termina en la posición actual ¿es igual a tag?». Equivale a assistantMessage.slice(i - tag.length + 1, i + 1) === tag pero sin hacer slice, con mejor rendimiento. El llamador, al recibir el arreglo nuevo (parse site:3506), compara prevLength con contentBlocks.length; si creció, resetea userMessageContentReady = false para que presentAssistantMessage sepa que hay contenido nuevo que presentar. Al terminar el stream, los partial block residuales se fuerzan a partial = false para que el downstream pueda finalizar y disparar la recursión (force partial false:3792).
Límites y fallos
- Etiqueta sin cerrar: si el stream se interrumpe sin haber llegado a
</tool_name>, al salir del bucle el finalize lo empuja como partial (finalize partial:223); el downstreampresentAssistantMessageal ver partial=true no ejecuta la herramienta, esperando reintento en la próxima ronda. - Etiqueta de cierre falsa embebida en write_to_file: cuando el modelo escribe un archivo XML, el
</content>dentro de<content>parece cierre pero es contenido del archivo.lastIndexOfbusca desde el final de toolContentSlice hacia atrás (lastIndexOf:119), garantizando la posición de cierre más externa. - Nombre de herramienta desconocido: las etiquetas fuera de la lista blanca (p. ej. una alucinación
<analyze_file>) se tratan como texto plano (toolParamNames whitelist:13); no se crea ToolUse, solo aparece en el chat un trozo de texto con ángulos. - Parámetro content vacío: la etiqueta
<content>dewrite_to_filepuede quedar sin valor por un fallo de parseo de parámetros; al cerrar el tool use se reescanea (content check:113) y se extrae content de toolContentSlice para rellenarlo. - Llamada repetida en streaming: cada fragmento de texto re-parsea todo, lo que significa que los tool use ya finalizados se vuelven a parsear; como la etiqueta de cierre sigue presente, el resultado es consistente.
call_idse regenera connanoid(8)en cada parseo (nanoid call_id:179), de modo que un mismo tool use tienecall_iddistinto entre llamadas; pero el downstreampresentAssistantMessagerastrea por posición de bloque concurrentStreamingContentIndexy no por call_id, así que no se desordena. - Trailing whitespace:
slice().trim()elimina whitespace a ambos lados del valor del parámetro (trim value:68), de modo que los saltos de línea extra del modelo no contaminan parámetros como path o command.
Resumen
parseAssistantMessageV2 es el núcleo del enfoque de Cline «protocolo XML sobre texto plano». Usa re-parseo completo + detección de etiquetas por sufijo + Map precalculado para correr un único escaneo O(n) suficientemente rápido; el flag partial permite al downstream distinguir «producto a medio streaming» de «bloque cerrado y ejecutable». Para ver cómo los bloques parseados se empujan a la UI y al ejecutor de herramientas, ver /agent-loop/present-assistant-message; para ver cómo la recursión exterior impulsa este ciclo de parseo, ver /agent-loop/recursion.