自托管开源项目 Hermes Agent 的 ACP 适配层:换壳的代价
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
把一个常驻在你机器上的 Agent 塞进别人的编辑器里,难的不是接上协议,而是它原本靠终端交互兜住的每一个决策点,都得在协议里找到一个对应事件;找不到对应物的那几处,就是你后面要踩的坑。 本文的解剖对象是 hermes-agent 这个开源仓库里的 acp_adapter/ 目录,所有结论都对着源码文件说。 这层适配的价值恰恰在于它把代价摊开摆在代码里了,你可以逐个文件读出来。
先消歧。本文说的 Hermes Agent 是 NousResearch 的自托管 Agent 项目 hermes-agent(MIT 许可,LICENSE 署名 Nous Research,仓库地址 https://github.com/NousResearch/hermes-agent ),不是同名的 Hermes 开源模型系列,也不是其它同名商标或库。它仓库里有一个 acp_adapter/ 目录,作用是把自己包成 ACP(Agent Client Protocol)服务端,让支持 ACP 的编辑器把它当后端来调,源码注释里反复点名的客户端是 Zed。
站内已有几篇相邻内容:Agent 协议生态对比 谈的是协议层面的横向格局,Agent 扩展与跨平台三体对比 比的是不同项目各自的扩展模型,pi 嵌入宿主程序 拆的是另一个项目的嵌入路径。那三篇给的是通用方法论和别的项目,本篇不重复它们的结论,只盯 hermes-agent 这一个仓库的 acp_adapter/,看会话、权限、编辑批准、来源溯源这四件事具体落成了什么代码。
一、第一个代价:stdout 不再属于你
ACP 的 stdio 传输把标准输出整条征用给 JSON-RPC 帧了。这对一个原本是 CLI 的项目来说不是小事——任何一行残留的 print 都会污染协议流。
适配层用两处硬约束兜住它。acp_adapter/entry.py 里的 _setup_logging() 清掉根 logger 的所有 handler,只挂一个写 stderr 的 StreamHandler,并把 httpx、httpcore、openai 三个库压到 WARNING。acp_adapter/session.py 里则定义了 _acp_stderr_print(),创建 agent 时直接把 agent._print_fn 换成它,让 Agent 内部任何顺手的人类可读输出都改道 stderr。
同一个文件里还有一处很能说明「适配就是打补丁」的细节:_BenignProbeMethodFilter。有些客户端会周期性发 ping / health / healthcheck 当活性探测,这些方法不在 ACP schema 里,路由层正确地回了 JSON-RPC -32601,但上层任务会把这个异常按 Background task failed 打成完整 traceback,于是每个探测周期都往 stderr 吐一坨噪声。这个 filter 只吞掉「代码是 -32601 且方法名在探测集合里」的那一类记录,协议响应本身一个字都不改。
启动方式有三种写法:python -m acp_adapter.entry、hermes acp、hermes-acp。带 --check 时它只做一次依赖与导入自检然后退出,--setup 会拉起交互式的 provider/模型配置,--setup-browser 装浏览器工具链。真正跑服务的那一行是 acp.run_agent(agent, use_unstable_protocol=True)——use_unstable_protocol 这个开关本身就是这层适配的处境写照:它吃的是协议里还在动的部分。
下面这张表是整个目录的地图,可以当读码顺序用:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 协议入口 | 加载 ~/.hermes/.env、把日志钉到 stderr、起 ACP 服务端、过滤探测噪声 | acp_adapter/entry.py | 配编辑器启动命令、服务起不来 |
| Agent 主体 | initialize / new_session / load_session / resume_session / fork_session / prompt / cancel、斜杠命令、模型与模式切换 | acp_adapter/server.py | 任何行为对不上预期时 |
| 会话管理 | ACP 会话与内部 Agent 实例的绑定、cwd 翻译、写入 ~/.hermes/state.db | acp_adapter/session.py | 重启后历史还在不在、工作目录不对 |
| 命令批准 | 危险命令确认映射成协议权限选项 | acp_adapter/permissions.py | 编辑器弹出终端命令确认框 |
| 编辑批准 | 执行前构造 diff、敏感路径豁免、按策略自动放行 | acp_adapter/edit_approval.py | 改文件前弹 diff,或者该弹没弹 |
| 事件桥 | 工具开始/结束、推理、消息流、todo 转任务面板 | acp_adapter/events.py | 编辑器里的工具卡片与任务列表 |
| 工具展示 | 工具名映射到协议 kind、结果排版与截断 | acp_adapter/tools.py | 卡片标题、图标、输出格式不对 |
| 溯源元数据 | 从压缩链推导 _meta.hermes.sessionProvenance | acp_adapter/provenance.py | 长会话被压缩后对不上账 |
| 认证方法 | 广告 provider 凭据方法与终端安装方法 | acp_adapter/auth.py | 编辑器里第一次配置这个后端 |
二、会话:编辑器手里的 id 和内部那个不是一个
这是整层适配里最容易被低估的一处。SessionState 是个 dataclass,字段包括 session_id、agent、cwd、model、history、cancel_event、is_running、queued_prompts、runtime_lock、current_prompt_text、interrupted_prompt_text。SessionManager 一边把它放内存 dict,一边持久化进共享的 ~/.hermes/state.db,source 字段写 acp。进程重启后,get_session() 找不到内存对象就走 _restore(),从库里把 cwd、provider、base_url、api_mode、模型和整条消息历史读回来,重新造一个 agent。
但编辑器拿到的那个 session_id 只是稳定的对外把手。上下文压缩会在内部把 Agent 的 DB 会话头换掉——prompt() 在调用 agent 前先快照 pre_turn_hermes_id,跑完再取 post_turn_hermes_id,两者不同就说明这一轮发生了压缩驱动的会话分裂。双轨 id 是这层适配必须自己承担的复杂度,协议本身不提供这个概念。
_persist() 里那段长注释值得读第二遍。当 agent 自己就在往同一个 SessionDB 增量写盘、并且用归档方式非破坏性地保留压缩前的轮次时,再调一次整体替换就会把那些归档行删掉——注释直接把它称为静默的数据丢失。所以它加了两道判断:agent 是否自己拥有持久化;库里是否已存在归档消息。只有两者都不成立时才允许整体替换,否则只替换活跃集合。这类「看起来能省一步、其实会删数据」的坑,是常驻型 Agent 特有的,可以对着 Agent 检查点与常驻 一起看。
历史回放也是被协议规矩逼出来的。_replay_session_history() 必须在 session/load 与 session/resume 的响应之前 await 完成,客户端才能在这次请求生命周期内收到完整会话记录;源码注释说曾短暂改成延迟调度,结果按规范实现的客户端全挂。回放时它把用户/助手消息重放成消息块、把 reasoning 字段重放成思考块,还要根据 tool_calls 反向重建工具调用的开始与完成通知——只恢复服务端状态的话,服务端记得上下文,编辑器里却是一条干净的空线程。
跨平台那一层也有实打实的代价。_translate_acp_cwd() 通过 translate_cwd_for_wsl_backend() 把 Windows 客户端发来的盘符路径或 UNC 路径翻成 POSIX 形式,_normalize_cwd_for_compare() 再做一次归一化用于会话过滤,server.py 的 _path_from_file_uri() 还单独处理 file:///C:/... 这类 URI 到 /mnt/<盘符>/... 的转换。工作目录不止要存对,还得通过 register_task_env_overrides() 绑到工具层,否则子进程在创建之前就失败了。
三、权限与编辑批准:终端里的三档确认怎么翻译
acp_adapter/permissions.py 全文不到两百行,但把「语义在协议里没有对应物」这件事暴露得很彻底。
映射表 _OPTION_ID_TO_HERMES 把五个选项 id 翻成内部的批准字符串:allow_once → once,allow_session → session,allow_always → always,deny 与 deny_always 都 → deny。注意 allow_session 这一行的注释:ACP 没有会话级的 kind,所以它借用了最接近的持久化 kind allow_always,把真实语义藏在 option id 里。也就是说编辑器 UI 上那个「本会话允许」按钮,在协议层面看起来跟「永久允许」是同一类——语义差异只有服务端自己认。
还有一处更直白的兼容动作:_permission_option_supports_kind() 用一个探针对象试着构造 reject_always 这个 kind,构造失败就说明当前装的 SDK 不支持,于是干脆不往选项列表里加这一项。适配层要同时面对协议在变、SDK 在变、客户端在变三个方向。
风险侧的默认值是保守的。make_approval_callback() 拿到的超时默认 60 秒,超时或异常一律返回 deny;调度失败返回 deny;响应为空返回 deny;返回的 option id 不在本次广告出去的集合里,打一条 warning 然后 deny。它同时还要跨线程:Agent 跑在线程池里(server.py 里那个 ThreadPoolExecutor 用 max_workers=4、线程名前缀 acp-agent),批准请求得通过 safe_schedule_threadsafe() 投回事件循环。
文件编辑走的是另一条更重的路,在 acp_adapter/edit_approval.py。它的关键设计是执行前就把 diff 算出来:build_edit_proposal() 对 write_file 直接读旧内容配上新内容;对 patch 的 replace 模式,先读原文再调 tools.fuzzy_match 里的 fuzzy_find_and_replace() 真跑一遍替换,匹配数为 0 就抛错;对 patch 模式则从补丁正文里正则抽出所有被 Update / Add / Delete / Move 的文件路径。算出来的 EditProposal 通过 acp.tool_diff_content() 包成 kind 为 edit、status 为 pending 的工具调用发给客户端,用户看到的是真实 diff 而不是一句「要改文件吗」。
代价是它对不上号的地方就只能降级。V4A 补丁一次可能动多个文件,而 ACP 这里只支持单个 diff 载荷,于是它把整段补丁正文直接当 new_text 塞进去,路径栏用逗号拼接——用户看到的是补丁原文,不是渲染好的逐文件对比。
自动放行的边界写得很清楚。server.py 里三个模式 default / accept_edits / dont_ask 映射到策略 ask / workspace_session / session,同时还接受一个 id 为 edit_approval_policy 的配置项做反向映射。should_auto_approve_edit() 的第一条判断就是:策略是 ask、或者路径命中敏感判定,直接返回 False。敏感判定包括路径任意一段是 .git 或 .ssh,以及文件名落在 SENSITIVE_AUTO_APPROVE_NAMES 里(.env、.env.local、.env.production、id_rsa、id_ed25519)。workspace_session 策略下还要求解析后的路径落在临时目录或当前 cwd 之内,越界就不放行;注释特意说明用 tempfile.gettempdir() 而不是硬写 /tmp,因为 macOS 上 /tmp 是符号链接、Windows 上是按用户的 Temp 目录。
绑定方式也值得注意:requester 存在一个名为 ACP_EDIT_APPROVAL_REQUESTER 的 ContextVar 里,只在一次 ACP 运行期间有效,CLI 和其它入口不设它、因此完全绕过这道闸。这句话反过来读就是风险提示——这道 diff 闸只保护走 ACP 的那条路。想进一步收紧,可以参照 最小权限设计 的思路自己加一层。
四、来源溯源:换头之后怎么对账
acp_adapter/provenance.py 只做一件事:从已有的压缩链推导出一份元数据,挂在 ACP 的 _meta.hermes.sessionProvenance 下面。它开头就声明自己不引入任何新的持久化状态,全部按需从 sessions 表的 parent_session_id 和 end_reason 两列算出来。
算出来的字段有 acpSessionId、currentHermesSessionId、rootHermesSessionId、parentHermesSessionId、sessionKind(continuation 或 root)、compressionDepth;如果这一轮发生了换头,再补上 previousHermesSessionId、reason 与 creatorKind。判定规则是:父会话的 end_reason 等于 compression 才算压缩续体;往上走父链时也只有这种父节点计入深度,因为委派/分支子会话共用同一列却不是压缩边界。防御性遍历上限写死为 100。
它解决的是一个很具体的观测问题:没有这份元数据,客户端只能靠解析状态文本、猜 token 数掉了多少、或者自己去读 state.db 来判断刚才是不是压缩了。附带的还有两个标记:compactionSummary 表示整块就是压缩交接摘要、可以整体折叠;containsCompactionSummary 表示这条消息前半是真实保留内容、后半才是摘要,折叠整块会藏掉真东西——为此专门分成两个 key,是个很实在的取舍。
同一节顺带说下事件桥。acp_adapter/events.py 把内部回调翻成协议通知:工具开始只在 tool.started 时发,且用「按工具名排队的 FIFO」来配对并发同名调用的完成事件;todo 工具的结果被 _build_plan_update_from_todo_result() 转成协议原生的 plan 更新,其中 cancelled 状态在协议里没有对应值,它选择映射成 completed 并在文本前面加 [cancelled] 前缀而不是直接丢掉。acp_adapter/tools.py 里的 TOOL_KIND_MAP 则把工具名映射到协议的 kind:read_file 是 read,write_file 和 patch 是 edit,terminal / process / execute_code 是 execute,web_search / web_extract 是 fetch,todo 落到 other。没登记的工具默认 other——换壳之后,编辑器里那个卡片长什么样,取决于这张表有没有覆盖到你在用的工具。
五、边界与代价:它明确不管的事
它不接管权限判断,只接管权限的呈现。 危险命令的识别、编辑是否需要批准的判断,都还在原有的工具层;适配层做的是把询问渲染出去、把答案翻回来。所以你在编辑器里看到的确认框,粒度完全取决于底层怎么切。
超时即拒绝,不排队。 两处批准的默认超时都是 60 秒,到点返回 deny。这个选择对安全有利,但意味着人不在座位上的长任务会在中途被拒死,而不是挂起等你回来。
斜杠命令是本地拦的,而且只认这一批。 _SLASH_COMMANDS 里只有 /help、/model、/tools、/context、/reset、/compress、/steer、/queue、/version。不在表里的以斜杠开头的输入会原样交给模型当普通消息。另外这些命令只在纯文本提示里生效——带了图片或附件,整个提示会直接送给 Agent。
/compress 在 ACP 下是被削过的。 处理函数里显式把 agent._session_db 临时置空再压缩,注释给的理由是 ACP 会话必须保持稳定 id,不能触发内部的会话分裂副作用。同一个功能在这层壳里和在原生入口里,行为并不完全等价。
并发是靠拿锁和 contextvars 撑住的,不是靠隔离。 多个 ACP 会话共享同一个线程池,跨会话的状态泄漏靠 contextvars.copy_context() 包裹执行、靠保存/恢复 HERMES_SESSION_ID 来防。源码注释里提到,批准回调是线程局部的、所以必须在执行线程里设置;交互标记从进程级环境变量改成了 contextvar,避免一个会话的操作把另一个会话推到自动放行的路径上。这些都是修出来的,不是天然安全的。
附件不是无限吞的。 资源内联有一个字节上限常量(512 KiB),超了就截断并留一行说明;图片超限只回一句体积说明,不内联。非 file: 协议的资源 URI 它直接告诉你读不了。
它是常驻的、能开终端、能写盘、能连外部服务。 这不是 ACP 带来的,但换壳会让它变得不那么可见:编辑器里一个看起来温和的对话框背后,是一个跑在你机器上、持有 provider 凭据、按需注册 MCP 服务、拥有工作目录写权限的进程。entry.py 会从 ~/.hermes/.env 加载环境变量,auth.py 会把当前 provider 作为凭据方法广告出去。这些都要按真实权限来评估,别因为它长在编辑器侧栏里就默认它是只读的。涉及模型服务商的部分,各家规则不同且会调整,以官方最新说明为准。
规模也是代价的一部分。 仓库 skills/ 下 14 个分类目录共 70 份 SKILL.md,optional-skills/ 下 21 个分类目录共 111 份,plugins/ 有 18 个顶层插件目录,optional-mcps/ 有 6 个,tests/ 里以 test_ 开头的测试文件有 2499 个。换壳把这么大一套能力接进编辑器,同时也把这套能力的全部行为面接了进来。
六、上手与避坑清单
先跑 --check 再配编辑器。 会踩是因为 ACP 依赖和适配层导入失败时,编辑器那侧只会显示一个含糊的连接错误,你根本不知道是路径、虚拟环境还是依赖的问题。--check 只做导入自检并打印一行结果,把问题定位在编辑器之外。
别让任何东西往 stdout 写。 会踩是因为你自己加的插件、钩子或调试 print 一旦落在 stdout,就会插进 JSON-RPC 流里,表现是协议直接崩、日志里却看不出所以然。写 stderr,或者复用适配层已经准备好的打印函数替换点。
Windows 加 WSL 的组合要显式验证工作目录。 会踩是因为客户端发的是盘符路径而进程跑在 WSL 里,session.py 和 server.py 里那几处翻译函数就是为此存在的;翻不对时症状不是报错,而是文件被写到工作区之外。先用一次只读操作确认它认的 cwd 是哪个。
先把模式定成默认询问,再逐步放宽。 会踩是因为 accept_edits 和 dont_ask 会让编辑请求不再弹 diff,而豁免只覆盖 .git、.ssh 和那五个敏感文件名;不在这份名单里的要紧文件(比如各种私有配置)在放宽模式下是会被直接改掉的。
长任务别指望批准框会等你。 会踩是因为默认 60 秒超时到点就 deny,而 deny 在编辑侧返回的是一条 JSON 错误串,模型可能把它当成普通失败继续往下试别的做法。要么守着,要么按上面的策略把该放宽的路径先放宽。
长会话要看溯源元数据,不要凭感觉。 会踩是因为压缩换头是静默发生的,编辑器里那个 id 一直没变;能告诉你是否发生了分裂、当前是第几层的,是那份溯源字段:一轮之内发生换头时靠 session_info_update 通知带出来,新建、加载、恢复会话的响应里则是挂在 field_meta 上一并返回。想在自己的客户端里做审计,直接读这几个字段,别去猜。
斜杠命令按纯文本发。 会踩是因为一旦你顺手拖了张截图进去,整个提示就不再走本地命令分支,/compress 之类会被当作普通文本交给模型。
并发多个会话时留意共享状态。 会踩是因为线程池是共享的,凡是靠环境变量或全局标记传递的东西都有跨会话串味的风险。源码里已有的几处保存/恢复和 contextvars 包裹是模板,你自己往里加东西时照抄这套写法。
收尾:三个文件的阅读顺序
想快速判断这层适配够不够你用,按这个顺序读:先 acp_adapter/session.py,看清会话 id 双轨和持久化那段注释,这决定你的历史会不会丢;再 acp_adapter/edit_approval.py,看清哪些路径永远会问、哪些策略下不问,这决定你的磁盘安全边界;最后 acp_adapter/permissions.py,看清选项映射和超时默认,这决定人机交接的手感。
至于「换壳到底要付什么代价」,读完这几个文件你会得到一个具体答案:代价不是接协议的工作量,而是每一处语义差都要落成一段有注释的补丁——会话级允许要借持久化 kind 来表达,多文件补丁要退化成一整段原文,取消状态要伪装成已完成,压缩换头要另开一路元数据来说明。这些补丁能不能长期维护,比协议本身接得多完整更值得你先判断。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的 MCP 双向落地 和 开源自托管 Agent 项目 Hermes Agent 的 LSP 集成。