上一篇 讲了 Eino ADK 的 Agent 生命周期。收尾前还剩两块"周边件":ACP 协议桥 (让 Eino Agent 接入外部编辑器,双向通信)和 devops 可视化(把编译后的 Graph 暴露成 HTTP 画布,给前端调试用)。一个向外交互,一个向内观察------正好是一进一出两条桥。
源码都在 eino-ext 下:acp/ 两个文件(backend.go 894 行 + conv.go 约 400 行),devops/ 一个模块。这篇拆完,Eino 的全景就闭环了。
(一)ACP 是什么:编辑器和 Agent 的通用语
ACP(Agent Client Protocol) 是一个开放协议,解决的问题是:编辑器(Zed、Neovim 等)和 Agent 后端怎么对话?
没有 ACP 之前,每个编辑器插件都要为每个 Agent 框架写一遍适配。有了 ACP,编辑器只需要实现一份协议客户端,任何实现 ACP 的 Agent 都能接入。
Eino 的接入层在 eino-ext/acp,README 里一句话说清了两个方向:
AgentEventToSessionUpdate------下行:把 Eino 的AgentEvent流转成 ACP 的SessionUpdate通知,推给编辑器NewClientToolsMiddleware------上行:把编辑器侧的能力(文件系统、终端)桥接成 Eino 的文件系统工具,Agent 反过来操作用户机器
(二)下行:事件转换 conv.go
conv.go:99-102 的签名:
go
func AgentEventToSessionUpdate(
event *adk.AgentEvent,
opt *EventConverterOption,
) iter.Seq2[acpproto.SessionUpdate, error]
映射关系(README 里的表):
| Eino 事件 | ACP SessionUpdate |
|---|---|
| Assistant 消息 | AgentMessageChunk |
| 推理内容(reasoning) | AgentThoughtChunk |
| User 消息 | UserMessageChunk |
| 工具调用 | ToolCall |
| 工具结果 | ToolCallUpdate |
| 中断 | AgentMessageChunk + _meta["eino:interrupted"] |
中断的跨进程契约
中断最特殊。ACP 协议本身没有"中断"概念,Eino 的做法是塞进 _meta(conv.go:33-35):
go
const (
MetaKeyInterrupted = "eino:interrupted"
MetaKeyInterruptContexts = "eino:interruptContexts"
)
注释里有一句关键的话:"These form a cross-process contract with clients; changing them is a breaking change."------这两个 key 是和客户端约定的跨进程契约,改名就是破坏性变更。不发明新消息类型,复用文本通道 + 元数据标记,是协议适配里最克制的做法。
流式工具调用:按 Index 聚合
流式模型输出时,工具调用的参数是一块块来的。上游只在第一个块 带 ID 和 Name(conv.go:74-81 的注释解释了原因:eino 的 concatToolCalls 按 Index 聚合,按 ID 会静默丢块)。所以转换器维护一个按 Index 键控的累积器:
go
type toolCallAccum struct {
id string
name string
args strings.Builder
}
默认把所有块拼成一个完整 ToolCall 再发;开 PreserveToolCallStream 则逐块透传,客户端按 ToolCallID 重组(ID 一变即上一个调用结束)。
(三)上行:能力门控 backend.go
NewClientToolsMiddleware(backend.go:143-185)的核心逻辑是一张能力 → 工具启用矩阵 。ACP 协议只暴露三个能力:read_text_file、write_text_file、terminal。Eino 的文件系统工具有七个:ls/read/write/edit/glob/grep/shell。怎么映射?
go
// 默认全部 Disable: true
config := &mfs.MiddlewareConfig{
LsToolConfig: &mfs.ToolConfig{Disable: true},
ReadFileToolConfig: &mfs.ToolConfig{Disable: true},
// ... 全禁
}
if cfg.Capabilities.Terminal {
config.Shell = &shell{...} // shell 只看 Terminal
if cfg.UseTerminalForFileTools {
config.LsToolConfig = nil // ls/glob/grep 解禁
config.GlobToolConfig = nil
config.GrepToolConfig = nil
}
}
if b.hasReadFS { config.ReadFileToolConfig = nil }
if b.hasWriteFS { config.WriteFileToolConfig = nil }
if b.hasReadFS && b.hasWriteFS { config.EditFileToolConfig = nil } // edit = 读+写
四种组合(demo 实测):
| 客户端能力 | shell | ls/glob/grep | read/write | edit |
|---|---|---|---|---|
| fs + terminal | ✓ | ✓ | ✓ | ✓ |
| 仅 fs | · | · | ✓ | ✓ |
| terminal(不开文件工具) | ✓ | · | · | · |
| terminal(开文件工具) | ✓ | ✓ | · | · |
edit 要读+写双能力------因为它是读-改-写三步,缺一个都做不了。这个门控不是配置洁癖:客户端没声明的能力,工具对模型就不可见,模型不会尝试调用然后失败。
Edit 读-改-写(backend.go:634-690)
ACP 没有原子的"编辑"RPC,Edit 是拼出来的:ReadTextFile 全量读 → 内存替换 → WriteTextFile 全量写。三态校验:
go
count := strings.Count(content, req.OldString)
switch {
case count == 0:
return ErrOldStringNotFound // 没找到
case count > 1 && !req.ReplaceAll:
return ErrAmbiguousReplace // 多处命中,要求显式 ReplaceAll
}
外加 32 MiB 大小上限(maxEditFileSize)防 OOM。五个哨兵错误(backend.go:41-52)让调用方能 errors.Is 精确分派。
rg 探测三态机(backend.go:404-455)
grep 工具优先走 ripgrep(rg --json),客户端没装就回退 POSIX grep。探测用 command -v rg,结果三态缓存:
go
const (
rgUnprobed // 没探测过
rgAvailable // 确认有 rg
rgUnavailable // 确认没有
)
并发设计很讲究:TryLock 非阻塞------探测进行中,后来的并发 grep 不等 ,直接走 POSIX 回退(rgProbeMu 注释原文:"latecomers fall back to POSIX grep for that single call rather than waiting")。传输错误不缓存,保持 unprobed 下次重试。demo 实测:首次探测发 1 条命令,二次调用快路径 0 条。
POSIX grep 回退解析(backend.go:815-893)
回退方案用 grep -RnE,输出是 path:line:content。难题:路径里含冒号 (vendor/pkg:v2/file.go:7:func Foo())。解法是找第一个 <分隔符><数字><分隔符> 边界:
go
vendor/pkg:v2/file.go:7:func Foo()
↑ ":v" 中 v 非数字,跳过
↑ ":7:" 命中 → path / line / content 三分
注释里诚实标注了已知局限:路径本身含 :N: 模式时会切错,POSIX grep 没有 NUL 分隔输出模式(-Z 是 GNU 独有),这是固有的 best-effort 权衡。
shell 生命周期(backend.go:193-260)
一次 shell 执行四步 RPC:CreateTerminal → WaitForTerminalExit → TerminalOutput → ReleaseTerminal。Release 放在 defer 里且用独立的后台 ctx (5 秒超时)------调用方的 ctx 被取消(用户停止、超时)时,终端清理仍然执行,不泄漏。后台执行(RunInBackendGround)显式拒绝而非静默泄漏。
(四)devops 可视化:编译后的 Graph 长什么样
换方向。Agent 跑起来后,Graph 内部结构怎么看?eino-ext/devops 模块把这个能力做成了一个内嵌 HTTP 服务。
启动:两行代码(dev.go:33-48)
go
func Init(ctx context.Context, opts ...model.DevOption) error {
opt := model.NewDevOpt(opts)
apihandler.InitDebug(opt)
errCh := make(chan error)
safego.Go(ctx, func() {
errCh <- apihandler.StartHTTPServer(ctx, opt.DevServerIP, opt.DevServerPort)
})
select {
case err := <-errCh: // 启动即失败
return err
case <-time.After(2 * time.Second): // 2 秒没报错就认为起来了
return nil
}
}
默认监听 127.0.0.1:52538------只绑本机回环,注释里明确警告绑 0.0.0.0 有安全风险。2 秒超时是个务实的启发式:HTTP 服务器要么立刻报错,要么认为已就绪。
路由表(server.go:55-75)
bash
GET /eino/devops/ping 探活
GET /eino/devops/stream_log 日志流
GET /eino/devops/version 版本
GET /eino/devops/debug/v1/input_types 输入类型
GET /eino/devops/debug/v1/graphs 图列表
GET /eino/devops/debug/v1/graphs/{graph_id}/canvas 画布信息
POST /eino/devops/debug/v1/graphs/{graph_id}/threads 建调试线程
POST /eino/devops/debug/v1/graphs/{graph_id}/threads/{tid}/stream 调试执行(SSE)
五个 debug 路由构成完整调试环:看有哪些图 → 拿画布结构 → 开调试线程 → 流式执行。
(五)CanvasInfo:画布数据模型
先修正一个容易想当然的点:devops 不生成 Mermaid。 画布的最终形态是 CanvasInfo JSON------节点、边、分支的带类型描述,由 EinoDev 前端(VSCode 插件/Web)渲染成可交互画布,不是文本图表。Go 侧只负责"把编译期图结构翻译成自描述 JSON"。
模型(devops/model/canvas.go):
go
type CanvasInfo struct {
Version string `json:"version"` // "1.0.0"
*GraphSchema `json:",inline"`
}
type GraphSchema struct {
ID, Name string
Nodes []*Node
Edges []*Edge
Branches []*Branch
}
type NodeType string
const (
NodeTypeOfStart NodeType = "start"
NodeTypeOfEnd NodeType = "end"
NodeTypeOfBranch NodeType = "branch"
NodeTypeOfParallel NodeType = "parallel"
)
BuildGraphSchema:三步翻译(container.go:159-195)
编译后的图信息(GraphNodeInfo:节点名、组件类型、反射类型)翻译成画布:
- buildGraphNodes :插入虚拟
__start__/__end__节点;每个组件节点的输入输出类型经parseReflectTypeToJsonSchema转成 JSON Schema(前端显示类型提示用) - buildGraphEdges :一对一的边直接建;一对多(并行)插入虚拟并行节点
from:X------源节点 → from:X → N 个目标,让前端能画出扇出形状 - buildSubGraphSchema :嵌套图(如 Agent 内部的 react 循环)递归构建,挂到节点的
GraphSchema字段------画布可下钻
demo 实测(judge 节点扇出到 output_a/output_b):
ini
节点(7): __start__[start] __end__[end] agent[Graph] judge[Lambda]
output_a[Lambda] output_b[Lambda] from:judge[parallel]
边(4): agent~>judge judge~>from:judge
from:judge~>output_a from:judge~>output_b
子图: agent → react_loop(节点数=3)
序列化后 1098 字节的 JSON,就是前端画布消费的全部信息。
(六)两个模块的共同设计味道
| acp | devops | |
|---|---|---|
| 定位 | 向外:接编辑器 | 向内:给调试器 |
| 依赖方向 | Eino 类型 → ACP 协议类型 | Eino 编译产物 → 画布 JSON |
| 边界守卫 | 能力门控(没声明就不暴露) | 回环绑定(127.0.0.1) |
| 契约稳定性 | _meta key 是跨进程契约 |
CanvasInfo 带版本号 "1.0.0" |
| 降级策略 | rg → POSIX grep 双轨 | 无 |
共同点:都不发明新概念,只做忠实翻译。ACP 侧把 Eino 事件翻译成协议更新,devops 侧把图结构翻译成自描述 JSON。适配层的本分是透明,不是聪明。
小结
- ACP 下行 :
AgentEventToSessionUpdate六类映射;中断走_meta["eino:interrupted"]跨进程契约;流式工具调用按 Index 聚合(按 ID 丢块) - ACP 上行:能力 → 工具矩阵,edit 要读写双能力;Edit = 读改写三态校验 + 32MiB 上限;rg 探测三态缓存 + TryLock 非阻塞回退;shell 四步 RPC、Release 用独立后台 ctx
- devops :两行 Init 起本机 HTTP 服务;八条路由;CanvasInfo 不是 Mermaid,是前端画布消费的带类型 JSON;并行扇出用
from:X虚拟节点表达;子图递归挂载可下钻
下一篇(E100)是收官:Eino + DeepFlux 实战全景复盘------100 篇从 5 分钟 Demo 到企业级平台走过的路。