LobeHub 异构 Agent 管线解析:从 CLI 原始流到 UI 的 Claude Code / Codex 适配器调试实战
在 LobeHub 的桌面端(Electron)中,Claude Code、Codex 等外部 CLI Agent 并不走常规的服务器端 Agent 运行时,而是走一条独立的"异构 Agent"(Heterogeneous Agent)管线:Electron 主进程负责拉起 CLI 子进程并广播原始 stdout,渲染进程中的适配器把各家私有的 NDJSON/JSONL 事件映射成统一的 HeterogeneousAgentEvent,再经由执行器完成消息持久化与 UI 水合。本文基于仓库内的技能文档 SKILL.md 与其配套参考 debug-workflow.md,完整讲清这条管线的分层结构、各层的关键不变量、原始 trace 的抓取方法、常见故障模式以及"复现到修复"的标准工作流,读完即可独立定位并修复 Claude Code / Codex 流式渲染、工具持久化、子 Agent 线程路由等各类管线缺陷。
一、管线全景:六层管道与"从最左端修起"原则
技能文档给出的 Pipeline Map 描述了数据从 CLI 到界面的完整流转:
CLI raw stdout
-> HeterogeneousAgentCtr (Electron main)
-> heteroAgentRawLine broadcast
-> createAdapter(...)
-> executeHeterogeneousAgent(...)
-> persistToolBatch / persistToolResult
-> createGatewayEventHandler(...)
-> UI hydration
即:
- CLI 的原始 stdout / JSONL;
- Electron 主进程中的
HeterogeneousAgentCtr拉起 CLI 子进程,并广播heteroAgentRawLine; - 适配器(
createAdapter)把原始 provider 事件映射为统一的HeterogeneousAgentEvent; executeHeterogeneousAgent持久化 assistant / tool 消息,并转发流事件;createGatewayEventHandler完成 UI 水合;- 只有当这条路径验证正确之后,才应该进入
agent-tracing或 context-engine 的排查。
核心调试原则是"从最左端出错的层开始"(Start at the leftmost broken layer):如果原始事件与适配后事件都正确,才考虑动 UI 代码。文档还明确指出,哪些改动应该触发这套排查:新增或修改 apps/desktop/src/main/modules/heterogeneousAgent/drivers/ 下的 driver、编辑 packages/heterogeneous-agents/src/adapters/ 下的 adapter、调试 heteroAgentRawLine 传输 / window.__HETERO_AGENT_TRACE / executeHeterogeneousAgent、修复 Claude Code 的重复 partial/full chunk、message.id 边界错乱、tool_result 缺失、TodoWrite 状态漂移、子 Agent 线程路由等问题,以及 Codex 的多工具消息混杂、turn 边界断裂、工具结果映射缺失等问题。
从源码结构看,Electron 主进程侧的入口是 HeterogeneousAgentCtr,它以一个稳定的 IPC 表面注册 startSession、sendPrompt、listModels、cancelSession 等方法,而真正的 CLI 适配、流管线、配额采样实现被懒加载(getImplementation 动态 import('./HeterogeneousAgentImpl')),避免在应用启动时就加载全部适配器代码。
二、第一步永远是抓取原始 trace
2.1 应用内 live trace(最忠实的抓取方式)
运行中的应用已经会记录它拉起的每一个 CLI 会话。这是最忠实的 trace,因为它捕获的是应用实际使用的 spawn 参数、env 键、cwd、--resume / --mcp-config 标志、模型和 stdin——手工敲 claude -p / codex exec 是无法完整复现这些细节的。记录器位于 HeterogeneousAgentCtr.ts 所在的控制器模块中(createCliTraceSession、shouldTraceCliOutput、resolveTraceRootDir)。
记录时机:
- 开发构建(
!app.isPackaged):始终记录; - 打包构建:仅当用户在 Help 菜单打开开发者开关(
heteroTracingEnabled)时记录,默认关闭以免污染正常运行; NODE_ENV=test下永不记录。
写入位置:
- 开关关闭(普通 dev 运行):写到
<cwd>/.heerogeneous-tracing/,即你正在运行的仓库目录内(注意目录名拼写就是heerogeneous,这是真实路径); - 开关打开:写到
<appStoragePath>/heteroAgent/tracing/,避免 trace 落入用户项目目录,这也是打包构建唯一使用的路径。
每个会话的目录布局为 .../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/,包含五个文件:
| 文件 | 内容 |
|---|---|
meta.json |
spawn args、command、cwd、envKeys、model、resumeSessionId / agentSessionId、附件摘要。应最先读它,以确认 CLI 到底是怎么被拉起的 |
stdin.txt |
喂给 CLI 的 stream-json 请求 |
stdout.jsonl |
原始 provider NDJSON,即真正要读的那份 trace |
stderr.log |
CLI 的 stderr |
exit.json |
{ code, signal, finishedAt } |
.heerogeneous-tracing/.last-live-trace 始终指向最近一次会话目录,因此"刚才发生了什么"的最快路径是:
dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json" # CLI 是如何被拉起的
wc -l "$dir/stdout.jsonl" # 原始事件条数
需要自己复现同一会话时,直接复用 meta.json 里的 args 与 stdin.txt(args 中已包含 --resume <sessionId>),而不是去猜命令行参数。
2.2 手工抓取 Codex 原始 JSONL
使用只读 prompt,并把 trace 存到仓库本地 scratch 目录 .heerogeneous-tracing/ 下:
ts=$(date +%Y%m%d-%H%M%S)
out=".heerogeneous-tracing/codex-${ts}.jsonl"
last=".heerogeneous-tracing/codex-${ts}.last.txt"
cat << 'EOF' | codex exec --json --skip-git-repo-check --sandbox read-only -C "$PWD" -o "$last" - > "$out"
You are being run only to collect a raw Codex JSON event trace.
Do not modify any files.
Use at least 4 separate shell tool invocations, one invocation per command.
Run a short sequence of read-only repo checks and then reply with a one-sentence summary.
EOF
在 JSONL 中重点观察以下事件:
thread.startedturn.starteditem.started/item.completeditem.type === 'command_execution'item.type === 'agent_message'turn.completed
判定逻辑很直接:如果原始 Codex 输出本身就把多个工具合并进了一个 item,那么适配器是无辜的;如果原始输出是独立 item 而 UI 却把它们折叠了,则 bug 在下游。另外,如果仓库 .heerogeneous-tracing/ 下已有可用的 trace,应先检查它们再手动复现。
2.3 手工抓取 Claude Code 原始 NDJSON
命令行参数应与桌面 driver 保持一致。桌面端 driver claudeCode.ts 中的参数由共享基础参数与调用方特定参数组合而成:
const DESKTOP_CLAUDE_CODE_ARGS = [
...CLAUDE_CODE_BASE_ARGS, // 共享不变基础参数
'--include-partial-messages', // 桌面端渲染实时气泡,永远需要 token 级增量
'--permission-mode',
'bypassPermissions',
] as const;
其中 CLAUDE_CODE_BASE_ARGS 定义在 spawnAgent.ts,包含 -p、--input-format stream-json、--output-format stream-json、--verbose 四个共享不变参数。此外 driver 在构造 spawn plan 时按需追加 --mcp-config <path>(控制器管理的临时 mcp.json,CC 不接受内联 JSON)与 --resume <sessionId>。因此手工抓取命令为:
ts=$(date +%Y%m%d-%H%M%S)
out=".heerogeneous-tracing/claude-${ts}.ndjson"
cat << 'EOF' | claude -p \
--input-format stream-json \
--output-format stream-json \
--verbose \
--include-partial-messages \
--permission-mode bypassPermissions \
> "$out"
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do a few read-only repo checks, use several tool calls, and then summarize briefly."}]}}
EOF
Claude Code 原始 trace 中应关注的事件:
type: 'system', subtype: 'init'type: 'assistant'块(thinking、tool_use、text)- 包含
tool_result的type: 'user'块 type: 'stream_event'中的message_start、content_block_delta、message_deltatype: 'result'type: 'rate_limit_event'
Claude Code 的关键流语义(也是 适配器源码头部注释 明确写下的协议事实):
- 每个 content block 通常以各自独立的 assistant 事件到达;
- 多个 assistant 事件可以共享同一个
message.id——它们仍然是同一个 turn; message.id变化才是主 Agent 的 step 边界;- partial delta(
stream_event)先于携带完整内容块的assistant事件到达,适配器对已流式输出过的message.id抑制后续重复输出; message_delta.usage是每个 turn 权威的 usage 来源,不要信任每个 assistant 块上回显的 usage;- 子 Agent 事件带有
parent_tool_use_id标记。
文档同时建议:若仓库中已有参考 trace(如 .heerogeneous-tracing/cc-monitor-real-trace.jsonl、.heerogeneous-tracing/cc-stream-chain-reference.md),优先检查它们;如果只需要验证边界语义或工具持久化行为,优先看现成的适配器测试 claudeCode.test.ts 与 claudeCode.e2e.test.ts。
三、对比原始事件与适配事件
在开发构建中,executeHeterogeneousAgent 会把原始行和适配后事件挂到 window.__HETERO_AGENT_TRACE 上。执行器实现位于 heterogeneousAgentExecutor.ts,对应的单测是 heterogeneousAgentExecutor.test.ts。
用该 trace 逐项对比:
- 原始
item.started/item.completed - 适配后的
stream_chunk { chunkType: 'tools_calling' } - 适配后的
tool_result - 适配后的
tool_end
对 Codex,典型映射关系为:
| 原始事件 | 适配后事件 |
|---|---|
item.started(command_execution) |
tools_calling + tool_start |
item.completed(command_execution) |
tool_result + tool_end |
item.completed(agent_message) |
stream_chunk(text) |
结论规则:如果原始 trace 正确而适配事件错误,先修适配器,再谈持久化。例如 CodexAdapter 的 adapt 方法就是按 thread.started、turn.started、item.started/updated/completed、item.agent_message.delta、item.command_execution.output_delta、turn.completed、error / turn.failed 等原始事件类型做 switch 分发的,任何映射错误都能在这一层被单测复现。
四、先查 step 边界,再查持久化
"多个工具混进一条 assistant 消息"这类 bug 的第一检查项就是 step 边界。
4.1 Claude Code 的边界语义
Claude Code 的 step 边界以 assistant 的 message.id 变化为键。适配器应发出:
stream_endstream_start { newStep: true }
同时需要验证以下 Claude 特有不变量:
- init 之后的第一个 assistant 事件不打开新 step;
- 相同
message.id的重复 assistant 事件不打开新 step; - partial
content_block_delta的 text/thinking 不被后续完整 assistant 事件重复输出; - 来自
type: 'user'事件的tool_result更新对应的工具行; parent_tool_use_id生成 thread 作用域的子 Agent chunk,而不是主流 chunk;- TodoWrite 的
tool_use.input在tool_result时转换为合成的pluginState.todos。
从 claudeCode.ts 的适配器源码可以看到,这套语义还通过 ClaudeCompatibleAdapterProfile.assistantMessageIdsDefineTurns 字段做了参数化——Claude 用同一个 message.id 覆盖一个 turn 内的所有 content block,所以 id 变化即 turn 边界;而兼容 provider(如 CodeBuddy)会给推理项和文本项分配独立 id,边界规则不同。另外,新版本的 CC 已将声明式 TodoWrite 替换为命令式三件套(TaskCreate / TaskUpdate / TaskList),适配器会按会话累积这些事件并在每次 task 工具的 tool_result 上合成共享的 pluginState.todos 结构,让既有 TodoProgress UI 继续工作(见 claudeCode.ts 中的常量与解析模式定义)。
4.2 Codex 的边界语义
Codex 原始 trace 通常通过 turn.started / turn.completed 提供 turn 级边界。执行器只有在收到它能理解的 step 边界信号时才会切分新的 assistant 消息。如果适配器发出的 stream_start 不带 newStep,多个 Codex 工具与文本 chunk 可能会在同一个 assistant 下累积得比预期更久。相关文件:codex.ts 与 heterogeneousAgentExecutor.ts。从源码看,CodexAdapter.handleTurnStarted 在首个 turn 发出 stream_start,后续每个 turn 先置 pendingTurnStartBoundary 并只发出 stream_end,边界信号在下一个工具或内容到达时通过 consumePendingTurnStart 消费——这正是"延迟边界"的实现细节。
五、工具持久化不变量(persistToolBatch / persistToolResult)
改 UI 代码之前先读 persistToolBatch 与 persistToolResult 所在执行器。
persistToolBatch 的期望顺序是三步:
- 预注册 assistant 的
tools[]; - 创建
role: 'tool'消息; - 把
result_msg_id回填到 assistant 的tools[]。
如果工具行先于 assistant 的 tools[] 注册而创建,就会得到孤儿工具消息。执行器源码中 toolMsgIdByCallId 全局映射 承担"工具调用 id → 工具消息 id"的解析职责:persistToolBatch 创建工具消息时写入 toolMsgIdByCallId.set(toolCallId, message.id),persistToolResult 则必须通过 toolMsgIdByCallId.get(toolCallId) 解析到既有工具行。
persistToolResult 的告警信号:
tool_result for unknown toolCallId- 工具行内容永远为空
- 缺失
result_msg_id
对 Claude Code 要记住:tool_result 源自原始 type: 'user' 事件,而不是 assistant 事件。
主/子 Agent 作用域规则:
- 主 Agent 的工具状态是 per-step 的;
toolMsgIdByCallId是 global 的,跨主/子 Agent 作用域共享,因此绝不能在主 step 边界处清空它;- 子 Agent chunk 不得被转发进主 gateway handler,否则主气泡会继承错误的
tools[]与内容。
六、必须守住的关键不变量清单
技能文档归纳的 Critical Invariants 是整个管线的契约,可视为"回归红线":
- 一条原始工具 item 必须映射到一个稳定的
ToolCallPayload.id; - 新的主 Agent step 在把事件转发给新 assistant 之前,必须先发出边界信号;
- Claude Code 中,共享同一
message.id的多个 assistant 事件是一个 turn,而不是多个 turn; - Claude Code 中
tool_result位于type: 'user'事件里,而非 assistant 事件; - Claude Code partial 模式下,
message_delta.usage是权威来源,不要信任每个 assistant 块回显的 usage; persistToolBatch必须在创建工具消息之前预注册 assistant 的tools[];- 每条工具消息必须保持
parentId等于所属 assistant、tool_call_id等于工具 id; tool_result必须能解析到既有的toolMsgIdByCallId条目;- 子 Agent chunk 必须留在 thread 作用域内,不得转发进主流 assistant;
- 绝不在主 step 边界清空全局
toolMsgIdByCallId映射。
七、常见 bug 模式与排查入口
| 症状 | 优先检查 |
|---|---|
| Claude Code 文本或 thinking 重复 | partial delta 与后续完整 assistant 块是否同时被发出 |
| Claude Code 打开了过多 assistant 消息 | 适配器是否在每个 assistant 事件上都切 step,而不是只在 message.id 变化时切 |
| Claude Code 工具结果永不落位 | 是否因为代码只检查 assistant 事件,而忽略了 type: 'user' 的 tool_result 块 |
| Claude Code TodoWrite 卡片过期 | 合成的 pluginState.todos 是否附着在 tool-result 时点上 |
| Claude Code 子 Agent 转录泄漏进主气泡 | parent_tool_use_id 的处理,以及子 Agent chunk 是否被转发到主 gateway handler |
| Codex 多个工具折叠进一条 assistant 消息 | 适配器是否发出可用 step 边界,如 newStep 或等价 turn 变化信号 |
| 孤儿工具消息 | step 转换顺序,以及 persistToolBatch 的 Phase 1 是否先于工具消息创建执行 |
| 工具气泡一直 loading | 查找 tool_result for unknown toolCallId 与缺失的 result_msg_id 回填 |
| 子 Agent 工具出现在主气泡 | 检查子 Agent chunk 是否到达了主 gateway handler |
| 错误的终端错误指引(如网络断连却提示"usage limit reached") | 分类器在某个结构化字段上分支,但字段存在并不等于其语义成立——见下文第 8 节 |
八、用真实 trace 校验"结构化字段分类器"
这是 debug-workflow 中反复出现的一条纪律:每当适配器基于原始流中的结构化字段做分支——status、usage、rateLimitType、stop_reason、parent_tool_use_id、subtype 等——都不要相信脑中的 wire format 模型。你要判定的字段几乎总会在良性/非目标事件上同样出现,忽略周围状态分类器就会在这些事件上误触发。
标准流程(每次都执行):
-
拉取最近一次真实会话:
dir=$(cat .heerogeneous-tracing/.last-live-trace); -
grep 该字段在所有事件状态中的分布,而不只是失败那次,并按共现状态计数,例如:
# 哪些 status 的事件携带 rate_limit_info 块? grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c grep -c 'rate_limit_info' "$dir/stdout.jsonl" -
如果该字段出现在你没有考虑过的状态上,分类器需要加一道门;并把 trace 固化为适配器测试的 fixture/断言,防止回归复发。
文档给出了一个完整实例——CC 的用量上限 vs 瞬时限流误分类:
- 症状:一次无关的终端失败(如
ECONNRESET网络掉线)渲染出虚假的 "usage limit reached, resets at X" 指引; - trace 揭示:Anthropic 即使请求成功放行(
status: "allowed")也会给事件打上rate_limit_info块(携带resetsAt与rateLimitType,如seven_day)。真实 trace 中这些窗口字段出现在几乎所有rate_limit_info块上,而其中绝大多数是allowed而非rejected——因此该窗口只是放行调用的滚动窗口元数据,不是"已触及限额"的证据; - bug 成因:
isUserQuotaRateLimit(位于 claudeCode.ts)只以"存在重置窗口"(info.resetsAt != null || info.rateLimitType != null)为判定依据,随后的终端错误继承了上一个 allowed 事件的窗口,造成误报; - 修复:要求
status === 'rejected'且有具体重置窗口同时成立;仅有rejected而无窗口的情况属于瞬时服务端限流,留给 overloaded(重试)分类器。状态码(429 / 529)与消息文本被刻意不参考——只有这个结构化信号决定指引。回归断言固化在 claudeCode.test.ts 中。
通用教训一句话:字段的"存在"不等于它的"含义"。基于某个判别字段做分支之前,先在真实录制的 trace 中确认它与哪些事件状态共现。
九、聚焦测试与"复现到修复"工作流
优先跑最小的有用测试集:
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/codex.test.ts'
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/claudeCode.test.ts'
bunx vitest run --silent='passed-only' 'src/store/chat/slices/agentRun/actions/__tests__/heterogeneousAgentExecutor.test.ts'
修复 Claude Code 类 bug 时值得补充的断言:
- 相同
message.id不发出newStep; message.id变化时发出stream_end+stream_start { newStep: true };- partial text/thinking 只输出一次;
- 来自
user事件的tool_result到达正确的工具行; - 子 Agent chunk 携带
subagent.parentToolCallId; - TodoWrite 结果合成
pluginState.todos。
如果 bug 源自真实 trace,应把它提炼到最接近的既有测试文件中,而不是依赖纯手工 UI 复现。
完整的 Repro-to-Fix 工作流共六步:
- 抓取原始 trace,保存到
.heerogeneous-tracing/; - 确认 bug 出现在原始事件、适配事件还是持久化哪一层;
- 在出错的层附近新增或更新最窄的失败测试;
- 修复能解释该症状的最小层;
- 重跑聚焦测试;
- 仅当仍需 UI 确认时,才做 Electron 冒烟测试。
原则:如果原始 trace 或适配器测试能更快证明故障区,就不要从宽泛的 Electron 复现开始。
十、参考文件索引
| 文件 | 角色 |
|---|---|
| SKILL.md | 技能入口:适用场景、管线地图、调试顺序、不变量与 bug 模式 |
| debug-workflow.md | 命令、trace 抓取、不变量细节与聚焦测试命令的完整参考 |
| HeterogeneousAgentCtr.ts | Electron 主进程 IPC 表面与懒加载实现边界 |
| drivers/claudeCode.ts | 桌面端 CC 的 spawn 参数、provider 绑定与 server-default 绑定 |
| drivers/codex.ts | 桌面端 Codex driver |
| adapters/claudeCode.ts | CC stream-json NDJSON → HeterogeneousAgentEvent 映射 |
| adapters/codex.ts | Codex JSONL → 统一事件映射与 pluginState 合成 |
| heterogeneousAgentExecutor.ts | 渲染进程执行器:持久化、边界处理、toolMsgIdByCallId |
| spawnAgent.ts | CLAUDE_CODE_BASE_ARGS 等共享 spawn 常量 |
| claudeCode.test.ts / codex.test.ts / executor.test.ts | 各层的行为验证与回归断言 |
小结
LobeHub 异构 Agent 管线的设计精髓在于"分层隔离 + 契约不变量":每一层(driver / 适配器 / 执行器 / gateway handler)都有明确的输入输出契约,调试时从原始 trace 逐层右移定位,用真实 live trace 校验每一个基于结构化字段的分支,最后把修复固化为最窄的层内测试。掌握本文的管线地图、不变量清单、trace 抓取命令和 bug 模式对照表,即可对 Claude Code / Codex 适配器及其上下游做出可验证的修改。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00