Skip to content

parseAssistantMessageV2 : découpeur du flux texte LLM en blocs

源码版本v4.0.10

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 : toolUseOpenTags et toolParamOpenTags sont des Map<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 blanche getToolUseNames() / 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 le presentAssistantMessage en 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, partial est forcé à false pour que l'aval puisse finaliser.
  • Traitement spécial du content de write_to_file : le paramètre <content> de write_to_file peut contenir du code ressemblant à une balise fermante (par exemple si le modèle écrit un fichier XML), on utilise indexOf + lastIndexOf avec 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 comme partial: false et le pousse dans contentBlocks.
  • content special:111 — pour le paramètre content de write_to_file, utilise lastIndexOf pour 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 union TextStreamContent | 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, avec name, 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 :

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
}

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 le presentAssistantMessage en 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. lastIndexOf recherche 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> de write_to_file peut 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_id est régénéré à chaque fois via nanoid(8) (nanoid call_id:179), donc un même tool use a des call_id différents d'une analyse à l'autre, mais le presentAssistantMessage en aval suit par position de bloc via currentStreamingContentIndex et 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.