Skip to content

presentAssistantMessage:assistant 訊息分塊呈現器

源码版本v4.0.10

職責

presentAssistantMessage 是 Task 類別裡那台「分塊推進器」。LLM 流式回應被 parseAssistantMessageV2 切成 text / tool_use / reasoning 幾種 block 後,這個方法負責一個一個把 block 推到 UI 和工具執行器。它不是自己迴圈跑,而是每次被流式回呼或者 scheduleAssistantPresentation 呼一次,推進一個 block,然後遞迴呼叫自己推進下一個。

它的位置在整個 agent loop 的最內層。recursivelyMakeClineRequests 拉起 attemptApiRequest 拿到流,流回呼裡把累積的文本重解析成 assistantMessageContent 陣列,然後呼叫 presentAssistantMessage。所以你看這個方法時,要把它當成「在流仍在吐字的過程中,被反覆叫醒,看有沒有新 block 可以呈現」的狀態機。

它管的狀態都在 taskState 上:currentStreamingContentIndex 指當前推到第幾個 block,presentAssistantMessageLocked 是個自旋鎖防止重入,presentAssistantMessageHasPendingUpdates 標記「在我執行期間又有新內容進來了」,userMessageContentReady 是給外層 pWaitFor 看的「這一輪所有 block 都處理完了」訊號。

設計動機

  • 鎖 + pending 標記替代佇列:不用訊息佇列,而是一個布林鎖加上「有 pending 就再跑一遍」的尾遞迴式重入控制 (lock check:2637)。簡單,且天然把多次流回呼合併成一次執行。
  • 邊流邊呈現:不等整條回應結束,流到哪推到哪。text block 增量 say("text", content, ..., block.partial),tool_use block 一旦完整就立刻 toolExecutor.executeTool
  • block 之間串行:並行 tool calling 關掉時,didAlreadyUseTool 標誌讓後續 block 直接跳過執行 (parallel gate:2668)。串行執行保證使用者在審批一個工具時不會被新工具打斷。
  • cloneDeep 防引用篡改:取 block 時深拷貝一份再處理,因為流仍在更新原陣列裡的物件屬性,直接拿引用會讀到半成品 (cloneDeep:2658)。
  • out-of-bounds 是常態:索引越界不是錯誤,是「流還沒出下一個 block,你來得太早」的訊號;如果流已經結束 (didCompleteReadingStream),才把 userMessageContentReady 置真讓外層繼續 (oob handling:2650)。

關鍵檔案

資料流

每次 presentAssistantMessage 被叫醒,先搶鎖。搶到後看當前索引有沒有越界,沒越界就取出 block、按類型分發。下面這段是分發後推進到下一個 block 的核心邏輯:

typescript
// apps/vscode/src/core/task/index.ts
if (
    !block.partial ||
    this.taskState.didRejectTool ||
    (!this.isParallelToolCallingEnabled() && this.taskState.didAlreadyUseTool)
) {
    // block is finished streaming and executing
    if (
        this.taskState.currentStreamingContentIndex ===
        this.taskState.assistantMessageContent.length - 1
    ) {
        // last block is complete and it is finished executing
        this.taskState.userMessageContentReady = true; // will allow pwaitfor to continue
    }

    // call next block if it exists (if not then read stream will call it when its ready)
    this.taskState.currentStreamingContentIndex++; // need to increment regardless, so when read stream calls this function again it will be streaming the next block

    if (
        this.taskState.currentStreamingContentIndex <
        this.taskState.assistantMessageContent.length
    ) {
        // there are already more content blocks to stream, so we'll call this function ourselves
        await this.presentAssistantMessage();
        return;
    }
}
// block is partial, but the read stream may have finished
if (this.taskState.presentAssistantMessageHasPendingUpdates) {
    await this.presentAssistantMessage();
}

這段決定了「當前 block 處理完之後要不要立刻接下一個」。block 是 partial (還在流) 就不主動推進,等下一次流回呼;block 是 complete 就推進索引,如果新索引還在陣列範圍內,直接自呼叫推進下一個 block,不用等流回呼。最後那個 presentAssistantMessageHasPendingUpdates 是兜底:如果執行期間流又往前推了,就再跑一遍。userMessageContentReady 只在「最後一個 block 完成」時置真 (ready flag:2769)。

邊界與失敗

  • abort 搶先:方法入口第一件事就是看 taskState.abort,取消了就直接拋 "Cline instance aborted" (abort guard:2631)。這保證取消訊號能在任何 block 執行前生效。
  • 工具被拒絕後串行跳過:didRejectTool 一旦為真,後續 text block 直接 break,tool_use block 走 ToolExecutor 時也會被它內部的拒絕檢查攔下 (reject gate:2667)。索引仍然推進,直到越界後置 userMessageContentReady,讓外層把控制權交還。
  • 初始 checkpoint 阻塞:任務開始時如果有個 initialCheckpointCommitPromise 在跑,非唯讀工具 (!READ_ONLY_TOOLS.includes(block.name)) 必須等它結束 (checkpoint wait:2737)。唯讀工具可以並行跑。
  • 鎖洩漏防護:鎖釋放在分發 switch 之前 (early unlock:2754)。這看起來奇怪,但故意如此——後面要呼叫 presentAssistantMessage 自身,如果還拿著鎖會撞上自己。
  • partial block 的清理:text block 在 partial 狀態下也要發到 UI,但末尾可能出現半個 XML 標籤 (比如 <think 還沒閉合)。程式碼會偵測最後一個 < 之後是不是合法標籤名,是的話就切掉,避免 UI 跳動 (partial tag trim:2695)。
  • 流先結束、block 後到:didCompleteReadingStream 已真但索引越界時,直接置 userMessageContentReady 讓外層 pWaitFor 繼續 (stream done oob:2650)。不會傻等一個永遠不會來的 block。

小結

presentAssistantMessage 是 agent loop 最內層那台推進機。它把「流到哪、推到哪」做到極致:有新 block 就跑、沒新 block 就等、流結束就放行。所有 UI 呈現和工具執行入口都匯聚到這一個方法。要繼續看工具怎麼執行,轉 /tools/coordinator/tools/validator;要看它上一層的遞迴驅動,轉 /agent-loop/attempt-api-request/agent-loop/task-class

對照官方資料:Cline 文件 · README