Skip to content

parseAssistantMessageV2:LLM ストリームテキストのブロック分割器

源码版本v4.0.10

役割

parseAssistantMessageV2 は Cline が LLM のストリーミング生文字列を構造化 block に切り分けるパーサで、apps/vscode/src/core/assistant-message/parse-assistant-message.ts に書かれている。入力は蓄積された assistant テキスト、出力は AssistantMessageContent[] で、各要素は TextStreamContentToolUseReasoningStreamContent の三種のいずれかになる。Cline は LLM のネイティブ tool_use API に頼らず、XML 風のタグプロトコル (<read_file>...</read_file><write_to_file><content>...</content></write_to_file>) でモデルに通常テキストの中にツール呼び出しを埋め込ませており、このパーサがその半構造化テキストを分割する中心である。

agent loop 内での位置づけは明確である: recursivelyMakeClineRequestsattemptApiRequest を呼んでストリームを取得し、ストリームのコールバックはテキストを受け取るたびに assistantMessage += chunk.text で蓄積した上で、即座に parseAssistantMessageV2(assistantMessage) で全体を再解析する (parseAssistantMessageV2 call:3508)。解析された block 配列は taskState.assistantMessageContent に格納され、長さが伸びるたびに scheduleAssistantPresentation を呼んで presentAssistantMessage を進める。そのためこの関数は毎秒何十回も呼ばれる可能性があり、O(n) の一回走査でなければならない。

設計動機

  • 増分ではなく全体再解析: 新しいテキストを受け取るたびに assistantMessage 全体を再走査する。無駄に見えるが、LLM のストリームは途中で前のタグを修正する (例: </parameter> を閉じる) ことがあり、増分ステートマシンはこの修正で壊れる。全体再解析が最も安定した手法で、assistant テキストは通常数 KB なので O(n) の一回走査でも十分速い。
  • endsWith 型のタグ探査: 事前トークン化はせず、i=0 から一文字ずつ進み、各位置で「i で終わる部分文字列がどこかの開始/終了タグにマッチするか」をチェックする (close tag check:56)。この書き方は LLM が時折文字を多く出力したり少なく出力したりするのに対して頑健で、タグが全体として閉じていればよい。
  • 開始タグ Map の事前計算: toolUseOpenTagstoolParamOpenTags はどちらも Map<string, name> で、ループ外で一度だけ構築する (precompute maps:38)。ループ内では配列を走査せず Map を引くだけ。ツール名とパラメータ名は getToolUseNames() / toolParamNames のホワイトリストから来ており、ホワイトリスト外のタグはテキストとして扱う。
  • partial フラグの貫通: ストリーミングで書き込み中、まだ閉じタグを見ていない block には partial: true を付ける (partial true:178)。下流の presentAssistantMessage は partial block を受け取った時、それが不完全であることを知り、増分 UI 更新だけを行ってツールを実際には実行しない。ストリーム終了後は partial を強制的に false にして下流が finalize できるようにする。
  • write_to_file の content 特別扱い: write_to_file<content> パラメータには閉じタグに見えるコードが含まれる可能性がある (例: モデルに XML ファイルを書かせる場合)。indexOf + lastIndexOf で先頭と末尾のアンカーを使って真の閉じ位置を特定し (content lastIndexOf:119)、中間の偽の閉じタグで内容が断ち切られるのを防ぐ。
  • ストリーム末尾の finalize: ループが正常に終了した後、まだ閉じていない tool use や text が残っていれば、それらを partial として contentBlocks に push する (finalize partial:223)。これによりストリームが中断しても下流が半完成品を受け取れる。

