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:609-666):
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:161-215):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:380-422)一律回 "behavior": "allow" 自动批准——而且顺手把 run_in_background: true 强行改成 false(forceClaudeToolInputForeground,claude.go:424-430),因为 Multica 托管的运行要求前台执行。这也是为什么 stdin 不能在写完 prompt 后就关掉——关早了子进程会卡在等 control_response(claude.go:116-120 注释)。
4.2 主例 B:Codex —— app-server(长连接 JSON-RPC,复杂度最高)
Codex 完全是另一个世界:它不是"发一段读一段",而是起一个长期存活的 app-server 进程,和它做一整套 JSON-RPC 对话——有请求/响应(带 id 配对)、有服务端主动发来的通知、有"线程(thread)"和"回合(turn)"的概念。
它的命令行只是 codex app-server(启动方式在 executeOnce,codex.go:827),真正的交互全在一个 JSON-RPC 客户端 codexClient(codex.go:1846-1883)里:
// 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:2008-2090)的形状是标准 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:1533-1547)会在 prompt 前面拼一段系统提示codexResumeUnavailableNotice(codex.go:1525),让模型主动告诉用户"上文没能恢复、这是新会话",而不是默默当新对话继续。 - 两段式重试。
Execute(codex.go:727-825)在executeOnce外面裹了一层最多两次的重试:只有两种"重试安全"的启动期失败(initialize 超时、模型目录刷新失败)才重试,且重试前会扣住领头的 session-pin 状态消息不往上发,直到这次尝试真的产出进展——否则会把 resume 指针指向一个从没产出过 turn 的废线程(codex.go:749-755注释,MUL-5110)。
对比一下就懂 Codex 为什么这么重:stream-json 是单向读一条流,app-server 是要维护一个有状态的双向协议客户端。
4.3 主例 C:OpenCode —— run 子命令(prompt 当参数,配置走环境变量)
OpenCode 走第三种:opencode run --format json,prompt 直接当最后一个命令行参数,stdout 吐 JSON 事件,进程跑完即止。
// server/pkg/agent/opencode.go:69-98(节选)
args := []string{"run", "--format", "json", "--dangerously-skip-permissions"}
if opts.Cwd != "" { args = append(args, "--dir", opts.Cwd) } // 锚定 AGENTS.md/skill 发现根
if opts.Model != "" { args = append(args, "--model", opts.Model) }
if opts.ThinkingLevel != "" { args = append(args, "--variant", opts.ThinkingLevel) } // 档位=模型变体
if opts.ResumeSessionID != "" { args = append(args, "--session", opts.ResumeSessionID) }
args = append(args, prompt) // ← prompt 是 argv 的最后一项
它的 MCP 注入方式也和 Claude 不同:不是写临时文件传 --mcp-config,而是塞进环境变量 OPENCODE_CONFIG_CONTENT(opencode.go:136-157)——这是 OpenCode 自己的内联配置合并机制。
4.4 无论哪种范式,终点是同一份"fail-closed"契约
三种传输不一样,但 stream-json 那几个 backend 收尾时共用同一个终结果收敛函数 finalizeStreamResult(stream_json_result.go:32-87)。它的核心信念一句话:进程干净退出 ≠ 协议正常完成。成功必须有正面证据——一个 result 事件;没有它,就算 exit code 是 0 也判失败。
// server/pkg/agent/stream_json_result.go:67-70(节选)
case !state.sawResult && status == "completed":
status = "failed"
errMsg = provider + " stream ended without terminal result"
为什么这么较真?因为失败的运行必须返回空 output,这样上层的 issue/chat 回退逻辑才不会把一段半截的 transcript 误当成最终答案(函数头注释,stream_json_result.go:27-31)。判定顺序也有讲究:
status = completed(乐观起步)
├─ result 事件说 is_error → failed
├─ runErr 是 DeadlineExceeded → timeout
├─ runErr 是 Canceled → aborted
├─ 扫描出错 / 写 stdin 出错 / 进程非零退出 → failed
└─ 从没见过 result 事件 → failed(核心那条)
最终:completed 才返回真答案;非 completed 一律返回空 output
app-server(Codex)和 ACP 类不走这个函数,它们各自有等价的"必须见到 turn 完成/成功事件才算成功"的判定,但理念完全一致:宁可漏报成功,不可把残缺当完成。
4.5 崩溃时,把最后几 KB 错误带回来
CLI 有时会在吐出结构化错误之前就崩了(Windows 上 V8 abort、Bun panic、OOM)。这时上层只看到 "exit status 3" 这种没用的东西——真正的原因烂在 daemon 日志里。
stderrTail(stderr_tail.go:52-98)解决这个:它把子进程 stderr 一边转发给 daemon 日志,一边留一段有界的尾巴(默认 2048 字节)。失败时用 withAgentStderr(stderr_tail.go:104-109)把这段尾巴拼进 Result.Error。
但直接把 stderr 塞进用户可见的错误里有泄密风险,所以先过 sanitizeAgentDiagnostic(stderr_tail.go:29-40):正则抹掉 Authorization 头、JSON 里的 token/secret/password、key=value 形态的密钥,再去掉控制字符和 home 目录细节。转发给日志的是原文,落 进任务行的是脱敏版。
5. 核心机制之三:一批"跨 backend"的共性能力
除了传输,pkg/agent 还统一处理几件所有 backend 都要的脏活。这些是本包"工程含量"的另一半。
5.1 MCP 配置注入:同一份配置,喂法各不相同
MCP(Model Context Protocol,给 agent 挂外部工具服务器的标准)配置从上层以 opts.McpConfig(一段 JSON)传进来。第一步是三态语义判断——hasManagedMcpConfig(mcp_config.go:11-14):只有 SQL NULL / JSON null 才叫"继承运行时默认";任何对象(哪怕空对象 {})都算"托管集",要开启严格模式。
然后各 backend 喂法不同:
| backend | 注入方式 |
|---|---|
| claude | 写临时文件,传 --mcp-config <path>;有托管配置时加 --strict-mcp-config(claude.go:43-52, 624-629) |
| codex | 物化进任务级 $CODEX_HOME/config.toml(ensureCodexMcpConfig,codex.go:390)——因为 MCP 的 env 可能含密钥,写文件才不会泄进 argv 和 ps |
| opencode | 塞进环境变量 OPENCODE_CONFIG_CONTENT(opencode_mcp.go + opencode.go:147-157) |
Windows 上还有一层浏览器 MCP 加固(browser_mcp_config.go:18 hardenBrowserMcpConfig):给 Playwright MCP 补 --config、给 chrome-devtools MCP 兜底一个 Edge 可执行路径,免得在 Windows 上找不到浏览器。
5.2 思考档位归一:不拉平,只如实转达各家词表
各家 CLI 的"推理努力度"词表根本不一样:Claude 是 low|medium|high|xhigh|max,Codex 是 none|minimal|low|medium|high|xhigh|max|ultra,OpenCode 用的是模型变体名。thinking.go 的核心决策是故意不把它们拍平成一个共享枚举(thinking.go:16-22,MUL-2339),因为用户选的值必须逐字能被对应 CLI 认。
它做两件事:
- 动态发现每个 CLI 本地实际支持哪些档位:Claude 靠解析
claude --help里--effort (…)那行(claudeEffortRe,thinking.go:87);Codex 靠codex debug models --bundled拿结构化目录(discoverCodexModels,thinking.go:313)。发现结果按(provider, execPath, cliVersion)缓存 10 分钟——升级 CLI 会自动失效旧缓存(thinking.go:31-47)。 - 校验用户填的档位对不对(
ValidateThinkingLevel,thinking.go:604-653)。这里有个 fail-closed 精华:Codex 若没指定模型,直接判非法——因为它的有效模型来自本地config.toml、无从得知,借用目录里的 Default(唯一支持ultra的那个)会放行别的模型压根不支持的档位。
5.3 会话续接与"续接被拒"的识别
续接(resume)最微妙的地方不是"怎么续",而是"续接被拒要能识别出来"——因为只有"续接被拒"这一种失败,能靠"开个全新会话重来"治好。
Result.ResumeRejected(agent.go:161-183 里的字段注释)就是这个正面证据。关键纪律:false 不等于"没被拒"。对某些 backend 它意思是"根本判断不了"。哪些 backend 判断不了,登记在 resumeRejectionUndetectable(agent.go:268-274,含 antigravity/copilot/cursor/deveco/opencode)——这份表默认不收录,新 backend 缺席即被当作"有能力判断",从而 fail-closed。
stream-json 那几家怎么判断?靠短语匹配(resumeWasRejected,claude.go:738-753):把 provider 可能写原因的地方(最终错误串 + stderr 尾巴)都扫一遍,命中 resumeRejectedPhrases(claude.go:708-728)里那几句就算被拒。这份短语表匹配得很紧——注释明说误报会让 daemon 白扔一个本可恢复的 session 指针去重跑任务。表里甚至标了哪几句是"已核实"(如 claude 的 "no conversation found"、issue #5704 里 zh-CN 的 "已绑定另外")、哪几句是"推断未证实"(英文版账号绑定提示)。这种诚实标注正是本包的可信度来源。
5.4 版本探测与最低版本门槛
daemon 注册时要探测每个 CLI 的版本。detectCLIVersion(claude.go:1000-1017)跑 <cli> --version,但带两道超时保险:一个 10 秒 context,一个 WaitDelay 2 秒——因为坏掉的 node/bun shim 会留下还攥着 stdout 管道的孙进程,让 cmd.Output() 在 Wait() 里卡死、绕过 context 超时(claude.go:1006-1011 注释)。输出还要过 extractVersionLine(claude.go:1030-1041)挑出真正带 semver 的那行,因为 Windows 上 npm shim 会先吐 Active code page: 65001 之类噪声(#2516)。
拿到版本后按 MinVersions(version.go:13-19)校验:比如 codex 要 ≥ 0.100.0(app-server --listen stdio:// 从这版才有)。CheckMinVersion(version.go:156-173)低于门槛就拒绝注册。源码构建的 dev 版(git describe 那种 v0.2.15-235-gxxx 形态)有豁免(devDescribeRe,version.go:82),免得 make daemon 被自己卡住。
6. 一段模型输出如何被落成一个 TaskResult(端到端简化示意)
把前面的机制串起来,走一遍 Claude 的完整路径(这是示意,非源码,真源码见 claude.go:24-306):
# 示意,非源码:一次 Claude 执行如何变成一个 Result
def execute(prompt, opts):
args = build_claude_args(opts) # 拼 -p --output-format stream-json …
if has_managed_mcp(opts.mcp_config): # 三态判断:非 null 才托管
path = write_mcp_temp(opts.mcp_config)
args += ["--mcp-config", path, "--strict-mcp-config"]
proc = spawn("claude", args, cwd=opts.cwd) # 起子进程
stderr_tail = wrap_stderr(proc) # 有界尾巴 + 转发日志
spawn_thread(lambda: write_json_frame(proc.stdin, prompt)) # prompt 从 stdin 灌
state = TerminalState() # 攒:最后一条助手文本 / 终结果 / 是否见过 result
for line in read_lines(proc.stdout): # 逐行 JSONL
ev = json.loads(line)
if ev.type == "assistant":
emit_messages(ev) # 翻成 MessageText/Thinking/ToolUse 发给上层
state.last_assistant = text_of(ev)
elif ev.type == "result": # ← 终结事件
state.saw_result = True
state.final_text = ev.result
state.is_error = ev.is_error
close(proc.stdin)
elif ev.type == "control_request":
respond_allow(proc.stdin, ev) # 自动批准,强制前台
exit_err = proc.wait()
# fail-closed 收敛:没见过 result 事件 = 失败,即使 exit code 为 0
status, output, err = finalize_stream_result("claude", state, exit_err, ...)
if err:
err = with_stderr_tail(err, stderr_tail.tail()) # 崩溃时带上最后几 KB
resume_rejected = resume_was_rejected(opts.resume_id, state.session_id, status=="failed", err)
return Result(status=status, output=output, error=err,
session_id=("" if resume_rejected else state.session_id),
usage=state.usage, resume_rejected=resume_rejected)
重点看三处:(1) for line in read_lines 这条循环就是"模型输出"进来的地方;(2) finalize_stream_result 是"落成结果"的收口,它坚持没有 result 事件就判失败;(3) 返回的 Result 里 session_id 在续接被拒时被清空,resume_rejected 单独如实上报——这两条决定了 daemon 之后能不能安全地"重来"。
7. 巧妙之处(可借鉴的技术)
-
"别人忽略也不报错"的旋钮契约。
ExecOptions一个字段只被少数 backend 消费,其余静默 fall-through(agent.go:64-66等多处)。这让新能力能只在一个 backend 里长出来,不动其余 15 个——是"增量扩展 15+ 异构后端"能成立的根本。 -
fail-closed 的成功判定。 干净退出不算成功,必须见到终结事件;失败一律返回空 output(
stream_json_result.go:27-31, 67-70)。宁可漏报成功,也不让残缺 transcript 冒充答案。 -
false不等于"否",而是"判断不了"。ResumeRejected和resumeRejectionUndetectable(agent.go:257-274)把"检查过、不是"和"根本没法检查"分开,新 backend 缺席即 fail-closed。这是一种对"不确定性"很诚实的建模。 -
密钥不进 argv,只进 0600 文件。 Codex 的 MCP env 可能含密钥,所以物化进
config.toml而非命令行(codex.go:847-856),躲开ps和日志。同理 stderr 尾巴落库前先脱敏(stderr_tail.go:29-40)。 -
思考档位不拉平。 拒绝造一个"统一枚举",坚持各家词表逐字 round-trip(
thinking.go:16-22),并用(provider, execPath, cliVersion)做缓存键,让升级 CLI 自动失效旧目录。 -
续接短语表带"已核实/推断"标注。
resumeRejectedPhrases(claude.go:708-728)对每条都注明是真实抓到的还是推断的,并说明推断错了会如何优雅降级。工程诚实度的范本。
8. 边界与局限(它刻意不做什么)
-
不负责调度。 本包只把"一次执行"做出来。谁在何时调用
Execute、任务怎么认领/排队/上报,是 daemon 的事(见 02-local-daemon.md)。 -
不统一 thinking/model 词表。 故意不做跨 provider 的抽象枚举——各家的推理档位、模型名保持原样透传(
thinking.go:16-22)。 -
有些能力按 backend 分布不均。
ServiceTier只有 codex 认;ResumeExpected的续接提示只有 codex 贴;ClaudeSettingsPath只有 claude 用。不是所有 backend 功能对齐,而是"谁需要谁长"。 -
续接被拒的识别对部分 backend 是盲区。
resumeRejectionUndetectable(agent.go:268-274)里那五个 backend 只能从流里 scrape session id,没有拒绝检测——它们的false永远是"不知道"。 -
CLI 版本过低直接拒。 低于
MinVersions(version.go:13-19)的 CLI 在 daemon 注册期就被CheckMinVersion挡下,不会进到执行。
9. 横向对比(同 shelf 兄弟项目)
Multica 的 pkg/agent 和其他"多后端 agent 网关"项目取舍不同:
- 多数框架(如各类 Python agent 库)抽象的是模型 API(OpenAI/Anthropic HTTP 接口)这一层。
pkg/agent抽象的是更外面的一层——整个编码 CLI(Claude Code、Codex CLI……),连它们的子进程管理、stdin/stdout 协议、MCP 注入都吃进来。抽象点更高,脏活更多。 - 它坚持"薄适配 + fail-closed 契约",而不是"厚统一模型"。宁可让
ExecOptions字段各认各的,也不造一个所有 CLI 都得迁就的最小公共子集。
同组其它章可继续读:daemon 如何驱动本包(02)、服务端任务状态机(03)、实时协议(04)。
10. 代码地图(导航索引)
用符号名
grep比行号抗漂 移;下表列真实符号,行号 as-ofsourceCommit。
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 统一接口 | server/pkg/agent/agent.go | Backend、Session |
| backend 工厂 | server/pkg/agent/agent.go | New、SupportedTypes、IsSupportedType |
| 执行输入旋钮 | server/pkg/agent/agent.go | ExecOptions、runContext |
| 事件模型 | server/pkg/agent/agent.go | Message、MessageType、TokenUsage |
| 结果模型 | server/pkg/agent/agent.go | Result、ResumeRejectionUndetectable |
| Claude stream-json | server/pkg/agent/claude.go | claudeBackend.Execute、buildClaudeArgs、writeClaudeInput |
| Claude 事件分派 | server/pkg/agent/claude.go | handleAssistant、handleControlRequest、forceClaudeToolInputForeground |
| custom_args 过滤 | server/pkg/agent/claude.go | filterCustomArgs、claudeBlockedArgs |
| 续接拒绝识别 | server/pkg/agent/claude.go | resumeWasRejected、resumeRejectedPhrases、resolveSessionID |
| 版本探测 | server/pkg/agent/claude.go | detectCLIVersion、extractVersionLine |
| Codex app-server | server/pkg/agent/codex.go | codexBackend.Execute、executeOnce、codexClient |
| Codex JSON-RPC 传输 | server/pkg/agent/codex.go | codexClient.request、startOrResumeThread、codexTurnInput |
| Codex MCP 注入 | server/pkg/agent/codex.go | ensureCodexMcpConfig、hasManagedCodexMcpConfig |
| OpenCode run 子命令 | server/pkg/agent/opencode.go | opencodeBackend.Execute、opencodeBlockedArgs |
| OpenCode MCP env 注入 | server/pkg/agent/opencode_mcp.go | buildOpenCodeMCPConfigContent |
| 终结果收敛契约 | server/pkg/agent/stream_json_result.go | finalizeStreamResult、streamTerminalState |
| stderr 尾巴 / 脱敏 | server/pkg/agent/stderr_tail.go | stderrTail、withAgentStderr、sanitizeAgentDiagnostic |
| MCP 三态语义 | server/pkg/agent/mcp_config.go | hasManagedMcpConfig |
| Windows 浏览器 MCP 加固 | server/pkg/agent/browser_mcp_config.go | hardenBrowserMcpConfig |
| 思考档位发现/校验 | server/pkg/agent/thinking.go | ValidateThinkingLevel、discoverCodexModels、IsKnownThinkingValue |
| 最低版本门槛 | server/pkg/agent/version.go | MinVersions、CheckMinVersion、parseSemver |
| 模型枚举 | server/pkg/agent/models.go | ListModels、Model、ModelThinking |
| Windows 命令行改写 | server/pkg/agent/copilot_invocation.go | chooseCopilotInvocation、platformCopilotInvocation |