跳到主要内容

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 -pcodex app-serveropencode runcursor-agentgrok 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() 工厂定义统一契约,按类型名建出具体 backendagent.go (Backend:17-22, New:284-327)
ExecOptions / Message / Result统一的输入旋钮、事件模型、结果模型agent.go (ExecOptions:25-87, Message:125-135, Result:161-194)
stream-json 类 backendClaude/CodeBuddy/Cursor/Qwen:读 JSONL 流claude.gocodebuddy.gocursor.goqwen.go
app-server 类 backendCodex:长连接 JSON-RPC 客户端codex.go
run 子命令类 backendCopilot/OpenCode/DevEco:跑一次吐 JSON 事件copilot.goopencode.godeveco.go
ACP 类 backendHermes/Kimi/Kiro/Grok/Qoder/Trae:Agent Client Protocolhermes.gokimi.gokiro.go
终结果收敛契约把"进程退出"翻译成 status/output/errorstream_json_result.go (finalizeStreamResult:32-87)
stderr 尾巴捕获崩溃时把最后几 KB 错误带回给用户stderr_tail.go (stderrTail:52-98)
MCP 配置注入把托管 MCP 配置喂给各家 CLImcp_config.gobrowser_mcp_config.goopencode_mcp.go
思考档位归一发现/校验各家 reasoning-effort 词表thinking.go
版本探测与门槛--version 解析、最低版本 gatingversion.goclaude.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;其余忽略
ServiceTierCodex 的执行层级("priority" 显示为 Fast)仅 codex
ResumeSessionID非空则续接上一段会话支持续接的 backend
ResumeExpected"本意是要续接的"——即使 ResumeSessionID 被回退清空仅 codex(用来贴"上文丢了"的提示)
OpenclawModeopenclaw 走本地还是网关路由仅 openclaw
McpConfig托管的 MCP 服务器配置大多数 backend(注入方式各异)
ClaudeSettingsPathdaemon 拥有的任务级 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 的传输方式"。它们大致落在三种范式里。先看一张对照,再各挑一个主例讲透。

范式代表 backendprompt 怎么进事件怎么出结束信号
stream-json(一发一收)claude、cursor、qwen、codebuddystdin 的 JSON 帧 / stdin 明文 / argvstdout JSONL,一行一事件一个 result 事件
app-server(长连接双向)codexJSON-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_responsehandleControlRequest(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/passwordkey=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) 返回的 Resultsession_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 不等于"否",而是"判断不了"。 ResumeRejectedresumeRejectionUndetectable(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-of sourceCommit

主题文件路径符号名
统一接口server/pkg/agent/agent.goBackendSession
backend 工厂server/pkg/agent/agent.goNewSupportedTypesIsSupportedType
执行输入旋钮server/pkg/agent/agent.goExecOptionsrunContext
事件模型server/pkg/agent/agent.goMessageMessageTypeTokenUsage
结果模型server/pkg/agent/agent.goResultResumeRejectionUndetectable
Claude stream-jsonserver/pkg/agent/claude.goclaudeBackend.ExecutebuildClaudeArgswriteClaudeInput
Claude 事件分派server/pkg/agent/claude.gohandleAssistanthandleControlRequestforceClaudeToolInputForeground
custom_args 过滤server/pkg/agent/claude.gofilterCustomArgsclaudeBlockedArgs
续接拒绝识别server/pkg/agent/claude.goresumeWasRejectedresumeRejectedPhrasesresolveSessionID
版本探测server/pkg/agent/claude.godetectCLIVersionextractVersionLine
Codex app-serverserver/pkg/agent/codex.gocodexBackend.ExecuteexecuteOncecodexClient
Codex JSON-RPC 传输server/pkg/agent/codex.gocodexClient.requeststartOrResumeThreadcodexTurnInput
Codex MCP 注入server/pkg/agent/codex.goensureCodexMcpConfighasManagedCodexMcpConfig
OpenCode run 子命令server/pkg/agent/opencode.goopencodeBackend.ExecuteopencodeBlockedArgs
OpenCode MCP env 注入server/pkg/agent/opencode_mcp.gobuildOpenCodeMCPConfigContent
终结果收敛契约server/pkg/agent/stream_json_result.gofinalizeStreamResultstreamTerminalState
stderr 尾巴 / 脱敏server/pkg/agent/stderr_tail.gostderrTailwithAgentStderrsanitizeAgentDiagnostic
MCP 三态语义server/pkg/agent/mcp_config.gohasManagedMcpConfig
Windows 浏览器 MCP 加固server/pkg/agent/browser_mcp_config.gohardenBrowserMcpConfig
思考档位发现/校验server/pkg/agent/thinking.goValidateThinkingLeveldiscoverCodexModelsIsKnownThinkingValue
最低版本门槛server/pkg/agent/version.goMinVersionsCheckMinVersionparseSemver
模型枚举server/pkg/agent/models.goListModelsModelModelThinking
Windows 命令行改写server/pkg/agent/copilot_invocation.gochooseCopilotInvocationplatformCopilotInvocation