Skip to content

parseAssistantMessageV2: the LLM stream text chunker

源码版本v4.0.10

Responsibilities

parseAssistantMessageV2 is Cline's parser that slices the raw string streamed from the LLM into structured blocks. It lives in apps/vscode/src/core/assistant-message/parse-assistant-message.ts. Its input is the accumulated assistant text; its output is AssistantMessageContent[], where each element is one of TextStreamContent, ToolUse, or ReasoningStreamContent. Cline does not rely on the LLM's native tool_use API. Instead it uses an XML-style tag protocol (<read_file>...</read_file>, <write_to_file><content>...</content></write_to_file>) that lets the model embed tool calls inside plain text; this parser is the core that breaks that semi-structured text apart.

Its position in the agent loop is well-defined: recursivelyMakeClineRequests kicks off attemptApiRequest to get the stream; the stream callback appends each text chunk with assistantMessage += chunk.text and then immediately calls parseAssistantMessageV2(assistantMessage) to re-parse the whole thing (parseAssistantMessageV2 call:3508). The parsed block array is stored in taskState.assistantMessageContent, and when its length grows scheduleAssistantPresentation is called so presentAssistantMessage can advance. That means this function may be called dozens of times per second, so each call must be a single O(n) pass.

Design motivation

  • Re-parse the whole thing rather than incremental: every new text chunk re-runs the entire assistantMessage. It looks wasteful, but the LLM stream may repair an earlier tag mid-stream (e.g. closing a </parameter> it forgot earlier), and an incremental state machine gets confused by those repairs. Full re-parse is the most robust approach, and since assistant text is usually a few KB, an O(n) pass is fast enough.
  • endsWith-style tag probing: there is no pre-tokenization. Starting from i=0 the parser walks character by character, and at each position checks "does the substring ending at i match some open/close tag" (close tag check:56). This style tolerates the LLM occasionally emitting an extra or missing character — as long as the tag closes as a whole, it works.
  • Precomputed open-tag maps: toolUseOpenTags and toolParamOpenTags are both Map<string, name> built once outside the loop (precompute maps:38); inside the loop only Map lookups happen, no array scans. Tool names and parameter names come from the whitelists getToolUseNames() / toolParamNames; tags outside the whitelist are treated as text.
  • The partial flag throughout: a block that is still being streamed and has not yet seen its closing tag is marked partial: true (partial true:178). When presentAssistantMessage downstream receives a partial block, it knows it is incomplete and only does incremental UI updates without actually executing the tool. When the stream ends, partial is forced to false so downstream can finalize.
  • Special handling for write_to_file's content: the <content> parameter of write_to_file may contain code that looks like a closing tag (e.g. when the model is writing an XML file). indexOf + lastIndexOf with first/last anchors locates the real closing position (content lastIndexOf:119), preventing a fake closing tag in the middle from truncating the content.
  • Finalize at end of stream: after the loop ends normally, any unclosed tool use or text remaining is pushed into contentBlocks as partial (finalize partial:223). That way, even if the stream is interrupted, downstream still gets the half-finished product.

Key files

  • parseAssistantMessageV2:28 — function entry; signature is (assistantMessage: string) => AssistantMessageContent[].
  • precompute maps:38 — builds <tool_name> and <param_name> tags into Maps for O(1) lookup inside the loop.
  • param state:52 — in parameter-value state, checks whether the current position is a </param_name> closing tag.
  • tool use state:78 — in tool-use-but-not-parameter-value state, checks whether a new parameter starts or the tool closes.
  • tool close tag:95 — when </tool_name> hits, marks the tool as partial: false and pushes it into contentBlocks.
  • content special:111 — write_to_file's content parameter uses lastIndexOf to find the real closing position, preventing fake inner closing tags.
  • text state:138 — in neither tool-use nor parameter state, scans in text state, checking whether a new tool starts.
  • new tool use:174 — on hitting an open tag, first finalizes the preceding text block, then creates { type: "tool_use", partial: true }.
  • finalize partial:215 — after the loop, leftover tool use / text blocks are pushed back as partial.
  • AssistantMessageContent:3 — the union type TextStreamContent | ToolUse | ReasoningStreamContent.
  • toolParamNames:13 — parameter-name whitelist (command, path, content, diff, etc.); non-whitelist tags are treated as text.
  • ToolUse:63 — tool-call structure, carrying name, params, partial, call_id, isNativeToolCall, signature.
  • parseAssistantMessageV2 call:3508 — call site; each text chunk re-parses the entire string.

Data flow

The parser's main loop is "scan character by character; on an open tag, switch to tool-use state; on a close tag, switch back to text state." Below is the core that, while in tool use but not in a parameter value, checks whether a new parameter starts or the tool closes:

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 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
}

This sits near tool use state:78. Note the way tags are checked: assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1), meaning "does the substring ending at the current position equal tag." This is equivalent to assistantMessage.slice(i - tag.length + 1, i + 1) === tag, but without an actual slice, so it performs better. After the caller at parse site:3506 gets the new array, it compares prevLength to contentBlocks.length; if the length grew, it resets userMessageContentReady = false so presentAssistantMessage knows there is new content to advance. Once the stream ends, any leftover partial block is forced to partial = false so downstream can finalize and trigger recursion (force partial false:3792).

Boundaries and failure modes

  • Unclosed tags: when the stream is interrupted and a tool use never gets its </tool_name>, the normal end-of-loop finalize pushes it back as partial (finalize partial:223). Downstream presentAssistantMessage sees partial=true and does not actually execute the tool, waiting for the next retry.
  • Fake closing tags nested inside write_to_file: when the model is writing an XML file, a </content> inside <content> looks like a closing tag but is actually file content. lastIndexOf scans backward from the end of toolContentSlice to find the real closing position (lastIndexOf:119), guaranteeing the outermost closing position is used.
  • Unknown tool names: tags outside the whitelist (e.g. the model hallucinates <analyze_file>) are treated as plain text (toolParamNames whitelist:13). No ToolUse is created; the chat just gains an extra piece of angle-bracketed text.
  • Empty content parameter: the <content> tag of write_to_file may end up unfilled because parameter parsing missed it; when the tool use closes, the parser scans once more (content check:113) to slice the content out of toolContentSlice and fill it in.
  • Repeated streaming calls: each text chunk re-parses the entire string, which means a previously finalized tool use gets re-parsed. Because the closing tag is still there, the result is identical. call_id is regenerated with nanoid(8) on every parse (nanoid call_id:179), so the same tool use has different call_ids across parses — but downstream presentAssistantMessage tracks blocks by position via currentStreamingContentIndex, not by call_id, so nothing gets confused.
  • trailing whitespace: slice().trim() strips whitespace from both ends of the parameter value (trim value:68), so extra newlines the model emits do not pollute parameters like path or command.

Summary

parseAssistantMessageV2 is the parsing core of Cline's "XML protocol over plain text" design. Full re-parse + end-of-string tag probing + precomputed Maps make the O(n) single pass fast enough, and the partial flag running through the system lets downstream distinguish "half-finished streaming products" from "closed, executable blocks." To see how the parsed blocks get pushed to the UI and tool executors, go to /agent-loop/present-assistant-message; to see how the outer recursion drives this parse loop, go to /agent-loop/recursion.

See official docs:Cline 文档 · README