Omnigent 源码拆解:一个把 Claude Code、Codex、Pi 统一起来的 Meta-Harness

从统一接口抽象到状态化策略与凭证代理:读完 omnigent 仓库,看 meta-harness 这一层到底在解决什么

Posted by 爱折腾的工程师 on Sunday, August 30, 2026

Omnigent:架在 harness 之上的那一层

Databricks 在 2026 年开源了 Omnigent,配套博文是 Introducing Omnigent: A Meta-Harness to Combine, Control and Share Your Agents。博文不长,主张也很清楚:agent 能力的战场正在上移一层——最好的结果不再来自"单个 harness 里的单个模型",而是来自跨 harness、跨模型、跨人的组合。

这句话在行业里已经被说了很多遍。真正值得关心的是它的工程落地:所谓 meta-harness,代码上到底长什么样?

所以这篇不复述博文,而是把仓库源码翻一遍,回答四个问题:

  1. 统一接口抽象到底是什么,凭什么能同时包住 Claude Code 的终端 TUI 和 OpenAI Agents SDK?
  2. native 与 SDK 两条适配路径的差别在哪,为什么两种都要?
  3. “状态化策略"比 allow X / deny Y 强在哪,代码怎么实现?
  4. “让 agent 永远看不到你的 GitHub token"是怎么做到的?

版本基线:v0.11.0(2026-08-24),README 自称 alpha。

一、为什么要多出这一层

博文给出的动机很实在,两条:

作为使用者:同时开着 4-5 个 agent(coding agent、Gemini 搜索……),在它们和 Docs、Slack 之间反复复制粘贴。

作为构建者:为了跟上最新能力,不停把新 harness、新 SDK、新模型组合进来——但 LLM 能力被包在各自 harness 里,接口互不兼容,组合和替换都很难。

关键在于后半句。问题不是某个 harness 不够强,而是许多问题本质上跨越 harness:组合、治理、协作,这三件事没有任何单个 harness 会替你解决。Omnigent 的答案是加一层,而不是再造一个 agent。

代码里这个定位非常克制——它没有试图重新实现 agent 循环,而是把"跑一个 agent"这件事拆成了五层。

二、五层切面:控制面与数据面分离

五层切面:Client / Server / Host / Runner / Harness

  • Client:终端 CLI、Web SPA、iOS / Android、桌面 App、Python SDK。只说 REST + SSE,不持有状态。
  • Server(控制面)omnigent server。持有会话表、鉴权授权、策略、共享与权限、主机注册表、成本账本。
  • Host(可选)omnigent host。跑在你自己机器上的守护进程,负责拉起和回收 Runner。
  • Runner(数据面):一个会话一个进程,管 harness 子进程、沙箱环境、MCP 连接、终端、文件、子 agent 路由。
  • Harness 子进程:把 Claude Code / Codex / Pi / 自定义 agent 适配成统一 HTTP 接口的那一个进程。

这个分层最实用的推论是最后一句:换 harness 只动最底一层。上面四层——你的会话历史、你的策略、你的权限、你的客户端——全部不动。

需要先破除一个术语歧义:这份代码里 “harness” 有两个含义,混着看会彻底看不懂。一个是被包裹的 agent 后端claude-nativecodexpi),也就是 meta-harness 语境下那个 harness;另一个是每个会话一个的适配子进程。下文用"后端 harness"和"harness 子进程"区分。

三、统一接口:一个 5 行签名的协议

Omnigent 的核心洞察,博文里说得极准:

无论每个 agent harness 内部怎么调 LLM,它对用户的接口是同一个:消息和文件进,文本流和工具调用出。

这不是比喻,是 omnigent/inner/executor.py:603 的字面实现:

class Executor:
    """Abstract interface for LLM backends and agent harnesses."""

    async def run_turn(
        self,
        messages: list[Message],
        tools: list[ToolSpec],
        system_prompt: str,
        config: ExecutorConfig | None = None,
    ) -> AsyncIterator[ExecutorEvent]:
        """
        Yields ExecutorEvent instances (TextChunk, ToolCallRequest,
        TurnComplete, or ExecutorError).
        """

进:消息列表、工具 JSON Schema、system prompt。出:一个异步事件流——TextChunkReasoningChunkToolCallRequestToolCallCompleteTurnCompleteCompactionStarted/CompleteSubAgentStarted/CompletedExecutorError

值得注意的是能力协商本身就是接口的一部分Executor 除了 run_turn 还声明了一组探针:supports_streaming()handles_tools_internally()supports_live_message_queue()supports_tool_boundary_interrupt()interrupt_session()。上层不假设任何 harness 具备某种能力,而是先问。这件事很关键——它意味着"某个 harness 不能中断"不是 bug,而是一个被显式建模的事实。

