parseAssistantMessageV2: Text-Stream-Block-Parser des LLM
Verantwortung
parseAssistantMessageV2 ist Clines Parser, der den vom LLM gestreamten rohen String in strukturierte Blöcke zerlegt, geschrieben in apps/vscode/src/core/assistant-message/parse-assistant-message.ts. Eingabe ist der kumulierte Assistant-Text, Ausgabe ist AssistantMessageContent[], wobei jedes Element eines von TextStreamContent, ToolUse oder ReasoningStreamContent ist. Cline verlässt sich nicht auf die native tool_use-API des LLM, sondern nutzt ein XML-artiges Tag-Protokoll (<read_file>...</read_file>, <write_to_file><content>...</content></write_to_file>), um das Modell Werkzeugaufrufe in normalen Text einzubetten; dieser Parser ist der Kern, der diesen halbstrukturierten Text zerlegt.
Seine Position in der Agent-Loop ist klar: recursivelyMakeClineRequests startet attemptApiRequest, um den Stream zu erhalten; bei jedem empfangenen Text-Stück im Stream-Callback wird assistantMessage += chunk.text kumuliert und dann sofort parseAssistantMessageV2(assistantMessage) aufgerufen, um die gesamte Zeichenkette neu zu parsen (parseAssistantMessageV2 call:3508). Das geparste Block-Array wird in taskState.assistantMessageContent gespeichert; wenn die Länge wächst, wird scheduleAssistantPresentation aufgerufen, damit presentAssistantMessage voranschreitet. Diese Funktion kann also dutzende Male pro Sekunde aufgerufen werden und muss ein O(n)-Einzeldurchlauf sein.
Entwurfsmotivation
- Ganze Zeichenkette neu parsen statt inkrementell: Bei jedem neuen Text-Stück wird die gesamte
assistantMessageneu geparst. Scheint verschwenderisch, aber da der LLM-Stream zwischendurch frühere Tags reparieren kann (z. B.</parameter>schließen), würde eine inkrementelle Zustandsmaschine durch solche Reparaturen durcheinander geraten. Ein kompletter Neuparse ist am robustesten; außerdem ist der Assistant-Text normalerweise einige KB groß, ein O(n)-Scan schnell genug. endsWith-artige Tag-Erkennung: Kein Vorab-Tokenize, sondern ein Charakter-weises Vorgehen ab i=0; an jeder Position wird geprüft, ob der Teilstring, der an i endet, einem Öffnungs-/Schließ-Tag entspricht (close tag check:56). Diese Schreibweise ist fehlertolerant gegenüber gelegentlich zu viel oder zu wenig ausgegebenen Zeichen des LLM; solange das Tag insgesamt geschlossen werden kann, ist es in Ordnung.- Vorab berechnete Öffnungs-Tag-Maps:
toolUseOpenTagsundtoolParamOpenTagssind beideMap<string, name>, die außerhalb der Schleife einmalig aufgebaut werden (precompute maps:38); in der Schleife wird nur die Map abgefragt, nicht das Array gescannt. Werkzeug- und Parameternamen stammen aus der WhitelistgetToolUseNames()/toolParamNames; Nicht-Whitelist-Tags werden als Text behandelt. - partial-Flag durchgängig: Ein Block, der gerade gestreamt wird und dessen Schließ-Tag noch nicht gesehen wurde, wird mit
partial: truemarkiert (partial true:178); der nachgelagertepresentAssistantMessageweiß bei einem partial-Block, dass er noch unvollständig ist, und führt nur inkrementelle UI-Updates durch, ohne das Werkzeug wirklich auszuführen. Nach Stream-Ende wirdpartialauf false erzwungen, damit der nachgelagerte Part finalisieren kann. - Sonderbehandlung von write_to_file-content: Der
<content>-Parameter vonwrite_to_filekann Code enthalten, der wie ein Schließ-Tag aussieht (z. B. wenn das Modell eine XML-Datei schreibt);indexOf+lastIndexOfnutzen first/last-Anker, um die echte Schließ-Position zu finden (content lastIndexOf:119), um zu verhindern, dass ein falsches mittleres Schließ-Tag den Inhalt abschneidet. - Finalize am Stream-Ende: Wenn die Schleife normal endet und noch未geschlossene tool use oder text vorhanden sind, werden sie als partial in contentBlocks gepusht (
finalize partial:223). So bekommt der nachgelagerte Part selbst bei Stream-Abbruch ein Halbprodukt.
Schlüsseldateien
parseAssistantMessageV2:28— Funktionseinstieg, Signatur(assistantMessage: string) => AssistantMessageContent[].precompute maps:38— baut<tool_name>- und<param_name>-Tags vorab als Map auf, in der Schleife O(1)-Lookup.param state:52— im Parameterwert-Zustand wird geprüft, ob die aktuelle Position ein</param_name>-Schließ-Tag ist.tool use state:78— im tool use, aber nicht im Parameterwert-Zustand, wird geprüft, ob ein neuer Parameter beginnt oder das Werkzeug geschlossen wird.tool close tag:95— bei Treffer von</tool_name>wird tool alspartial: falsemarkiert und in contentBlocks gepusht.content special:111— write_to_file-content-Parameter nutztlastIndexOf, um das echte Schließ-Tag zu finden und ein falsches mittleres zu verhindern.text state:138— weder in tool use noch in Parameter: im Text-Zustand scannen und prüfen, ob ein neues Werkzeug beginnt.new tool use:174— bei Treffer des Öffnungs-Tags wird zuerst der vorherige text-Block finalisiert, dann{ type: "tool_use", partial: true }erstellt.finalize partial:215— nach Schleifenende werden verbleibende tool use- / text-Blöcke als partial zurückgepusht.AssistantMessageContent:3— Union-TypTextStreamContent | ToolUse | ReasoningStreamContent.toolParamNames:13— Parameternamen-Whitelist (command, path, content, diff etc.); Nicht-Whitelist-Tags werden als Text behandelt.ToolUse:63— Werkzeugaufruf-Struktur, mitname,params,partial,call_id,isNativeToolCall,signature.parseAssistantMessageV2 call:3508— Aufrufstelle, bei jedem Text-Stück im Stream-Callback wird die gesamte Zeichenkette neu geparst.
Datenfluss
Die Hauptschleife des Parsers ist «zeichenweises Scannen: beim Öffnungs-Tag in den tool use-Zustand wechseln, beim Schließ-Tag zurück in den text-Zustand». Das folgende Stück ist der Kern, wenn man sich im tool use, aber nicht im Parameterwert-Zustand befindet, und prüft, ob ein neuer Parameter beginnt oder das Werkzeug geschlossen wird:
// 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 special handling ...
currentToolUse.partial = false // Mark as complete
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Reset state
currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
continue
}
continue
}Dieses Stück liegt nahe tool use state:78. Beachte, dass die Tag-Prüfung über assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1) erfolgt, was bedeutet: «Entspricht der Teilstring, der an der aktuellen Position endet, dem Tag». Das ist äquivalent zu assistantMessage.slice(i - tag.length + 1, i + 1) === tag, aber ohne echtes slice, mit besserer Performance. Der Aufrufer erhält nach parse site:3506 das neue Array, vergleicht prevLength und contentBlocks.length; bei Längenzunahme wird userMessageContentReady = false zurückgesetzt, damit presentAssistantMessage weiß, dass neuer Inhalt voranzutreiben ist. Nach Stream-Ende werden verbleibende partial-Blöcke per partial = false erzwungen finalisiert, damit der nachgelagerte Part finalisieren und die Rekursion auslösen kann (force partial false:3792).
Grenzen und Fehler
- 未geschlossene Tags: Beim Stream-Abbruch hat der tool use kein
</tool_name>erreicht; nach normalem Schleifenende wird er durch finalize als partial zurückgepusht (finalize partial:223); der nachgelagertepresentAssistantMessagesieht partial=true und führt das Werkzeug nicht wirklich aus, sondern wartet auf die nächste Runde. - write_to_file mit eingebettetem falschem Schließ-Tag: Wenn das Modell eine XML-Datei schreibt, sieht
</content>innerhalb von<content>wie ein Schließ-Tag aus, ist aber Dateiinhalt.lastIndexOfsucht vom Ende des toolContentSlice rückwärts nach dem echten Schließ-Tag (lastIndexOf:119), um die äußerste Schließ-Position zu finden. - Unbekannter Werkzeugname: Tags außerhalb der Whitelist (z. B. ein halluziniertes
<analyze_file>) werden als Plain-Text behandelt (toolParamNames whitelist:13); es wird kein ToolUse erstellt, sondern der Chat erhält nur einen Text mit spitzen Klammern. - Leerer content-Parameter: Das
<content>-Tag vonwrite_to_filekann aufgrund eines Parameter-Parse-Fehlers leer bleiben; beim Schließen des tool use wird noch einmal gescannt (content check:113), um content aus dem toolContentSlice herauszuschneiden und nachzufüllen. - Wiederholte Streaming-Aufrufe: Jedes Text-Stück parst die gesamte Zeichenkette neu; das bedeutet, dass bereits finalisierte tool use erneut geparst werden. Da das Schließ-Tag aber noch da ist, ist das Ergebnis konsistent.
call_idwird mitnanoid(8)jedes Mal neu generiert (nanoid call_id:179), sodass derselbe tool use in mehreren Parses unterschiedlichecall_idhat; der nachgelagertepresentAssistantMessageverfolgt aber übercurrentStreamingContentIndexnach Block-Position, nicht nach call_id, sodass es nicht zu Verwirrung kommt. - trailing whitespace:
slice().trim()entfernt Whitespace an beiden Enden des Parameterwerts (trim value:68), sodass zusätzlich ausgegebene Zeilenumbrüche des Modells nicht path, command und andere Parameter verschmutzen.
Zusammenfassung
parseAssistantMessageV2 ist der Parsing-Kern von Clines «XML-Protokoll über Plain-Text»-Ansatz. Es nutzt einen ganzen Neuparse + End-Tag-Erkennung + vorab berechnete Map, um den O(n)-Einzelscan schnell genug zu machen; das partial-Flag durchgängig, sodass der nachgelagerte Part zwischen «gerade streamendem Halbprodukt» und «geschlossenem, ausführbarem Block» unterscheiden kann. Um zu sehen, wie die geparsten Blöcke in UI und Werkzeug-Executor gepusht werden, gehe zu /agent-loop/present-assistant-message; um zu sehen, wie die äußere Rekursion diese Parse-Schleife antreibt, gehe zu /agent-loop/recursion.
Siehe offizielle Dokumentation: Cline-Dokumentation · README