到 2026 年 H2,Coding Agent / Agent Runtime 已经百花齐放,社区也开始关注另一条路线:Harness Adapter(也叫 Meta Harness)。为啥要做,原因有二:

  • 用户对 Runtime 各有所好,不会只选一家;
  • 各家 Runtime 往往针对自家模型做过特调,只有“自家 Runtime + 自家 Harness”才能发挥完整能力。比如 Claude Code + Claude、Codex + GPT、Kimi CLI + Kimi。2026 年初,Cursor 的博客Kimi K3 的 limitation 都提到过类似现象。

当一个平台需要同时托管多种 Agent Runtime——有的用 Python 写,有的用 Rust 写,还有的是第三方 CLI——怎样让上层业务只面对一套协议、一套状态机和一套可观测语义?

社区在 26 年也有做过很多类似的产品和项目,比如 omini-agent(databrick 做的)、multica、raft.build 等等。

本文分享我在我们产品是如何做 Harness Adapter 的设计与实现。原文篇幅较多,这里使用 5.6-Sol & Opus5 一起压缩。


一、Harness Adapter 要解决的工程问题

1.1 每接一个 Runtime,就长一条私有链路

先看没有 Adapter 的情况。

Worker 启动时,要把某个 Runtime 的 Executor 和 Controller 注册进 Registry。Agent 绑定环境后,Worker 订阅 Agent 的增删改事件,把当前状态拼成“期望状态”;Controller 再据此创建 K8s 资源:Namespace、API key Secret、工作区 PVC、Headless Service、PDB、各类凭据 Secret,最后 apply 一个 StatefulSet。

随后,平台还要同步技能、资源和 MCP 配置,必要时调用 Runtime 私有的 reload 接口。真正执行时,Executor POST 私有的 /v1/runs,再打开私有 SSE,把私有事件翻译成平台事件写库。

这条链路能跑,但从入口到出口都是私有的:私有 HTTP、私有 SSE、私有工具返回、私有产物字段。Artifact 是最典型的例子:Runtime 自己发现并上传产物,再把产物 ID 塞进 end_turn / result 的私有参数。Worker 只能解析这个字段。接入第二种 Runtime 时,这段逻辑要再写一份;接第三种,还要再写一份。

Runtime B
Runtime A 私有链路
平台 / Worker
新增一套
新增一套
重写翻译与解析
定制传播
插件异常则断链
正常则可能重复
Controller B
Executor B
另一套协议与解析
Controller A
Executor A
私有 HTTP / SSE
私有 Artifact 字段
Registry
Agent 期望状态
平台事件库
Worker Trace
Gateway Trace
Plugin Trace

追踪也会断链:Worker 建一个 Trace,Runtime 网关再建一个,Runtime 插件又建一个。根 Span 的所有权被拆散,跨进程传播只能依赖 Runtime 的定制代码。插件挂了,链路断;插件正常,根 Span 又可能重复。

因此,“再写一个 Provider”不是答案。需要先划清公共能力、原生能力和平台补齐能力的边界。

1.2 通用协议为什么不够用

最自然的想法是:业界已经有通用的 Agent 客户端协议(下文简称 ACP),让所有 Runtime 都说 ACP,Adapter 只消费协议不就行了?

我们调研过协议本身、stdio / WebSocket 桥、开源 daemon 型接入方案,以及某些 SDK 提供的 Harness 抽象。结论是:ACP 可以作为进程内的数据面语言,但撑不起平台级的完整诉求。

缺口主要有五个:

  1. 没有 conversation history 入参。 Prompt 没有标准的完整历史字段,只能让 Runtime 在本地维护 Session 状态,或者把整段历史塞回 Prompt。前者让实例变成有状态实例,后者只是文本层 workaround。
  2. 没有 Session / Run 级 system prompt。 协议只有实例级配置,单次对话的附加指令只能混进用户消息或自定义 meta。
  3. Remote 模式不成熟。 生态实现大多基于本地 stdio;Worker 要远程调用别处的实例,仍要自己加桥或 sidecar,而这层并不标准。
  4. 没有 tracing 传播语义。 协议没有统一 trace 字段,也没定义跨进程上下文传播方式。
  5. 没有通用插件机制。 安全审计、追踪注入和工具策略都缺少承载位置。