再往上包一层就是跨进程的边界:omnigent/runtime/harnesses/__init__.py:38 只有一行

_HARNESS_MODULES = harness_modules()   # 内置 + 已安装的 entry-point 插件

名字到模块的查表。每个模块导出一个零参数 create_app() -> FastAPI。也就是说,native 和 SDK 的差别只存在于子进程内部;Runner 和 Server 用同一个 REST/SSE 契约跟两者对话。这个设计是整个 meta-harness 能成立的支点——它把"适配一个 harness"变成了"写一个 FastAPI app”,而不是"改 Runner 的核心循环”。

未知的 harness 名字会回落到 acp,或者提示安装对应的插件包。

四、同一份契约,三条适配路径

同一份契约,三条适配路径

三条路径在 omnigent/harness_capabilities.py 里被声明成 IntegrationMode 枚举,各有权衡:

① Native TUI(NATIVE_TUI——tmux / PTY 包裹现有 CLI。起一个 tmux 会话跑 claude / codex,用 bridge 解析终端输出流,用 forwarder 转成统一事件,权限走 hook 或审批镜像。

保真度最高:订阅登录、斜杠命令、厂商的所有原生行为全部可用。代价是靠解析终端——脆弱,而且慢。

② SDK 进程内(SDK_IN_PROCESS——omnigent/inner/*_executor.py 直接调 Agent SDK,原生工具调用和流式输出都是结构化对象,子 agent 事件可以直接归一化,usage 与成本能精确取到。结构最稳,代价是只覆盖有官方 SDK 的厂商。

③ ACP 子进程(ACP_SUBPROCESS——走 Agent Client Protocol,厂商 CLI over stdio,自带登录,Omnigent 不存凭证。生态最广,代价是能力以对方实现为准。

为什么要同时支持 ① 和 ②? 因为它们是两种不同的赌注。SDK 路径赌的是"厂商会持续提供稳定的编程接口",native 路径赌的是"CLI 的交互界面短期不会大改"。前者给你结构,后者给你保真。对 Claude Code、Codex 这种既有 SDK 又有 CLI 的,Omnigent 两套都留着(claude-sdk vs claude-native),让你按场景选。

三条路共用一张能力表:omnigent/harness_capabilities.py 里每个 harness 声明能否中断、能否流式、恢复方式(冷启动 / 热重连)、模型族、权限通道类型、指令投递方式。仓库里还有个 harness 测试台(tests/harness_bench),用真实交互去验证这些声明,而不是相信它们。

五、组合:一行换 harness,跨厂商交叉评审

组合:一行换 harness,跨厂商交叉评审

两种写法

仓库里并存两套 agent 定义语法。博文和 README 展示的是单文件 YAML:

name: my_agent
prompt: You are a helpful data analyst.

executor:
  harness: claude-sdk

tools:
  word_count:                      # 本地 Python 函数,schema 从签名自动生成
    type: function
    callable: mypackage.mymodule.word_count
  docs:                            # MCP 服务器
    type: mcp
    url: https://example.com/mcp
  researcher:                      # 可委派的子 agent
    type: agent
    prompt: Search for relevant information and summarize it.
    tools:
      word_count: inherit

实际 examples/ 里用的是更完整的agent 镜像目录规范omnigent/spec/AGENTSPEC.md):config.yaml + AGENTS.md + skills/ + tools/{python,typescript,mcp}/ + agents/。子 agent 就是 agents/<dir>/ 下的一个嵌套目录,递归解析。

“一行换 harness"是真的

executor:
  type: omnigent
  config:
    harness: claude-sdk     # → codex-native,只改这一行

prompttoolspoliciesos_envterminals 一行不动。等价写法是 omnigent run agent.yaml --harness codex,连文件都不用改。

Polly 的四个 worker 就是这句话的证明:它们的 prompt 几乎一样,唯一实质差别就是 executor 块——agents/claude_codeclaude-nativeagents/codexcodex-nativeagents/pipiagents/agyantigravity-native

委派:sys_session_send

omnigent/tools/builtins/spawn.py:120 定义的 sys_session_send 是组合的中枢。它的 agent 参数枚举是动态生成的——从父 spec 声明的子 spec 键推导,所以 LLM 只看得到它真能派活的那些子 agent。

一次 response 里发多个 sys_session_send并发执行。参数支持 {input, purpose, model, harness, cost_budget, file_ids, reasoning_effort},其中 purpose 是个有意思的设计:implement / review / explore / search——同一个 worker,按用途路由到不同模型。

子 agent 从自己的 spec 启动,所以它的 harness 天然可以与父不同。每次派发还可以用 args.harness 临时改,但必须落在该子 spec 声明的 allowed_harnesses 白名单里——不是随便换的。

Polly:跨厂商评审是目的本身

仓库自带的三个例子里,examples/polly/ 最能说明 meta-harness 的价值。Polly 是个不写代码的 tech lead:规划 → 把活派给不同厂商的 coding 子 agent(各自在独立 git worktree 里)→ 把每个 diff 交给另一家厂商评审 → 由人合并。

examples/polly/config.yaml:174 把动机写死了:

Cross-vendor verification is the point: review is ALWAYS done by a DIFFERENT vendor than the implementer — e.g. claude_code’s PR is reviewed by any of codex / opencode / cursor / hermes / agy / pi, and vice versa. Give the reviewer ONLY the diff + contract; never point it at the implementer’s worktree.

两个细节很讲究:评审者只拿到 diff 和契约,不看实现者的 transcript 或 worktree——交叉独立性就是全部意义所在;只有实现者开 PR,评审者只报告不编辑,所以评审者的误操作永远到不了交付物。

这个 pattern 在单个 harness 里几乎无法实现。它需要的正是"不同后端可以出现在同一棵 agent 树里"这一层能力。

另外两个例子:examples/debby/ 是双头头脑风暴(Claude + GPT 并行回答同一个问题,/debate 让两个头互相批判几轮再收敛);examples/deep-research/ 是最简单的模板——一个 agent 加一个 tools/mcp/*.yamltools/mcp/ 下的 MCP 服务器会被自动发现,连 tools: 块都不用写。

六、控制:策略引擎

策略引擎:六个检查点,三种判定,三级叠加

策略是无状态纯函数

omnigent/policies/base.py:31Policy 抽象基类只有一个抽象方法:

class Policy(ABC):
    @abstractmethod
    async def evaluate(self, ctx: EvaluationContext, context: dict) -> PolicyResult: ...

它被刻意设计成纯求值器:不跨调用持有可变状态,不碰数据库,不知道会话的存在。所有状态都住在 omnigent/runtime/policies/engine.py

这个拆分是"状态化策略"能成立的原因。策略负责"给定当前事实,该怎么做决定”;引擎负责"记住发生了什么"。于是策略可测试、可复用、可组合,而状态仍然跨轮次连续。

六个检查点、三种判定

omnigent/spec/types.py:1126 定义了六个 Phaserequestllm_requesttool_calltool_resultllm_responseresponse。注意 llm_request / llm_response每次 LLM 往返都触发,不是每个 turn 一次——这让策略能在 agent 循环的每一圈都介入。

判定只有三种(omnigent/spec/types.py:1168):ALLOWASKDENY

ASK 就是"暂停",没有第四个状态。批准转 ALLOW,拒绝或超时转 DENY

PolicyResult 能带回来的东西比想象中多:

action, reason
set_labels        # 写标签状态
data              # 替换负载(ALLOW 也能改写,比如脱敏后的参数)
state_updates     # set / increment / delete / append

data 这一项很关键——策略不只是闸门,它可以在放行时改写传给模型的参数。

策略能看见什么

EvaluationContext 注入的信息里,最关键的是 session_state每个会话可变的 KV。这就是博文说的"跟踪每个会话的动态状态"。加上累计 token 与 total_cost_usd、按 UTC 日聚合的 user_daily_cost、当前 model / harness / labelsactor(谁在跑),以及 tool_result 阶段回带的原始 request_data

有了 session_state + state_updates,“装了新 npm 包之后 git push 需要人工批准"这类规则就不是 allow/deny 表能表达的,而是一个小型状态机:某次工具调用写入一个状态位,后续判定读取它。

三级叠加

server-wide(管理员定底线)→ per-agent(开发者随 agent 发布,写进 YAML)→ per-session(使用者在对话里说一句话就加上)。越靠近会话越优先,最严的规则赢。

内置策略清单

omnigent/policies/builtins/ 下 13 个模块,值得一读的几个:

内置策略 位置 作用
ask_on_os_tools safety.py:225 shell / 文件写入前先问
max_tool_calls_per_session safety.py:93 限制单次会话的工具调用次数
detect_loop safety.py:145 检测循环行为
enforce_sandbox safety.py:466 强制沙箱
deny_pii_in_llm_request safety.py:578 PII 直接拒
cost_budget cost.py:416 硬性花费上限 + 软性阈值提醒
user_daily_cost_budget cost.py:610 按人按天聚合的花费上限
subagent_cost_budget cost.py:770 子树成本封顶
github_policy github.py:942 按 repo / branch 限制 GitHub 操作
block_working_dir_changes working_dir.py:252 只能改自己创建的目录
cel_policy cel.py:81 用 CEL 表达式写策略
detect_task_switch / detect_thrashing context.py:122 / :347 上下文层面的异常检测

context.py 那两个尤其能体现"meta-harness 层独有的能力”:检测 agent 是不是在反复横跳、是不是已经偏离了原始任务。这些判断需要看到跨轮次的完整轨迹,单个 harness 里做不出来。

成本这一块值得单独说:Omnigent 要在 Claude Code、Codex、Pi 这些异构 harness 上都算出每会话 LLM 花费,才能做 cost_budget。这件事本身就是统一接口带来的红利——成本采集在 meta-harness 层做一次,而不是每个 harness 各做一遍。

七、控制:沙箱与凭证代理

凭证代理:让真 secret 从不进入沙箱

博文里那句"不要让 agent 看到你的 GitHub security token,改成只在出口代理里对批准的请求注入",在仓库里是一份完整的设计文档:designs/SANDBOX_CREDENTIAL_PROXY.md,开头标着 IMPLEMENTED

问题

沙箱里的工具经常需要向外认证:gh apigit clone https://github.com/...、带 Bearer token 的 SaaS API。朴素做法是把真 token 注入沙箱环境(或写进沙箱能读的配置文件)——但那基本抵消了沙箱的意义:任何 agent 跑起来的代码,包括被网页里的 prompt injection 骗着跑的代码,都能从 os.environ 里把 token 读走,再往一个恰好在出网白名单里的攻击者主机传出去。

解法

L7 egress 代理已经是所有出网流量的强制中间人。把它扩展成凭证注入点,默认模型是 swap-on-access(访问时替换)

  1. 父进程解析真 secret(父进程不在沙箱里)。真值只留在父进程和代理的内存重写表里。
  2. 沙箱里的工具直接发请求,不带 Authorizationgit clonecurlpythonnode——任何 HTTP 客户端都行,零 in-sandbox 接线。沙箱里没有任何可泄漏的东西。
  3. 代理识别绑定主机,在出网时注入
  4. 不覆盖:客户端自己设的真 Authorization 头原样透传,代理从不覆盖工具故意发的凭证。

少数客户端不看到本地凭证就不发请求——最典型的是 gh,它在碰网络之前就用 “authentication required” 短路了。对这些,可选开启占位符注入:父进程铸造一个随机、一次性的 oa_cred_ 前缀占位符,只把占位符注入 GH_TOKENgh 以为自己已登录,发出携带占位符的请求,代理再换真值。

泄漏防线在占位符路径上:一个占位符被拿到它没绑定的主机上使用(也就是外传尝试)会收到 HTTP 403;任何未知的 oa_cred_* 形状的值同样被拒。所以即便工具把自己环境变量里的占位符读出来重放到攻击者主机,代理也不会附上真凭证。

配置长这样:

os_env:
  sandbox:
    type: linux_bwrap
    egress_rules:
      - "* github.com/**"
      - "* api.github.com/**"
    credential_proxy:
      - type: gh_basic
        source: {command: gh auth token}
      - type: git_https
        target: github.com/databricks-eng/agent-framework.git
        source: {env: OA_TEST_GITHUB_PAT}
      - type: https_bearer
        target: mycorp.atlassian.net/rest/**
        source: {env: JIRA_PAT}

几个约束很硬:这个块要求 egress_rules 存在,且 backend 必须是 linux_bwrapdarwin_seatbelt——只有这两个能保证 MITM 代理是唯一出网路径(工具没法绕过它开裸 socket)。真 secret 永不进 argv、永不落盘到沙箱、不参与 SandboxPolicy 序列化(避免泄进日志和转储)。

沙箱后端本身是三选一:Linux bwrap、macOS seatbelt、Windows Job Object(进程树 containment,但不隔离文件系统和网络——Windows 是降级模式)。

八、协作:一个会话,多端同步,四级权限

协作:一个会话,多端同步,四级权限

权限是数字等级,不是枚举

omnigent/server/auth.py:110

LEVEL_READ = 1
LEVEL_EDIT = 2
LEVEL_MANAGE = 3
LEVEL_OWNER = 4

用数字而不是枚举,好处是"至少要有多少级"这类判断变成整数比较。各级别能做什么:

  • READ(1):订阅 SSE 流、读消息与文件、只读终端。
  • EDIT(2):给 agent 发新指令、评论文件、响应审批。
  • MANAGE(3):授予与回收他人权限。
  • OWNER(4):删除会话、拉起主机、写 PTY。创建时给定,不可转让

有两个保留标记:"local"(单用户)和 "__public__"(有链接即可读)。公共只读分享还有独立的总开关,以及一个贴心的保护——当工作目录是 $HOME/ 时直接禁止分享。

扇出靠 SSE

GET /v1/sessions/{id}/stream 是协作的技术核心,只需要 LEVEL_READ。连上时先发一份快照,结束时发 [DONE],并在连接期间注册 presence(按会话树根记录在线者)。

终端交互式 attach 走 WebSocket,但写入需要 owner,只读只需要读权限。

六种客户端(终端 CLI、Web、iOS、Android、桌面 App、编辑插件)加 Python SDK,都消费同一条流——这就是"在终端开始,在浏览器继续,在手机上接起来"的实现方式:不是同步聊天记录,是同一条会话流扇出给多个客户端。

从同一会话派生的四种动作

动作 效果
Share link 发一个链接,对方实时旁观,可在文件上留评论
/comments/send 评论直接变成给 agent 的指令,不必抢占会话
omnigent attach <id> 共同驾驶:对方的消息在你的机器上执行
omnigent run --fork <id> 克隆到自己的机器,从分叉点独立继续

共同驾驶(co-drive)是最值得注意的那个:它是纯客户端行为,不会重新拉起 server / runner / harness,而是派发到主机已经绑定的那个 runner——对方的键盘直接落在你的机器上。适合结对,也适合中途把键盘交给领域专家。

鉴权侧是三选一:accounts(内置用户名密码 + 管理员生成的一次性邀请链接,不需要邮件服务器)、oidc(Google / GitHub / Okta / Microsoft,带域名白名单)、header(仅用于前置代理之后)。一个环境变量 OMNIGENT_AUTH_ENABLED=1 开关。

九、什么时候该上,什么时候别上

读完代码之后,我的判断是这样。

该上的信号:

  • 你已经在同时用 2 个以上 harness,并且在它们之间手动搬运上下文。这是最直接的痛点,meta-harness 的价值随 harness 数量超线性增长。
  • 你需要跨 harness 的多 agent 编排。Polly 那个"不同厂商交叉评审"的 pattern 在单 harness 里做不出来,或者要做出来得自己写一遍会话管理。
  • 你需要组织级的治理:成本上限、工具白名单、审计。这类需求在 meta-harness 层做一遍,比在每个 harness 各配一遍(并且随版本漂移)可靠得多。
  • 协作是刚需:让同事旁观、评论、共同驾驶一个正在跑的 agent 会话。

别上的信号:

  • 你只用一个 harness,且没有治理需求。那这一层就是纯粹的额外复杂度和额外故障点。
  • 你要求极致稳定的终端体验。native 路径靠解析 tmux 输出,这个脆弱性是结构性的——仓库自己也用 tests/harness_bench 持续验证能力矩阵,说明这层确实会漂。
  • 你在 Windows 上需要真正的沙箱隔离。Job Object 只做进程树 containment,不隔离文件系统和网络。

当前的边界:README 自称 alpha,v0.11.0 的 changelog 里大量条目仍是 bug fix 与 UI 打磨。agent 镜像目录规范明确标注 “Tool inheritance is not supported in v1”。这些不是劝退,但意味着你今天接入的话,要接受接口还会动。

小结

Omnigent 让我觉得有意思的地方,不是它支持了多少个 harness,而是它非常清楚自己不做什么

  • 不重新实现 agent 循环——只定义 Executor 协议(5 行签名)让各家接入。
  • 不要求 harness 具备某种能力——把能力做成可查询的声明表,再用测试台去验证。
  • 不把策略写成配置语言——策略是无状态纯函数,状态交给引擎。
  • 不把凭证塞进沙箱——改成在唯一出网路径上做替换。

博文最后那句话,代码是对得上的:

模型和 harness 会一直变,你工作的那一层不该跟着变。

相关链接

  • 仓库:https://github.com/omnigent-ai/omnigent
  • 博文:https://www.databricks.com/blog/introducing-omnigent-meta-harness-combine-control-and-share-your-agents
  • 文档:https://omnigent.ai/

「真诚赞赏,手留余香」

爱折腾的工程师

真诚赞赏,手留余香

使用微信扫描二维码完成支付