parseAssistantMessageV2 : découpeur du flux texte LLM en blocs
Responsabilités
parseAssistantMessageV2 est l'analyseur par lequel Cline découpe la chaîne brute produite en flux par le LLM en blocs structurés, écrit dans apps/vscode/src/core/assistant-message/parse-assistant-message.ts. Il prend en entrée le texte assistant cumulé, et renvoie AssistantMessageContent[], dont chaque élément est l'un de trois types : TextStreamContent, ToolUse ou ReasoningStreamContent. Cline ne s'appuie pas sur l'API native tool_use du LLM, mais utilise un protocole à balises de style XML (<read_file>...</read_file>, <write_to_file><content>...</content></write_to_file>) pour que le modèle embarque des appels d'outils dans du texte ordinaire ; cet analyseur est le cœur qui désassemble ce texte semi-structuré.
Sa position dans la boucle agent est claire : recursivelyMakeClineRequests lance attemptApiRequest pour récupérer le flux, et à chaque morceau de texte reçu, le callback fait assistantMessage += chunk.text puis appelle immédiatement parseAssistantMessageV2(assistantMessage) pour réanalyser tout le bloc (parseAssistantMessageV2 call:3508). Le tableau de blocs produit est stocké dans taskState.assistantMessageContent, et quand la longueur augmente on appelle scheduleAssistantPresentation pour que presentAssistantMessage avance. Cette fonction peut donc être appelée des dizaines de fois par seconde, et doit rester O(n) en un seul passage.
Motivation de conception
- Réanalyse complète plutôt qu'incrémentale : à chaque morceau de texte, on relance tout
assistantMessage. Ça paraît coûteux, mais comme le LLM peut réparer en cours de route des balises précédentes (par exemple refermer un</parameter>oublié), un automate incrémental serait perturbé par ces réparations. La réanalyse complète est la plus robuste, et le texte assistant ne fait généralement que quelques Ko, un balayage O(n) est suffisamment rapide. - Détection de balises en
endsWith: pas de pré-tokenization, on avance caractère par caractère depuis i=0, et à chaque position on vérifie « la sous-chaîne finissant à i correspond-elle à une balise ouvrante/fermante » (close tag check:56). Cette approche tolère bien les oublis ou caractères en trop du LLM, du moment que la balise finit par se fermer. - Map de balises ouvrantes précalculée :
toolUseOpenTagsettoolParamOpenTagssont desMap<string, name>construits une fois pour toutes hors de la boucle (precompute maps:38), et dans la boucle on ne fait que des requêtes Map, pas de scan de tableau. Les noms d'outils et de paramètres viennent de la liste blanchegetToolUseNames()/toolParamNames, et les balises hors liste blanche sont traitées comme du texte. - Le flag partial traverse tout : un bloc en cours de streaming, qui n'a pas encore vu sa balise fermante, est marqué
partial: true(partial true:178), et lepresentAssistantMessageen aval sait qu'il est incomplet, il ne fait qu'une mise à jour UI incrémentale sans exécuter l'outil. À la fin du flux,partialest forcé à false pour que l'aval puisse finaliser. - Traitement spécial du content de write_to_file : le paramètre
<content>dewrite_to_filepeut contenir du code ressemblant à une balise fermante (par exemple si le modèle écrit un fichier XML), on utiliseindexOf+lastIndexOfavec ancrage début/fin pour localiser la véritable position de fermeture (content lastIndexOf:119), pour empêcher une fausse balise fermante intermédiaire de tronquer le contenu. - Finalize en fin de flux : à la fin normale de la boucle, s'il reste un tool use ou un text non fermé, on les pousse comme partial dans contentBlocks (
finalize partial:223). Ainsi, même en cas d'interruption du flux, l'aval récupère un demi-produit.
Fichiers clés
parseAssistantMessageV2:28— point d'entrée, signature(assistantMessage: string) => AssistantMessageContent[].precompute maps:38— pré-construit les balises<tool_name>et<param_name>en Map, pour des requêtes O(1) dans la boucle.param state:52— en état de valeur de paramètre, vérifie si la position courante est une balise fermante</param_name>.tool use state:78— en tool use mais pas en valeur de paramètre, vérifie si un nouveau paramètre commence ou si l'outil se ferme.tool close tag:95— sur</tool_name>, marque le tool commepartial: falseet le pousse dans contentBlocks.content special:111— pour le paramètre content de write_to_file, utiliselastIndexOfpour trouver la véritable fermeture, afin d'éviter les fausses balises fermantes intermédiaires.text state:138— ni en tool use ni en paramètre, on scanne en état texte en vérifiant si un nouvel outil commence.new tool use:174— sur balise ouvrante, finalise d'abord le bloc texte précédent, puis crée{ type: "tool_use", partial: true }.finalize partial:215— à la fin de la boucle, les tool use / text résiduels sont repoussés comme partial.AssistantMessageContent:3— type unionTextStreamContent | ToolUse | ReasoningStreamContent.toolParamNames:13— liste blanche de noms de paramètres (command, path, content, diff, etc.), les balises hors liste sont traitées comme du texte.ToolUse:63— structure d'appel d'outil, avecname,params,partial,call_id,isNativeToolCall,signature.parseAssistantMessageV2 call:3508— point d'appel, le callback de flux réanalyse tout le bloc à chaque morceau de texte.
Flux de données
La boucle principale de l'analyseur se résume à « scanner caractère par caractère, quand on voit une balise ouvrante on bascule en état tool use, quand on voit une balise fermante on bascule en état texte ». Voici le cœur, en état tool use mais hors valeur de paramètre, qui vérifie si un nouveau paramètre commence ou si l'outil se ferme :
// 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
}Ce bloc se trouve près de tool use state:78. À noter, la vérification de balise se fait via assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1), c'est-à-dire « la sous-chaîne finissant à la position courante est-elle égale à tag ». Cela équivaut à assistantMessage.slice(i - tag.length + 1, i + 1) === tag, mais sans vraiment slicer, pour de meilleures performances. L'appelant à parse site:3506 récupère le nouveau tableau, compare prevLength et contentBlocks.length, et si la longueur augmente, il remet userMessageContentReady = false pour signaler à presentAssistantMessage qu'il y a du nouveau contenu à pousser. À la fin du flux, les blocs partial résiduels sont forcés à partial = false, pour que l'aval puisse finaliser et déclencher la récursion (force partial false:3792).
Limites et échecs
- Balise non fermée : si le flux est interrompu alors que le tool use n'a pas reçu son
</tool_name>, la finalize en fin de boucle le pousse comme partial (finalize partial:223), et lepresentAssistantMessageen aval, voyant partial=true, n'exécute pas l'outil, dans l'attente d'une retry au prochain tour. - Fausse balise fermante imbriquée dans write_to_file : quand le modèle écrit un fichier XML, le
</content>à l'intérieur de<content>ressemble à une balise fermante alors que c'est du contenu de fichier.lastIndexOfrecherche depuis la fin du toolContentSlice vers le début (lastIndexOf:119), garantissant qu'on prend la position de fermeture la plus externe. - Nom d'outil inconnu : les balises hors liste blanche (par exemple
<analyze_file>halluciné par le modèle) sont traitées comme du texte pur (toolParamNames whitelist:13), sans création de ToolUse, et le chat se contente d'une ligne supplémentaire avec des chevrons. - Paramètre content vide : la balise
<content>dewrite_to_filepeut rester vide si l'analyse des paramètres a oublié de la remplir ; à la fermeture du tool use on rescanne (content check:113) pour extraire le content de toolContentSlice et le rajouter. - Appels répétés en flux : chaque morceau de texte réanalyse tout le bloc, ce qui signifie que les tool use déjà finalisés sont réanalysés à chaque fois, mais comme la balise fermante est toujours là, le résultat est identique.
call_idest régénéré à chaque fois viananoid(8)(nanoid call_id:179), donc un même tool use a descall_iddifférents d'une analyse à l'autre, mais lepresentAssistantMessageen aval suit par position de bloc viacurrentStreamingContentIndexet non par call_id, donc pas de désordre. - Trailing whitespace :
slice().trim()supprime les espaces aux deux extrémités de la valeur du paramètre (trim value:68), pour que les sauts de ligne en trop du modèle ne polluent pas path, command, etc.
Résumé
parseAssistantMessageV2 est le cœur analytique de la solution « protocole XML sur texte brut » de Cline. La réanalyse complète + détection de balises en fin + Map précalculée font qu'un balayage O(n) reste suffisamment rapide, et le flag partial traverse tout pour que l'aval distingue « demi-produit en cours de flux » et « bloc fermé exécutable ». Pour voir comment les blocs analysés sont poussés vers l'UI et les exécuteurs d'outils, aller à /agent-loop/present-assistant-message ; pour la récursion externe qui pilote cette boucle d'analyse, aller à /agent-loop/recursion.
Voir la documentation officielle : Cline docs · README.