现有桥接方案的共同代价,是为了统一而压扁原生能力。例如:Prompt 只支持纯文本,多模态直接报错;内置审批被改成“全部允许”;原生工具只暴露少数几个;手动上下文压缩不支持;宿主工具甚至要走“生成脚本 → 提示模型用 bash 执行 → 桥再包装为 tool-call”的绕路。它们能用,但不是干净的函数调用路径。

最终的架构选择是:不把 ACP 当作唯一事实源。ACP 只负责 Adapter 与子进程之间的数据面;history、Run 级指令、追踪传播、安全插件和产物能力,由平台侧 Adapter 显式补齐。


二、四个所有权层的切法

架构里最贵的不是代码,而是所有权不清。一旦出现第二套调度器、第二套状态机或第二个协议事实源,后面的偶发 bug 都会变成“到底谁说了算”。

因此,整套架构只设四层,并把每层边界写死:

+-------------------------------------------------------------+
| L1  控制面 + Worker                                          |
|     环境/Agent/不可变发布版本/Session/Run/Message/审批/终态     |
|     选 Provider、持 Session lease、持 Run fence、事件落库       |
+----------------------------+--------------------------------+
                             | Prepare
                             v
+-------------------------------------------------------------+
| L2  Session Sandbox 管理层                                   |
|     按 <kind+租户+工作区+Agent+Session> 管计算实例             |
|     PVC / 工作区投影 / 镜像认证 / 凭据保险箱 / 出网策略 / 就绪校验 |
+----------------------------+--------------------------------+
                             | Sandbox API
                             v
+-------------------------------------------------------------+
| L3  Runtime Adapter(跑在沙箱主容器里,:8765)                  |
|     HTTP/SSE 入口、并发准入、ACP 生命周期、事件归一化            |
|     审批、取消、产物扫描上传、清理                              |
+----------------------------+--------------------------------+
                             | stdio JSON-RPC
                             v
+-------------------------------------------------------------+
| L4  Provider 包(每 Run 一个非 root 子进程)                    |
|     只管自己的认证、模型配置、MCP 投影、原生 trace、进程参数      |
+-------------------------------------------------------------+

边界可以压缩成三句话:

  • L1 拥有平台事实,不碰子进程细节。
  • L2 是计算与存储生命周期的唯一所有者。 Provider 不得另开 StatefulSet 路径。
  • L3 拥有协议与进程细节,不拥有平台终态。

