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