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