L1 和 L3 分别依赖一个独立发布的共享协议包,请求结构、事件结构和版本号都在其中;两边都不能 import 对方的 internal/**。这条约束防的是隐性耦合:Adapter 一旦偷读 Engine 内部枚举,Engine 升级时就可能直接炸掉。

Provider 包只放差异:Runtime 如何鉴权、模型配置长什么样、MCP 如何投影、原生 trace 从哪里读取、启动参数如何拼接。公共调度、状态和幂等语义一律不复制。判断标准很简单:如果一段逻辑在三个 Provider 包里几乎一样,它就不该属于 Provider。


三、运行时拓扑:从常驻 Pod 到 Session Sandbox

3.1 把 Pod 当沙箱用的旧账

托管 Runtime 最早采用“短进程 + Session 隔离 + 多实例”:一个 Agent 对应一组常驻 Pod,多副本共享同一份工作区存储。

看起来现代,实质上是在把 Pod 当沙箱用,并留下两笔必付的账:

  • 多副本同时挂载同一工作区,存储必须支持 RWX(ReadWriteMany),而 RWX 在许多环境里都是运维负担;
  • 没有对话时 Pod 仍然常驻,持续消耗资源。

新架构改成按需分配:Agent 创建时不创建沙箱;第一次 Run 进入 Prepare 才申请运行环境;Run 结束后也不立刻删除,超过空闲 TTL 再回收计算。

3.2 计算可回收,工作区要保留

拓扑设计最关键的一刀,是把“计算”和“存储”拆成生命周期不同的两类资源。

可以把 Session Sandbox 理解成工位,把 Session PVC 理解成工位下的抽屉。计算可以因空闲、指纹变化或故障被回收重建,但工作区仍留在原 PVC。下次执行时,只需申请新工位,再挂回原来的抽屉。

Session Key = <runtime-kind>:session:<租户>:<工作区>:<agentID>:<sessionID>
                             |
             +---------------+----------------+
             |                                |
   可替换的计算资源                     持久的存储资源
   Sandbox 主容器 / sidecar             Session RWO PVC
   共享 emptyDir / Run 级目录           工作区 / 持久 venv
   ACP 子进程                           跨 Run 保留的文件状态
             |                                |
   空闲 TTL / 指纹变化 / 就绪失败        Stop 保留,Delete 才删
   → 删除并重建,不影响 PVC

复用维度锁定到 Session 后,同一份存储不会被多个副本同时挂载,PVC 用 RWO(ReadWriteOnce)就够了,RWX 的历史包袱随之消失。

Stop 和 Delete 的语义也更清晰:

  • Stop:Agent 不再接收新执行,删除活动和空闲计算实例,但保留 PVC;Resume 后的下一次 Run 重新申请沙箱并挂载原 PVC。
  • Delete:分阶段删除。先删沙箱和非锚点资源,落 releasing 状态;再删 Session PVC;最后删凭据锚点。每一步都必须幂等。

3.3 复用维度带来的三笔账

这个方案不是免费的,至少要正面接受三笔成本。

  1. 复用维度过粗,会砍掉水平扩展。 如果按 user + agent + agent_version 复用 Pod,同一 Agent 只能落到一个 Pod,并发上限会被单 Pod 的 Adapter 能力钉死。后来把复用维度收紧到 Session,就是为了让 Session 之间天然并行。
  2. 托管沙箱的常驻内存更高。 纯工具沙箱只承担镜像、依赖和工具进程;托管沙箱还要常驻 Adapter 和控制面进程。这是统一协议的直接成本。
  3. 冷启动延迟不可忽略。 某些 Runtime 启动时要下载并解析 3.7 MB 的模型元数据。填掉这类坑后,短进程模式每 Run 仍约有 1 秒准备成本;常驻模式首个 Run 约 1 秒,后续可降到 0.1 秒。Rust Runtime 的短进程启动则可能更快。

语言和启动路径的差异,会直接决定“每 Run 一进程”是否可接受。这个决策不能靠架构偏好,必须拿实测数据做。


四、一次 Run 的完整链路

这是全篇最重要的一段。下面保留完整时序,再拆出四个关键语义。

子进程Adapter沙箱平台K8sSandbox 层WorkerDB / 队列网关子进程Adapter沙箱平台K8sSandbox 层WorkerDB / 队列网关请求入队与执行权获取Prepare:建立可信运行环境创建 Run 并消费严格有序的事件流带外命令、唯一终态与资源释放客户端Chat 请求,打开客户端 SSE1落 Session、Run、用户消息并入队2按 Session 有序投递任务3获取 Session lease;pending → running CAS + fence4解析确定版本;物化 RunContext / RuntimeBinding5Executor.Prepare6确保并校验 Session RWO PVC7申请或创建计算实例镜像 + sidecar + 凭据 + 出网策略8返回就绪状态、endpoint、策略状态和 Pod 身份9返回校验后的 endpoint、bearer、能力集和沙箱租约10POST /v1/runsIdempotency-Key = run_id11持久化 provider run id 与 endpoint12启动 → initialize → session/new → session/prompt13回调事件、审批请求和 prompt 结果14严格递增 seq 的 SSE 帧最后一帧 run/completed15Fence 感知的事件落库与客户端投影16可选:审批或取消17使用持久化路由信息派发命令18重新校验 endpoint;POST approval 或 stop19终态 CAS;提交消息、产物和计量有且仅有一个终态20释放沙箱租约;计算实例等待空闲 TTL 回收21释放 Session lease;保留 Session PVC22客户端

4.1 Lease + fence:谁有权写状态

第 04 步和第 16 步是一对。

同一 Session 被映射到同一条有序执行车道。Worker 写任何 Run 状态前,必须先拿到 Session lease;lease 被占用或出现临时错误时,返回可重试、不 ACK,等待队列重投。

拿到 lease 后,Worker 通过 CAS 把 pending 推进到 running,同时写入 fence generation。Lease 可能因网络抖动或 GC 停顿丢失,但旧 owner 仍可能存活并继续写事件。Fence 就是写入权凭证:lease 丢失后,事件 sink 被标记为 stale,执行上下文被取消,旧 owner 的后续写入全部被拒绝。

CAS claim miss 不是错误,它只说明 Run 已被其他 Worker 处理或已经进入终态。此时幂等 ACK,并尝试补齐“已持久化但尚未投影给客户端”的终态即可。

4.2 Prepare:拿到一个可信 endpoint

第 06 到第 10 步不只是“起一个 Pod”,而是一条完整的信任建立链:

  1. 校验身份和活性。 要求已持久化的非零 Session ID 与完整绑定信息;锁住 Agent 和 Session;创建前后两次确认 Agent 仍为 active。
  2. 生成计算定义。 Provider Builder 生成镜像、环境变量、资源 request / limit、RWO PVC、共享卷、sidecar、探针、能力集和 Adapter bearer。
  3. 验证凭据与工作区。 只为匹配仓库的镜像生成 pull Secret;认证不匹配时,在调用沙箱平台前失败。工作区同步采用 staging、完整性校验和原子发布。
  4. 创建或复用沙箱。 出网策略和凭据代理进入创建参数及计算指纹;同一 Session key 只保留一个当前有效实例;Adapter /readyz 与 sidecar /healthz/ready 必须同时通过。
  5. 回读并验证实际状态。 就绪后回读出网策略,确认 DNS + nft 规则完整生效且与请求一致;再校验 endpoint 对应的 Pod、Session、Provider、release 和 8765 端口。任一步无法证明,就删除计算实例并 fail closed。

这里有两个关键设计点。

第一,Worker 直连 Pod IP,不经过沙箱平台转发。 少一跳,也少一个故障域;代价是 Worker 必须自己完成 endpoint 归属校验。

第二,工作区模板和 Session 工作区不是同一个东西。 模板来自控制面绑定和外部资产服务,由 sidecar 下载,在 staging 中校验后原子发布;失败时保留上一份可恢复快照。Session 工作区则位于 PVC,跨 Run 保留。

还有一个容易踩的细节:需要可靠文件锁的状态,例如 SQLite / WAL / mmap,不要放进网络存储上的 Session 工作区,而应写入 Run 级 tmp 目录。

4.3 Create Run 与 SSE:严格序号

第 11 到第 15 步只有三组规则,但每一组都直接影响正确性。

先写路由,再执行。 Provider kind 和 endpoint 先落库;拿到 provider run id 后,再把 run id 与 endpoint 一起持久化。如果持久化失败,必须主动向 Adapter 发 stop,否则会留下孤儿执行。

幂等键就是 run_id。 POST /v1/runsIdempotency-Key 必须等于规范化的 run_id。同一个 ID、同一请求体复用结果;同一个 ID、不同请求体返回 409。

SSE 做三重校验。 版本必须受支持;方法必须在 Adapter 允许生产的白名单中;seq 必须严格等于上一帧加一。版本错误、未知方法、序号跳号或重复、非法 JSON、EOF 前没有 run/completed,都要把当前 Run 判为 Provider 失败并执行有界清理。这里不能“跳过坏帧继续”,因为事件流本身就是状态推导的输入。

公共协议里存在某个方法,不等于 Adapter 有权生产它。例如产物更新由平台合成:Adapter 只上报上传结果,平台再根据终态和版本序号生成统一事件。同一事实不能有两个作者。

4.4 终态唯一性

Adapter 返回成功,只是 Provider 的一份输入。平台终态必须由 Run 状态机 + owner fence + 终态 CAS 唯一产出。

因此,迟到终态、重复终态和旧 owner 终态都会被 fence 拒绝。一个 Run,在平台上有且仅有一个终态。


五、Adapter 内部:每 Run 一个进程

5.1 Runner 的固定顺序

Adapter 内部分为四层:

  • HTTP server:鉴权、幂等、并发准入、SSE,以及审批和停止入口;
  • 通用 ACP Runner:进程隔离、事件归一化和产物处理;
  • Provider:校验、进程配置、认证、MCP 和原生追踪;
  • 启动装配:合并 CLI / env / config,选择 executor。

边界同样明确:HTTP server 不知道子进程细节,Provider 不拥有幂等和终态。

每个 Run 都按固定顺序执行:

预留身份槽位(uid/gid/进程组)
  -> 创建 Run 级 home / workspace / input / output / tmp
  -> 物化附件、记忆、MCP、Provider 配置
  -> 启动非 root 子进程
  -> ACP initialize
  -> ACP session/new
  -> ACP session/prompt
  -> 归一化回调事件,等待 prompt 结果
  -> 静默化整棵进程树(quiesce)
  -> 扫描并上传产物
  -> 发出唯一终态
  -> 清理 Run 目录,释放身份槽位

5.2 隔离靠身份,不靠信任

多租户执行用户代码时,隔离必须落在可验证的身份边界上。

Root supervisor 为并发 Run 分配不同的非 root UID、独立进程组、独立 HOME 和 Run 目录。同一 Session 内需要共享持久文件时,使用独立补充 GID + setgid 工作区,而不是放宽目录权限。

子进程只继承白名单环境变量。平台保留变量——内部前缀、HOME、PATH、代理和模型凭据——不能被用户配置覆盖。否则,用户可能通过环境变量把模型请求导向自己的服务。

取消采用有界升级:先 ACP cancel,关闭 stdin,再 terminate,最后 SIGKILL。Linux 上优先使用 pidfd 绑定已经观测到的进程实例,避免因 PID 复用误杀。

身份槽位只有在三个条件都满足后才能复用:进程树确认终止、产物文件稳定、文件归属校验通过。清理可以重试;永久失败或无法证明干净时,直接 poison 槽位,必要时 poison readiness。宁可降低实例容量,也不能把可能残留的进程和文件交给下一个 Run。

5.3 事件归一化:不猜语义

事件归一化只映射已经观测到的事实:

  • 消息增量映射为 agent message 的 start / delta,完整文本由终态结果携带,不重复发送 completed;
  • 思考增量映射为 reasoning 的 item / delta / completed;
  • 工具调用映射为 tool 的 start / delta / completed,输入输出在出站前统一脱敏、规范化和限长;
  • 计划事件映射到保留命名空间的 todo 快照,不占普通工具预算;
  • 权限请求映射为稳定审批 ID,Adapter 内保留原始 option ID 供回传。

停止原因统一为 completed、failed、cancelled 三种。遇到未知停止原因、尚未闭合的工具或语义模糊的工具更新,一律明确失败,不猜生命周期。

协议中可选的 messageId 目前被有意忽略,因此这套实现不声称支持消息替换或 latest-wins 纠错。能力边界必须写清楚,不能向上层承诺尚未实现的语义。

5.4 单进程,还是每 Run 一进程

数字很残酷:每 Run 启动独立进程,单 Run 内存约 200 MB;复用常驻进程,单 Run 增量约 1.6 MB,相差两个数量级。

最终仍然选择每 Run 一个独立进程,因为它换回四项确定收益:

  1. Session 目录具备进程级隔离;
  2. 提高并发不需要二次开发上游 Runtime;
  3. Run 结束后的数据清理边界明确;
  4. 单个进程崩溃不会拖垮其他 Run。

本质上,这是用内存和约 1 秒启动成本,购买“隔离性 + 不改上游 + 最小崩溃域”。对于托管用户代码的平台,这笔成本应该明牌计算,而不是藏在抽象后面。

不同 Provider 可以有不同并发准入策略:按 cgroup 有效内存推导槽位、固定为 1,或者镜像预设 16。但无论 Adapter 有多少槽位,平台层都必须保证同一 Session 严格串行;槽位只控制不同 Session 之间的并行度。


六、三个横切能力的收口

统一 Adapter 的主要价值,不是少写几行 HTTP,而是把追踪、安全和产物三项横切能力从各个 Runtime 内部收回公共层。

6.1 Tracing:根 Span 归 Adapter,叶子归插件

规则只有两句:

  • Adapter 从可信上层上下文创建唯一语义根 runtime_request -> runtime_run,并负责结束;
  • Runtime 原生插件只贡献受控叶子 Span,例如 LLM、工具、审批、技能、子 Agent 和上下文压缩。

插件不能再创建一组 request / run 根,也不能持有导出凭据。原生模式与 Adapter fallback 模式必须互斥;fallback 只能根据公共事件生成 Runtime 和 Tool Span,不能推测 Runtime 内部语义。

插件 journal / buffer 是“子进程可写输入”。导出前必须校验版本、序号、身份、权限、拓扑和字段上限,非法时整体 fail closed。Span 不记录密钥、完整 Prompt、完整工具参数和文件内容;追踪故障也不能阻塞 Run。

一个更具体的问题是追踪 Header 的传播。平台需要向模型网关携带约 18 个 Header,包括 traceparent、tracestate、baggage,以及租户、工作区、用户、Agent、Session 和消息维度。Header 每个 Run 都不同,但常驻进程中的插件只注册一次。

解决方法是让插件注册一个可修改请求的 LLM 中间件。中间件用 session_id 查询 Runner 内存中的 registry:

Runner
  |
  |-- session/new  --> 返回 session-a
  |
  |-- Registry.Put(session-a, Run A 的 18 个 Header)
  |
  '-- session/prompt(session-a)
              |
              v
      运行时把 session_id 当作 task_id 传给 Agent Loop
              |
              v
      每次调模型前执行 llm_request 中间件
              |
              v
      Registry.Get(session-a) -> 合并进 extra_headers
              |
              v
      Provider Client 发出 HTTP 请求 -> 模型网关拿到 Run A 的完整 Header

映射关系:
  session-a  ->  run-A 的 Header 集合
  session-b  ->  run-B 的 Header 集合
  session-c  ->  run-C 的 Header 集合

Run 结束:defer Registry.Delete(session-a)

这样做有三个收益:插件只注册一次;同一 Run 中的多次 LLM 请求都能拿到正确 Header;Provider 名或 base_url 不匹配时,中间件直接返回,避免内部 Header 泄漏到其他服务商。允许注入的 Header 也严格限制在白名单内。

6.2 安全:加载失败就不 Ready

重构前,安全开关、插件配置和凭据由上层直接写入 Runtime 配置;插件虽然跑在 Runtime 进程里,却没有统一的装载和生命周期入口。允许、阻断和降级结果通过私有链路返回;远端审核异常甚至会静默 fail-open。

重构后,Adapter 把插件包和只读配置投影到 Run 级 HOME;安全开启时,校验或加载失败会让整个 Runtime 无法 Ready。 “安全插件没装上,但服务照常运行”这个危险状态因此被直接消灭。

审核点按职责拆开:

  • pre_llm_call:审核模型输入;
  • pre_tool_call:在执行前阻断;
  • post_tool_call:缓存执行上下文;
  • post_llm_call:只做输出审计。

另有独立插件阻断技能目录写操作。远端审核异常仍可按既定策略重试和 fail-open,但必须通过指标与告警显式暴露。Fail-open 本身是一种策略;静默 fail-open 才是事故。

凭据也必须分域:Adapter bearer、记忆 MCP key、产物代理 key、工具检索 key 和 Provider 凭据互不复用。用户 Agent 的外部 API 凭据通过凭据保险箱和代理投影,真实值不进入普通环境变量、metadata、日志或公共事件。代理转发时重建上游 Header,拒绝透传 Authorization、Cookie、forwarded 和逐跳头。

6.3 Artifact:从 Runtime 内部搬到 Adapter

过去,Runtime 负责产物发现和上传,再把产物 ID 写入私有结果字段。Worker 必须解析私有字段,文件生成、上传和终态提交也被绑在 Runtime 的进程生命周期里。

收口后,流程变成:

输入侧:Adapter 通过内部产物 MCP 把附件下载进 Run 工作区
        -> 路径校验 + 去重 + 配额控制
        -> 附件必须落在 attachments 根目录下

执行侧:子进程只把最终用户文件写进 output 目录,临时文件写 tmp
        其他目录一律不发布

输出侧:ACP 结束 -> 先终止全部写进程 -> 再扫描 output
        只接受普通文件
        拒绝:软链、设备文件、Socket、路径穿越
        配额:最多 64 个文件 / 单文件 64 MiB / 总量 256 MiB
        上传前复查文件状态(防"扫描后被替换")
        -> 批量上传,返回结构化产物 ID
        -> 任一上传失败:Run 不提交成功终态,也不发布部分产物

这里有两条不能放松的顺序约束。

第一,先终止写进程,再扫描。 顺序反过来,扫描和上传看到的可能不是同一份内容,而且问题往往只在高负载下偶发。

第二,任一上传失败,整体不能成功。 成功终态必须同时满足:进程静默、文件稳定、全部上传成功。产物半发布比明确失败更糟。

需要区分“上传失败”和“通知投影延迟”:上传本身失败,Run 不得成功;全部上传成功后,如果终态前的产物通知屏障超时,可以在终态中携带显式“不完整”标记,再由后台复用原消息 ID 幂等补发通知。这样既不伪造成功产物,也不会无限阻塞终态。

收口后的直接收益是:模型不需要主动调用“上传工具”,平台也能稳定返回产物 ID。


七、审批、取消与恢复

审批、取消和恢复都是带外动作。它们不沿正常 Run 数据流前进,因此必须依赖同一份持久化路由与状态事实。

claim + fence
提前取消意图
权限请求
批准
拒绝或超时
取消
唯一终态 CAS
Provider / 协议失败
stop + 有界升级
Pending
Running
Cancelling
AwaitingApproval
Completed
Failed
Cancelled
Resume 只恢复同一 Run 的事件投影,
不创建第二个 Run

7.1 审批:稳定 ID + 同一 endpoint 权威

流程如下:子进程发出权限请求;Adapter 生成 <run-id>:approval:<n> 形式的稳定 ID;Worker 持久化审批和路由;网关向客户端展示公开选项;用户提交决定;Worker 重新验证 endpoint 仍属于当前 Session、release 和 Sandbox,再带 bearer 调用 Adapter;Adapter 对相同决定幂等去重,对冲突决定返回 409,对过期请求直接拒绝,并把公开选项映射回原始 option。

三个细节需要写死:

  • “本次允许”只作用于当前 Run;“本会话允许”由平台持久化成跨 Run grant,下一个 Run 通过授权快照恢复;
  • 审批超时由 Adapter 收敛,超时后停止执行并带明确原因结束,不能让 Run 无限挂起;
  • 审批与 Run 共用同一个 endpoint 权威,不能在审批时重新服务发现一个“可达地址”。

7.2 取消:状态先落地,再发命令

平台必须先把 Run 推进到 cancelling,再向 Adapter 发送 stop。Stop 请求成功不等于 Run 已取消;如果先发命令后改状态,中间任何一次崩溃都会让状态与现实脱节。

还要处理一个竞态:取消可能发生在 provider run 具备路由信息之前。Executor 不能丢弃这个请求,而要记录 cancel intent;Create 完成并拿到路由后,立即执行清理。

Adapter 侧依次尝试 ACP stop、关闭 stdin、terminate、kill。最终由 Provider 终态或平台取消 owner 产出唯一 cancelled 终态。

Drain 也遵循相同思路:先停止准入,再等待活动 Run;超时后强制产生取消终态。Drain、stop、HTTP shutdown 和 telemetry flush 各自有界。

Adapter Run 超时、Worker lease、业务 Run 预算和恢复清扫器是四种不同保证,不能互相替代。没有心跳等活性证据时,清扫器不能只按“stale 了多久”回收长任务,否则正常运行数小时的任务也会被误杀。

7.3 Resume:不创建第二个 Run

恢复依赖持久化事件时间线、cursor 和可选内存流快照,从客户端 LastEventID 之后继续投影。

Resume 只恢复同一个 Run 的客户端视图,不向 Adapter 创建第二个 Run,也不重放已经确认的 cursor。 内存快照只补齐正在生成的消息文本;已完成消息和 Run 状态始终以数据库为权威。


八、协议版本与升级顺序

共享协议包有明确版本,并保留上一兼容版本。新增语义——例如更细粒度的工具调用增量事件——必须配合固定发布顺序:

升级:先升 Engine / Worker  ->  再升 Adapter
      (新 Engine 同时接受 当前版 与 上一版 Adapter)

回滚:先回滚 Adapter  ->  确认不再产生新版本专属事件  ->  再回滚 Engine

版本兼容不能靠“忽略未知字段、忽略未知方法”实现。旧 Engine 不承诺理解新 Adapter 的事件;如果先升级 Adapter,新事件会被旧 Engine 的严格校验拒绝,Run 将批量失败。

既然选择严格校验来保证状态正确,就必须用发布顺序换取兼容窗口。

请求侧也有硬边界:所有 /v1/* 请求都需要 bearer,并使用常量时间比较;健康检查不鉴权;请求体最大 4 MiB;请求头、响应体和超时均有限制;HTTP 重定向直接拒绝。Adapter 端口固定,探针必须同时覆盖 Adapter 和 sidecar 的就绪状态。


九、Fail closed 的底线

底层原则只有一句:无法证明安全或正确时,拒绝继续;只影响观测时,允许有界降级。

场景处理方式
Lease 被占或临时错误不 claim、不 ACK,等待重投,不计入 poison 预算
绑定、版本或配置不完整在任何 Provider I/O 前失败,不创建沙箱
PVC、出网策略或凭据校验失败保留证据或删除当前计算实例,禁止无策略运行
Endpoint 不属于当前 Pod / Session / release拒绝命令,不回退到任意可达地址
SSE 非法或清理无法证明完成Run 判失败并清理;必要时 poison 槽位或 readiness

有界降级只用于不改变业务正确性的路径:Tracing fallback 默认 fail-open,只损失遥测;取消或失败场景下的产物收集有时间上限,超时追加诊断,但不阻塞唯一终态和容量释放。

对外错误合同也要稳定:Reason 是可扩展的机器合同,Message 是有界且不稳定的诊断文本,调用方不能解析 Message 做流程分支。

指标 label 只使用低基数的 provider、stage、result 和 reason。Agent、Session、Run、工具参数和凭据都不能进入 label,否则监控系统会先被高基数拖垮。

运维定位按阶段展开:Agent 生命周期 → 派发与租约 → 绑定物化 → 沙箱与 PVC → 就绪、策略与凭据 → Adapter 准入 → 子进程与模型 → 事件与终态 → 产物与追踪 → 清理。不要用一个笼统的“Runtime status”掩盖真实故障层。

十、总结

到此为止,我们做了一套 acp based 的 Harness Adapter 结构,每次接入一个 Agent Runtime,我们只需要做:

  • Agent Runtime 能力实验 & base 镜像构建
  • Agent Runtime ACP Adapter & 镜像构建
  • 业务平台集成

这个过程, 平台负责 Harness 的抽象定义(Skill,MCP,System Prompt,Resource,Memory,Model Provide, etc),而 ACP Adapter 负责翻译 & 物化 到 Harness 的具体实现(说的再大白话些,比如把 Skill 写到哪个目录,以及在配置文件如何定义)

从而实现,同一套架构和约束,不同的 Runtime Kernel。