Skip to content

parseAssistantMessageV2: segmentador del flujo de texto del LLM ​

源码版本v4.0.10

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: toolUseOpenTags y toolParamOpenTags son Map<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 blanca getToolUseNames() / 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 downstream presentAssistantMessage, al ver partial, sabe que no está completo y solo hace actualización incremental de UI sin ejecutar la herramienta. Al terminar el stream, partial se fuerza a false para que el downstream pueda finalizar.
  • Manejo especial de content en write_to_file: el parámetro <content> de write_to_file puede contener código que parece etiqueta de cierre (p. ej. si el modelo escribe un archivo XML); indexOf + lastIndexOf con 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 como partial: false y lo empuja a contentBlocks.
  • content special:111 — para el parámetro content de write_to_file, usa lastIndexOf para 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ón TextStreamContent | 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, con name, 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:

typescript
// 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 downstream presentAssistantMessage al 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. lastIndexOf busca 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> de write_to_file puede 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_id se regenera con nanoid(8) en cada parseo (nanoid call_id:179), de modo que un mismo tool use tiene call_id distinto entre llamadas; pero el downstream presentAssistantMessage rastrea por posición de bloque con currentStreamingContentIndex y 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.

Véase la documentación oficial: Cline 文档 · README