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 的签名:

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 的做法是塞进 _metaconv.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

NewClientToolsMiddlewarebackend.go:143-185)的核心逻辑是一张能力 → 工具启用矩阵 。ACP 协议只暴露三个能力:read_text_filewrite_text_fileterminal。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:节点名、组件类型、反射类型)翻译成画布:

  1. buildGraphNodes :插入虚拟 __start__/__end__ 节点;每个组件节点的输入输出类型经 parseReflectTypeToJsonSchema 转成 JSON Schema(前端显示类型提示用)
  2. buildGraphEdges :一对一的边直接建;一对多(并行)插入虚拟并行节点 from:X------源节点 → from:X → N 个目标,让前端能画出扇出形状
  3. 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。适配层的本分是透明,不是聪明。

小结

  1. ACP 下行AgentEventToSessionUpdate 六类映射;中断走 _meta["eino:interrupted"] 跨进程契约;流式工具调用按 Index 聚合(按 ID 丢块)
  2. ACP 上行:能力 → 工具矩阵,edit 要读写双能力;Edit = 读改写三态校验 + 32MiB 上限;rg 探测三态缓存 + TryLock 非阻塞回退;shell 四步 RPC、Release 用独立后台 ctx
  3. devops :两行 Init 起本机 HTTP 服务;八条路由;CanvasInfo 不是 Mermaid,是前端画布消费的带类型 JSON;并行扇出用 from:X 虚拟节点表达;子图递归挂载可下钻

下一篇(E100)是收官:Eino + DeepFlux 实战全景复盘------100 篇从 5 分钟 Demo 到企业级平台走过的路。

相关推荐
Akiyama_Mio-Kon30 分钟前
DALL·E GPT 明天退役:不是 ChatGPT 不能画图,一份可照做的备份清单
aigc·openai·ai 绘画·提示词·dall·e·hatgpt images·图片备份
梦想的颜色1 小时前
ComfyUI 深度硬核科普:节点式 AI 生成框架完整实战(安装、原理、使用场景、避坑)
人工智能·机器学习·计算机视觉·aigc·comfyui·ai视频·图生视频
七77.2 小时前
SceneAssistant: A Visual Feedback Agent for Open-Vocabulary 3D Scene Generation
3d·agent·世界模型
ShallWeL2 小时前
RAG 文档切分长度与检索召回
agent·知识库·rag·智能体
DeepAgent11 小时前
AI Agent 工程实践(35):我的 AI Engineering OS 最终架构
大数据·人工智能·agent
粥里有勺糖14 小时前
视野修炼-技术周刊第131期 | Bun 与 pnpm Rust 化
前端·github·agent
像云~16 小时前
DeepSeek Harness Cordis理解
agent·harness·crodis
魔术师Grace17 小时前
调用一次大模型,就算 Agent 吗?
aigc·agent·ai编程
桃西西呀17 小时前
GPT-5.6 的 ultra 模式凭什么开 4 个 Agent 并行跑?——多智能体协作,是把"一个聪明人"换成"一个团队"
人工智能·llm·agent