跳到主要内容

聊天缓冲区主循环

这一章讲整个插件的骨架:用户按回车后,一条消息怎么变成 HTTP 请求、LLM 的流式回话怎么实时写回屏幕、以及工具调用怎么让对话「自己转下去」。

1. 核心直觉:对话是一个 buffer,但还有一份「影子消息表」

CodeCompanion 把对话渲染成一个可编辑的 Markdown buffer(你看到的 ## Me / ## CodeCompanion)。但发给 LLM 的不是 buffer 文本本身,而是一份结构化的影子消息表 self.messages(角色 + 内容 + 元数据)。

为什么要两份?因为:

  • buffer 是给人看的——可编辑、可滚动、带语法高亮。
  • self.messages 是给LLM 看的——带 role、隐藏的系统提示、工具调用的 tool_calls、token 估算等 buffer 里看不见的字段。

submit 时,插件会重新解析 buffer 里用户那段新文本(parser.messages,见 chat/init.lua:1308),把它并进 self.messages,再发出去。两者通过这次解析对齐。

2. 主线:submit → 流式 → done

按回车


Chat:submit ──┬─ 解析 buffer 新文本 → 并入 self.messages
├─ 拼 payload { messages, tools(schemas) }
└─ 按 adapter.type 分流

┌───────────┴───────────┐
▼ ▼
_submit_http _submit_acp
(HTTP 流式) (子进程 JSON-RPC)

▼ 每个 chunk
process_chunk ── adapter.parse_chat ──▶ 实时 add_buf_message 写回屏幕
│ (文本) 或攒进 tools(工具调用)
▼ 流结束
Chat:done ──┬─ 有文本 → 落成一条 assistant 消息
├─ 有工具 → Tools:execute(见 02 章),跑完自动 submit 下一轮
└─ 都没有 → ready_for_input,等用户

2.1 submit:把 buffer 拼成请求

Chat:submit(chat/init.lua:1287)先用一个守卫挡住并发请求(if self.current_request then return),然后区分两种触发:

  • 用户手动提交:解析 buffer 里 header_line 之后的新文本,做上下文展开(check_context)、图片检查、replace_user_inputs 等,再把用户消息 add_message 进历史。
  • 自动提交(opts.auto_submit,工具跑完后触发):跳过解析,只注入排队的 btw 消息。

关键是 payload 的拼装(chat/init.lua:1373):

local payload = {
messages = self.adapter:map_roles(shallow_messages),
session_id = self.session_id,
tools = (not vim.tbl_isempty(self.tool_registry.schemas)
and { self.tool_registry.schemas } or {}),
}

注意 map_roles——同一份消息,不同适配器对「assistant」「user」的叫法不同,适配器在这里把内部角色名翻译成各家的叫法。工具 schemas 来自 tool_registry(见 02 章),只有挂了工具才非空。

一个易错点:提交前会浅拷贝每条消息(chat/init.lua:1364),这样 map_rolesrole 字段时不会污染 self.messages 原表。注释明确点了这个原因。

2.2 process_chunk:流式回话怎么实时上屏

_submit_http(chat/init.lua:1185)里定义了 process_chunk,它是流式的心脏。每收到一个 chunk:

  1. 先让适配器抽 token 数(parse_tokens)。
  2. adapter.parse_chat(chat/init.lua:1206)把这家 LLM 的原始 chunk 解析成统一结构 { status, output = { role, content, reasoning, ... } }
  3. 如果 output.content 有内容,立刻 add_buf_message 写进 buffer——这就是你看到文字一个个蹦出来的原因(chat/init.lua:1239)。
  4. reasoning(思考过程)、meta(如 compaction 压缩信号)分别攒到各自的累加器里。

这段演示「流式累加」的核心想法(# 示意,非源码):

-- 累加器:文本和工具调用分开攒
local output, reasoning, tools = {}, {}, {}

local function on_chunk(raw)
local result = adapter.parse_chat(raw, tools) -- 各家格式 → 统一结构
if result.output.content then
table.insert(output, result.output.content) -- 攒最终文本
add_buf_message(result.output.content) -- 同时实时上屏
end
-- 工具调用由 parse_chat 直接塞进 tools 表,不在这里处理
end

注意 tools传进 parse_chat 的引用——适配器把流式拼接的工具调用片段直接攒进这张表,等流结束一次性交给 done

2.3 done:收尾与「自动转下一圈」

Chat:done(chat/init.lua:1421)是回合的终点,做三件事,顺序很重要:

  1. 先落文本消息:把累加的 output 拼成一条 assistant 消息进历史。
  2. 再处理工具(chat/init.lua:1490):如果 has_tools,先把工具调用作为一条隐藏的 assistant 消息存好,然后 return self.tools:execute(self, tools)——把控制权交给工具系统。
  3. 否则就绪:没工具、没排队消息,就 checkpoint 存档,准备接收用户下一句。

「自动转下一圈」是 agent 能多轮自主工作的关键:工具跑完后,工具系统会触发一次 auto_submit(见 tools/init.lua:236CodeCompanionToolsFinished 处理),把工具输出作为新消息再发给 LLM,LLM 看到结果后决定下一步——形成「LLM 调工具 → 看结果 → 再调工具」的循环,直到它不再调工具为止。

3. 巧妙之处

「stopped 后不标记 sent」防丢消息。 label_sent_items(chat/init.lua:1406)给已发送的用户消息打 sent 标记,避免对「替我们存状态的适配器」重复发送。但如果用户中途按 stop(opts.status == "stopped"),代码故意不打这个标记(chat/init.lua:1485)——因为无法确定 LLM 到底收到了多少,留着以便重发。这是一个很细的正确性考量。

孤儿工具调用补全。 如果用户在工具调用还没返回结果时就停掉请求,消息历史里会留下「有 tool_call 却没有对应 tool_result」的孤儿,多数 LLM 的 API 会因此报错。_complete_orphaned_tool_calls(chat/init.lua:1078,在 done 的 stopped 分支调用)会补一个占位结果,保证历史合法。

4. 边界与局限

  • 单飞请求:同一个聊天 buffer 同时只能有一个在途请求(current_request 守卫,chat/init.lua:1288)。
  • buffer 被关:done 回来时若 buffer 已失效会直接返回(chat/init.lua:1426),自动化测试里常见。
  • 这个文件超过 2000 行,是全库最大的单文件——Chat 类承担了 buffer 管理、上下文、提交、工具桥接等过多职责。

5. 代码地图

主题文件符号
提交入口lua/codecompanion/interactions/chat/init.luaChat:submit
HTTP 流式lua/codecompanion/interactions/chat/init.luaChat:_submit_httpprocess_chunk(局部)
回合收尾lua/codecompanion/interactions/chat/init.luaChat:doneChat:tools_done
工具输出回写lua/codecompanion/interactions/chat/init.luaChat:add_tool_output
孤儿补全lua/codecompanion/interactions/chat/init.luaChat:_complete_orphaned_tool_calls
底层发请求lua/codecompanion/http.luaClient:sendClient:request