云计算百科
云计算领域专业知识百科平台

ACP 协议 + devops 可视化:Agent 与编辑器的两条桥(第99篇-E85)

上一篇 讲了 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 的签名:

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):

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 键控的累积器:

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。怎么映射?

// 默认全部 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 实测):

客户端能力shellls/glob/grepread/writeedit
fs + terminal
仅 fs · ·
terminal(不开文件工具) · · ·
terminal(开文件工具) · ·

edit 要读+写双能力——因为它是读-改-写三步,缺一个都做不了。这个门控不是配置洁癖:客户端没声明的能力,工具对模型就不可见,模型不会尝试调用然后失败。

Edit 读-改-写(backend.go:634-690)

ACP 没有原子的"编辑"RPC,Edit 是拼出来的:ReadTextFile 全量读 → 内存替换 → WriteTextFile 全量写。三态校验:

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,结果三态缓存:

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())。解法是找第一个 <分隔符><数字><分隔符> 边界:

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)

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)

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):

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):

    节点(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,就是前端画布消费的全部信息。

    (六)两个模块的共同设计味道

    acpdevops
    定位 向外:接编辑器 向内:给调试器
    依赖方向 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 到企业级平台走过的路。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » ACP 协议 + devops 可视化:Agent 与编辑器的两条桥(第99篇-E85)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!