主要ファイル

  • parseAssistantMessageV2:28 — 関数入口。シグネチャは (assistantMessage: string) => AssistantMessageContent[]
  • precompute maps:38<tool_name><param_name> のタグを Map に事前構築し、ループ内では O(1) で引く。
  • param state:52 — パラメータ値状態のとき、現在位置が </param_name> 閉じタグかをチェックする。
  • tool use state:78 — tool use だがパラメータ値状態ではないとき、新パラメータ開始かツール終了かをチェックする。
  • tool close tag:95</tool_name> にヒットした時、tool を partial: false にして contentBlocks に push する。
  • content special:111 — write_to_file の content パラメータは lastIndexOf で真の閉じ位置を探し、中間の偽の閉じタグを防ぐ。
  • text state:138 — tool use でもパラメータでもない状態のとき、テキスト状態で走査し、新ツール開始かをチェックする。
  • new tool use:174 — 開始タグにヒットした時、先に前の text block を finalize し、その後に { type: "tool_use", partial: true } を生成する。
  • finalize partial:215 — ループ終了後、残留した tool use / text block を partial として押し戻す。
  • AssistantMessageContent:3TextStreamContent | ToolUse | ReasoningStreamContent のユニオン型。
  • toolParamNames:13 — パラメータ名ホワイトリスト (command、path、content、diff など)。ホワイトリスト外のタグはテキスト扱い。
  • ToolUse:63 — ツール呼び出し構造。nameparamspartialcall_idisNativeToolCallsignature を持つ。
  • parseAssistantMessageV2 call:3508 — 呼び出し点。ストリームのコールバックでテキストごとに全体を再解析する。

データフロー

パーサのメインループは「一文字ずつ走査し、開始タグを見たら tool use 状態に切り替え、閉じタグを見たら text 状態に戻す」というもの。以下は tool use だがパラメータ値状態ではないとき、新パラメータ開始かツール終了かをチェックするコア部分:

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
}

この部分は tool use state:78 の付近にある。タグのチェック方法は assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1) で、「現在位置を末尾とする部分文字列が tag に等しいか」を意味する。これは assistantMessage.slice(i - tag.length + 1, i + 1) === tag と等価だが、実際に slice せず性能が良い。呼び出し側は parse site:3506 で新しい配列を受け取った後、prevLengthcontentBlocks.length を比較し、長さが増えていれば userMessageContentReady = false にリセットして presentAssistantMessage に新しい内容が到着したことを知らせる。ストリーム終了後に残った partial block は強制的に partial = false にされ、下流が finalize して再帰をトリガーできるようにする (force partial false:3792)。

境界と失敗

  • 未閉じタグ: ストリーム中断時に tool use が </tool_name> を受け取れなかった場合、ループ正常終了後に finalize が partial として押し戻す (finalize partial:223)。下流の presentAssistantMessage は partial=true を見るとツールを実際に実行せず、次のラウンドで再試行するのを待つ。
  • write_to_file 内の偽の閉じタグ: モデルに XML ファイルを書かせる場合、<content> の中の </content> は閉じタグに見えるが実際はファイル内容。lastIndexOf で toolContentSlice の末尾から前に向かって真の閉じ位置を探し (lastIndexOf:119)、最も外側の閉じ位置を取得する。
  • 未知のツール名: ホワイトリスト外 (例: モデルが幻覚で <analyze_file> を出す) のタグは純テキストとして扱い (toolParamNames whitelist:13)、ToolUse は作らず、チャットに山括弧付きの文字列が一つ増えるだけ。
  • 空の content パラメータ: write_to_file<content> タグはパラメータ解析の都合で値が入らないことがある。tool use の閉じ時に再度スキャンし (content check:113)、toolContentSlice から content を切り出して補う。
  • ストリーミングの重複呼び出し: テキストごとに全体を再解析するため、前に finalize した tool use も再解析される。閉じタグがまだ残っているので結果は一致する。call_idnanoid(8) で毎回再生成される (nanoid call_id:179)。そのため同じ tool use が複数回の解析で call_id が変わるが、下流の presentAssistantMessage は call_id ではなく currentStreamingContentIndex で block の位置を追跡するため、混乱は起きない。
  • trailing whitespace: slice().trim() でパラメータ値両端の空白を落とす (trim value:68)。モデルが余分に出した改行が path、command などのパラメータを汚さないようにする。

まとめ

parseAssistantMessageV2 は Cline の「XML プロトコル over プレーンテキスト」方式の解析コアである。全体再解析 + 末尾タグ探査 + 事前計算 Map で O(n) の一回走査を十分速くこなし、partial フラグの貫通で下流が「ストリーミング中の半完成品」と「閉じた実行可能 block」を区別できるようにする。解析された block がどう UI とツール実行器に押し込まれるかは /agent-loop/present-assistant-message へ、さらに外側の再帰がどうこの解析ループを駆動するかは /agent-loop/recursion へ。

公式資料: Cline 文档 · README