数据截至 (上游 commit 877a71568f6d)
Agent 运行时:把 15+ 种编码 CLI 抽象成一种执行
30 秒导读: Multica 要让 Claude Code、Codex、Copilot、Cursor、OpenCode……这十几种"编码 agent CLI"都 能当任务的执行者。可它们每一个的命令行、输入方式、输出协议都不一样。
server/pkg/agent这个包的活,就是把这堆异构 CLI 全部藏到一个 Go 接口Backend背后——上层只管"给一段 prompt,拿回一个统一的Result",完全不用知道底下跑的是哪家 CLI、说的是哪种协议。这是整个平台工程含量最高的一支。
本章只讲 pkg/agent「怎么把一次执行做出来」。谁在什么时候调用它、任务怎么排队调度是 daemon 的事,留给 02-local-daemon.md。
1. 这是什么(零基础也能懂)
一句话定义: pkg/agent 是一层适配器(adapter)——把 15+ 种各说各话的编码 CLI,统一成"输入 prompt → 流式吐事件 → 最后给一个结构化结果"的同一种执行接口。
它解决谁的什么问题。 设想你在做一个"让 AI 帮你干活"的平台:用户可以把一个 issue 指派给某个 AI agent,agent 去改代码、跑命令、最后回一句结论。问题来了——用户机器上装的可能是 Claude Code,也可能是 OpenAI 的 Codex,或者 GitHub Copilot CLI、Cursor 的 cursor-agent……
这些 CLI 没有一个统一标准:
| 差异维度 | 举例 |
|---|---|
| 启动命令 | claude -p、codex app-server、opencode run、cursor-agent、grok agent stdio |
| prompt 怎么送进去 | Claude 走 stdin 的 JSON 帧;OpenCode 直接当命令行参数;Codex 走 JSON-RPC 请求体 |
| 输出协议 | 有的吐 JSONL 流(一行一个事件),有的是长连接 JSON-RPC,有的是 ACP 协议 |
| 结束信号 | Claude 等一个 result 事件;Codex 等一个 turn 完成通知 |
如果上层每接一种 CLI 就写一套逻辑,平台会被拖垮。pkg/agent 的价值就是:把这些差异全部吃进适配器里,对上只暴露一个干净的接口。
它能做什么:
- 用同一个
Backend.Execute接口跑任意一种受支持的 CLI。 - 把各家 CLI 的原生事件(文字、思考、工具调用、工具结果)翻译成统一的
Message流,供实时展示。 - 把"这次跑完了没、成功还是失败、最终答案是什么、花了多少 token"收敛成一个统一的
Result。 - 顺带处理一堆脏活:MCP 服务器配置注入、思考档位(thinking level)归一、CLI 版本探测与门槛校验、会话续接(resume)与"续接被拒"的识别。
一句话直觉/类比: 把它想成电源适配器上的万能转换头。世界各地的插座(CLI)孔位各不相同,你的笔记本(上层平台)只有一种插头(Backend 接口)。转换头负责让任何插座都能给这一种插头供电。
2. 顶层全景(它大概怎么转)
2.1 一次执行的主线
先看"喂一段 prompt,怎么最后变成一个 Result"的高层流向。从左到右读,中间那一大坨(不同 CLI 的传输协议)是本包吸收掉的复杂度:
┌──────────── pkg/agent 适配层 ────────────┐
│ │
上层(daemon) │ agent.New(type) → 选出一个 Backend │
│ │ │ │
│ Execute(ctx, │ ┌───────────┴───────────┐ │
│ prompt, opts) ───┼──────► │ 该 Backend.Execute │ │
│ │ │ 1. 拼命令行 args │ │
│ │ │ 2. 起子进程 / 连协议 │ │
│ │ │ 3. 送 prompt │ │
│ │ └───────────┬───────────┘ │
│ │ │ 子进程边跑边吐原生事件 │
│ │ ┌───────────▼───────────┐ │
│ ◄─── Messages ────┼─────── │ 读流 + 翻译成 Message │ │
│ (实时文字/工具) │ │ (每家协议一套解析) │ │
│ │ └───────────┬───────────┘ │
│ │ │ 进程退出 │
│ ◄─── Result ──────┼─────── │ 收敛成统一 Result │ │
│ (一个终结果) │ │ (fail-closed 契约) │ │
│ │
└──────────────────────────────────────────┘
关键点:上层拿到的是一个 Session,里面两个 channel——Messages(边跑边来的实时事件)和 Result(跑完只来一个的最终结果)。上层永远不碰子进程、不解析协议。
2.2 部件与职责
pkg/agent 内部按职责分成两拨文件:每个 backend 一个文件,加上一批跨 backend 的共性机制文件。
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Backend 接口 + New() 工厂 | 定义统一契约,按类型名建出具体 backend | agent.go (Backend:17-22, New:284-327) |
ExecOptions / Message / Result | 统一的输入旋钮、事件模型、结果模型 | agent.go (ExecOptions:25-87, Message:125-135, Result:161-194) |
| stream-json 类 backend | Claude/CodeBuddy/Cursor/Qwen:读 JSONL 流 | claude.go、codebuddy.go、cursor.go、qwen.go |
| app-server 类 backend | Codex:长连接 JSON-RPC 客户端 | codex.go |
run 子命令类 backend | Copilot/OpenCode/DevEco:跑一次吐 JSON 事件 | copilot.go、opencode.go、deveco.go |
| ACP 类 backend | Hermes/Kimi/Kiro/Grok/Qoder/Trae:Agent Client Protocol | hermes.go、kimi.go、kiro.go 等 |
| 终结果收敛契约 | 把"进程退出"翻译成 status/output/error | stream_json_result.go (finalizeStreamResult:32-87) |
| stderr 尾巴捕获 | 崩溃时把最后几 KB 错误带回给用户 | stderr_tail.go (stderrTail:52-98) |
| MCP 配置注入 | 把托管 MCP 配置喂给各家 CLI | mcp_config.go、browser_mcp_config.go、opencode_mcp.go |
| 思考档位归一 | 发现/校验各家 reasoning-effort 词表 | thinking.go |
| 版本探测与门槛 | --version 解析、最低版本 gating | version.go、claude.go (detectCLIVersion:1000) |
| 模型枚举 | 列出各 provider 的模型目录 | models.go (ListModels:105) |
3. 核心机制之一:Backend 接口与统一数据模型
3.1 接口小到只 有一个方法
整个抽象的地基,是一个只有一个方法的接口:
// server/pkg/agent/agent.go:17-22
type Backend interface {
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
}
Execute 返回一个 Session,里面装两个只读 channel:一个流实时事件,一个收最终结果。
// server/pkg/agent/agent.go:103-109
type Session struct {
Messages <-chan Message // 边跑边来,跑完前 close
Result <-chan Result // 只来一个值,然后 close
}
New(agentType, cfg) 是工厂:传个类型名("claude"、"codex"……),吐出对应的 Backend 实现——就是一个大 switch(agent.go:284-327)。受支持的 17 种类型登记在 SupportedTypes(agent.go:225-243),这份白名单必须和数据库那侧的 CHECK 约束逐字对齐(注释里点名了 migration 120/134/136/175/179/202),否则自定义 runtime profile 会对不上号。
3.2 ExecOptions:一大把"旋钮",但每个 backend 只认自己那几个
ExecOptions(agent.go:25-87)是喂给一次执行的全部配置。它字段很多,设计上的关键是:一个字段可能只被一两个 backend 消费,其余 backend 直接忽略而不是报错。这让新能力可以只在一个 backend 里长出来,不惊动其他 15 个。
挑几个有代表性的旋钮:
| 字段 | 含义 | 谁消费 |
|---|---|---|
Cwd / Model / SystemPrompt | 工作目录、模型、系统提示 | 几乎所有 backend(SystemPrompt 除外,Hermes ACP 故意忽略它) |
ThinkingLevel | 运行时原生的推理档位("low/medium/high…") | claude、codex、opencode、codebuddy、grok;其余忽略 |
ServiceTier | Codex 的执行层级("priority" 显示为 Fast) | 仅 codex |
ResumeSessionID | 非空则续接上一段会话 | 支持续接的 backend |
ResumeExpected | "本意是要续接的"——即使 ResumeSessionID 被回退清空 | 仅 codex(用来贴"上文丢了"的提示) |
OpenclawMode | openclaw 走本地还是网关路由 | 仅 openclaw |
McpConfig | 托管的 MCP 服务器配置 | 大多数 backend(注入方式各异) |
ClaudeSettingsPath | daemon 拥有的任务级 settings 文件 | 仅 claude |
这条"别人忽略也不报错"的纪律,在注释里被反复强调,例如 ThinkingLevel 的注释明说其他 backend "ignore the field rather than fail"(agent.go:64-66)。它就是本包能"增量支持、不互相拖累"的根源。
3.3 Message:把各家原生事件压成同一种
不论底下 CLI 说什么协议,流出来的实时事件都被翻译成同一个 Message 结构,靠一个 Type 区分种类:
// server/pkg/agent/agent.go:111-135(节选)
const (
MessageText MessageType = "text" // 助手正文
MessageThinking MessageType = "thinking" // 思考过程
MessageToolUse MessageType = "tool-use" // 发起工具调用
MessageToolResult MessageType = "tool-result" // 工具返回
MessageStatus MessageType = "status" // 状态(含 SessionID 早绑定)
MessageError MessageType = "error"
MessageLog MessageType = "log"
)
有了这一层,上层做实时展示时,面对的永远是这 7 种事件,而不是 15 种协议的原始 JSON。
3.4 Result:一次执行最终落成的那一个东西
Result(agent.go:161-194)是整章的落点——一段模型输出最终变成的结构化结果。
// server/pkg/agent/agent.go:161-183(节选)
type Result struct {
Status string // completed/failed/aborted/timeout/cancelled
Output string // 面向用户的最终答案
Error string // 失败原因
DurationMs int64
SessionID string
Usage map[string]TokenUsage // 按模型名分桶的 token 消耗
ResumeRejected bool // 续接是否被明确拒绝(见 §5.3)
}
Status 只有五种取值,是上层判断"这次到底怎么了"的唯一依据。Usage 按模型名分桶,因为一次会话里可能跨多个模型调用。
4. 核心机制之二:三种传输范式,一份共同的落地契约
各 backend 最大的差异在"和 CLI 的传输方式"。它们大致落在三种范式里。先看一张对照,再各挑一个主例讲透。
| 范式 | 代表 backend | prompt 怎么进 | 事件怎么出 | 结束信号 |
|---|---|---|---|---|
| stream-json(一发一收) | claude、cursor、qwen、codebuddy | stdin 的 JSON 帧 / stdin 明文 / argv | stdout JSONL,一行一事件 | 一个 result 事件 |
| app-server(长连接双向) | codex | JSON-RPC 请求体 | JSON-RPC 响应 + 通知,长连接 | turn 完成通知 |
| run 子命令(跑一次) | opencode、copilot、deveco | 命令行参数 | stdout JSON 事件 | 进程退出 |
ACP 范式(hermes/kimi/kiro/grok/qoder/trae)是第四种,走 Agent Client Protocol;本章以前三种为主例,ACP 作对照点到为止。
4.1 主例 A:Claude —— stream-json(最"直觉"的一种)
Claude backend 的思路最好懂:起一个子进程,prompt 从 stdin 灌进去,stdout 一行一个 JSON 事件读出来,读到 result 就算完。
先看命令行怎么拼(buildClaudeArgs,claude.go:717-775):
claude -p
--output-format stream-json ← 让它吐 JSONL 流
--input-format stream-json ← 让它从 stdin 读 JSON 帧
--verbose
--permission-mode bypassPermissions ← 自主运行,不弹权限确认
--disallowedTools AskUserQuestion ← 禁掉交互式提问(见下)
[--model / --effort / --resume / --mcp-config …]
--disallowedTools AskUserQuestion 是个有意思的细节:daemon 跑的是无界面的自主模式,Claude 那个"向用户提问"的内置工具没有 UI 可渲染,调了只会静默返回空答案(GitHub #2588),所以直接禁掉——要澄清就去 issue 里发评论。
prompt 不是当参数传,而是包成一个 JSON 帧写进 stdin:
// server/pkg/agent/claude.go:679-697 buildClaudeInput(节选)
payload := map[string]any{
"type": "user",
"message": map[string]any{
"role": "user",
"content": []map[string]string{{"type": "text", "text": prompt}},
},
}
// → 序列化后追加一个 '\n' 写进 stdin
核心是读流循环(claude.go:221-273):bufio.Scanner 逐行扫 stdout,每行 json.Unmarshal 成一个 claudeSDKMessage,按 Type 分派:
读到一行 JSON
├─ "assistant" → 拆出 text/thinking/tool_use,翻成 Message 发给上层
├─ "user" → 拆出 tool_result,翻成 MessageToolResult
├─ "system" → 记下 session_id,发一个 "running" 状态(带 SessionID 早绑定)
├─ "result" → 终结事件!记下最终文本 + is_error,关闭 stdin
├─ "log" → 翻成 MessageLog
└─ "control_request" → 自动批准工具调用(见下)
有个双向细节:Claude 的 stream-json 协议会中途发 control_request(比如请求批准某个工具),backend 必须在同一条 stdin 上回一个 control_response。handleControlRequest(claude.go:452-494)一律回 "behavior": "allow" 自动批准——而且顺手把 run_in_background: true 强行改成 false(forceClaudeToolInputForeground,claude.go:496-502),因为 Multica 托管的运行要求前台执行。这也是为什么 stdin 不能在写完 prompt 后就关掉——关早了子进程会卡在等 control_response(claude.go:150-154 注释)。
4.2 主例 B:Codex —— app-server(长连接 JSON-RPC,复杂度最高)
Codex 完全是另一个世界:它不是"发一段读一段",而是起一个长期存活的 app-server 进程,和它做一整套 JSON-RPC 对话——有请求/响应(带 id 配对)、有服务端主动发来的通知、有"线程(thread)"和"回合(turn)"的概念。
它的命令行只是 codex app-server(启动方式在 executeOnce,codex.go:936),真正的交互全在一个 JSON-RPC 客户端 codexClient(codex.go:2138-2181)里:
// server/pkg/agent/codex.go:1846-1861(节选)
type codexClient struct {
stdin interface{ Write([]byte) (int, error) }
nextID int // 请求 id 自增
pending map[int]*pendingRPC // id → 等应答的请求
processDone chan struct{}
threadID string // 当前线程
turnID string // 当前回合
// …通知门控、用量累积、turn 错误捕获…
}
一次请求(request,codex.go:2306-2388)的形状是标准 JSON-RPC:自增一个 id,把请求登记进 pending,写进 stdin,然后阻塞等三件事之一——应答回来 / 进程死了 / 超时:
c.request("turn/start", params)
1. id = nextID++; pending[id] = 等待通道
2. 写 {"jsonrpc":"2.0","id":id,"method":"turn/start","params":…}\n 到 stdin
3. select {
case 应答从 pending[id] 回来 → 返回结果
case <-processDone → 进程退出,报 errCodexProcessExited
case <-requestCtx.Done() → 超时(握手类 RPC 有独立 handshakeTimeout)
}
一次执行的高层编排(executeOnce)大致是:握手 initialize → 开或续线程(startOrResumeThread)→ 发 turn/start 灌 prompt → 持续读 stdout 上的通知,把 item 通知翻成 Message → turn 完成通知到 → 收敛 Result。
Codex 独有两个精华:
- "续接失败要如实告诉用户"。 若本意要续接(
ResumeExpected)但最终落在了新线程上,codexTurnInput(codex.go:1781-1787)会在 prompt 前面拼一段续接提示,让模型主动告诉用户"上文没能恢复、这是新会话",而不是默默当新对话继续。提示文本不再是本文件里的常量,改由调用方经ExecOptions.ResumeContinuityNotice传入(agent.go:70-82)——措辞取决于会话是否仍可读,只有知道运行界面的调用方能选对(MUL-5722)。 - 两段式重试。
Execute(codex.go:835-934)在executeOnce外面裹了一层最多两次的重试:只有两种"重试安全"的启动期失败(initialize 超时、模型目录刷新失败)才重试,且重试前会扣住领头的 session-pin 状态消息不往上发,直到这次尝试真的产出进展——否则会把 resume 指针指向一个从没产出过 turn 的废线程(codex.go:857-863注释,MUL-5110)。
对比一下就懂 Codex 为什么这么重:stream-json 是单向读一条流,app-server 是要维护一个有状态的双向协议客户端。