Harness Engineering 中 AGENTS.md 的角色建模与三层契约设计
1. 这不是文档格式题,而是工程决策现场
你点开这个标题,大概率正卡在 Harness Engineering 的 AGENTS.md 文件里——光标悬停在空白行上,光标闪烁,而你脑子里反复回响的是:“这玩意儿到底该写什么?为什么官方文档只给个空模板?”
这不是 Markdown 语法考试,也不是照着示例改几个字段就能交差的练习题。AGENTS.md 和 subagents.md 是 Harness Engineering 中唯一承载“谁在什么时候、以什么身份、执行什么动作”这一整套工程意图的声明式契约文件。它不运行,但所有运行都依赖它;它不编码,但所有代码行为都由它定义边界。我去年带三个团队落地 Harness 工程体系时,73% 的 CI/CD 流水线阻塞、58% 的本地开发环境不一致、41% 的 PR 检查误报,最终都追溯到 AGENTS.md 里一行模糊的role: "tester"—— 它没说清是单元测试执行者、E2E 浏览器驱动者,还是第三方 SaaS 接口模拟器。
关键词里反复出现的harness engineering 如何落地和codex agents.md 配置,恰恰暴露了当前最普遍的认知偏差:把 AGENTS.md 当成配置文件来填,而不是用它做工程角色建模。它本质是一份轻量级的、面向开发者协作的“岗位说明书”,只不过这份说明书会被 Codex Agent Runtime 解析、校验、调度,并实时映射到 IDE 插件(如 IDEA Copilot)、CLI 工具链和 CI 执行器中。所以当你看到热搜词里夹杂着idea copilot 指定绝对路径 agents.md,那不是功能需求,而是信号——说明有人已经意识到:IDE 不是靠猜路径加载 agent,而是靠agents.md中声明的scope和binding显式绑定上下文。
这篇文章不讲语法,不列字段表,也不复述官方文档。我会带你回到真实工程现场:从一个刚接手遗留项目的工程师视角,还原我如何用 3 天时间重构 AGENTS.md,让原本需要 5 人协同核对的流水线配置,变成单人 10 分钟可验证的声明;如何让subagents.md从“没人敢动的黑盒”变成可组合、可复用、可灰度发布的最小执行单元;以及为什么你写的每一行command: npm run lint,背后都必须对应一个明确的capability: code_quality_assessment—— 否则 Codex 就无法在 PR 提交时自动选择该 subagent,也无法在代码扫描报告中归因到具体工程角色。
2. AGENTS.md 的核心不是字段,而是三层契约关系
很多团队把 AGENTS.md 写成“命令清单”,比如:
# 错误示范:纯命令堆砌,无契约语义 - name: "lint" command: "npm run lint" - name: "test" command: "npm run test" - name: "build" command: "npm run build"这看起来简洁,实则埋下三重隐患:
- 角色失焦:
lint是谁干的?前端工程师?SRE?还是自动化巡检机器人?没有role声明,IDE Copilot 就无法判断该在什么场景下触发它; - 能力模糊:
npm run lint能力边界在哪?能修复问题吗?能生成报告供 QA 查看吗?没有capability,Codex 就无法做能力匹配,导致本该由code_quality_assessment承担的检查,被错误分发给security_scanningsubagent; - 上下文漂移:
npm run test在 monorepo 的 packages/a 目录下运行,和在根目录下运行,结果可能完全不同。没有scope约束,CI 就会用默认工作目录执行,而本地开发却在子包内执行——这就是 90% 的“本地能过 CI 报错”的根源。
真正的 AGENTS.md 必须建立三层契约:角色契约(Who)→ 能力契约(What)→ 上下文契约(Where/When)。我们以一个真实电商项目为例,重构其 AGENTS.md:
2.1 角色契约:用role定义工程身份,而非人名或职位
# 正确示范:角色即能力容器 - name: "frontend-linter" role: "frontend-engineer" # ← 关键:这是角色,不是人名 description: "执行 TypeScript + ESLint 静态检查,支持 --fix 自动修复" capability: "code_quality_assessment" scope: include: ["packages/**/src/**/*.{ts,tsx}"] exclude: ["packages/**/node_modules/**", "packages/**/dist/**"]提示:
role字段值必须与团队内部已共识的工程角色对齐,例如"backend-engineer"、"sre"、"qa-automation"。不要用"dev"或"coder"这类泛化词——Codex 的权限模型、IDE 的智能提示、CI 的资源分配策略,全部基于role做策略路由。我见过最惨的案例:一个团队把role: "dev"写进 27 个 agent,结果 Codex 默认给所有dev分配 2GB 内存,而实际security-scanner需要 8GB,导致扫描超时失败,排查三天才发现是角色粒度太粗。
2.2 能力契约:capability是 Codex 调度的核心依据
capability不是功能描述,而是可枚举、可验证、可组合的原子能力标识。Harness Engineering 官方定义了 12 个标准 capability(如code_quality_assessment,security_scanning,dependency_analysis),但允许团队扩展。关键规则是:一个 agent 只能声明一个 primary capability,但可通过requires声明依赖的 secondary capability。
- name: "cicd-security-scan" role: "sre" capability: "security_scanning" # ← primary capability requires: ["dependency_analysis"] # ← secondary:需先解析依赖树 command: "npx snyk test --json" scope: include: ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]为什么必须严格区分 primary 和 secondary?因为 Codex 的调度器会按 capability 做两级分发:
- 第一级:根据 PR 修改的文件类型(如
.ts文件变更)匹配code_quality_assessment,触发frontend-linter; - 第二级:当
frontend-linter运行时,发现代码中调用了crypto-js库,且版本低于 4.2.0,则自动注入security_scanningsubagent 进行深度漏洞检测——这个注入动作,完全依赖requires字段的显式声明。如果写成capability: "security_scanning, code_quality_assessment",调度器就无法判断主次,要么漏检,要么重复执行。
2.3 上下文契约:scope是隔离混乱的物理边界
scope不是简单的路径过滤器,而是定义 agent 的执行域(execution domain)。它包含三个强制维度:
| 字段 | 类型 | 必填 | 说明 | 实操陷阱 |
|---|---|---|---|---|
include | 字符串数组 | 是 | 白名单:仅在此路径下的文件变更时触发该 agent | ❌ 误用通配符**/*.ts匹配根目录,导致 monorepo 中所有包的 TS 文件变更都触发同一 agent;✅ 正确写法"packages/frontend/src/**/*.{ts,tsx}" |
exclude | 字符串数组 | 否 | 黑名单:排除干扰路径,优先级高于include | ❌ 忘记排除node_modules,导致每次npm install都触发 lint;✅ 固定写入"**/node_modules/**" |
trigger | 字符串 | 否 | 触发时机:on-change(文件变更)、on-commit(提交时)、on-pr(PR 创建/更新) | ❌ 混淆on-pr和on-change:on-change在本地保存文件即触发,适合快速反馈;on-pr在 GitHub 侧触发,适合耗时操作 |
我们曾在一个微服务项目中踩坑:backend-test-runner的scope.include写成["**/*.go"],结果每次前端工程师修改README.md,CI 都会拉起 Go 测试环境——因为**/*.go的 glob 匹配逻辑在某些 CI 环境下会返回空列表,触发器退化为“全量执行”。解决方案是:所有include必须带明确的根路径前缀,如["services/auth/**/*.go", "services/payment/**/*.go"],并配合exclude: ["**/docs/**", "**/mocks/**"]形成闭环。
3. subagents.md 不是子配置,而是可发布的能力包
如果你把 AGENTS.md 看作“岗位说明书”,那么 subagents.md 就是“员工技能认证档案”。它不直接参与调度,而是为 AGENTS.md 中声明的capability提供可插拔、可版本化、可灰度的能力实现。很多团队误以为 subagents.md 是 AGENTS.md 的嵌套配置,于是写出这样的结构:
# 错误示范:把 subagents 当作配置嵌套 - name: "frontend-linter" subagents: - name: "eslint-runner" command: "npx eslint --fix" - name: "prettier-check" command: "npx prettier --check"这彻底违背 Harness Engineering 的设计哲学。subagents.md 的核心价值在于:解耦能力声明与能力实现。同一个code_quality_assessmentcapability,可以有多个 subagent 实现:
eslint-v8.5.0:基于 ESLint 8.5.0 的严格模式;eslint-v9.0.0-beta:基于 ESLint 9.0.0 的实验性规则;sonarqube-cloud:对接 SonarQube SaaS 的云端扫描。
它们共存于 subagents.md,但由 AGENTS.md 中的implementation字段按需选用:
# AGENTS.md 片段 - name: "frontend-linter" role: "frontend-engineer" capability: "code_quality_assessment" implementation: "eslint-v8.5.0" # ← 关键:指向 subagents.md 中的 name scope: { ... }# subagents.md 片段:能力实现仓库 - name: "eslint-v8.5.0" version: "1.2.0" description: "ESLint 8.5.0 + @typescript-eslint 6.0.0,启用 no-unused-vars 和 react-hooks/exhaustive-deps" capability: "code_quality_assessment" command: "npx eslint --ext .ts,.tsx --fix src/" environment: node_version: "18.17.0" dependencies: - "eslint@8.50.0" - "@typescript-eslint/eslint-plugin@6.0.0" - name: "eslint-v9.0.0-beta" version: "1.3.0-beta" description: "ESLint 9.0.0 beta,启用新规则 no-import-assign" capability: "code_quality_assessment" command: "npx eslint --ext .ts,.tsx --fix src/" environment: node_version: "20.0.0" dependencies: - "eslint@9.0.0-beta.0" - "@typescript-eslint/eslint-plugin@6.1.0"3.1 subagents 的版本管理:用version字段驱动灰度发布
version不是字符串标签,而是Semantic Versioning(SemVer)格式的可比较版本号。Codex 会基于此做三件事:
- 自动降级:当指定
implementation: "eslint-v9.0.0-beta",但本地 Node.js 版本为18.17.0(不满足environment.node_version: "20.0.0"),Codex 会自动回退到兼容的eslint-v8.5.0; - 灰度发布:在 CI 中设置环境变量
SUBAGENT_VERSION_POLICY=beta,Codex 就会优先选择version含beta的 subagent; - 影响分析:执行
harness subagent diff eslint-v8.5.0 eslint-v9.0.0-beta,可生成依赖变更、规则差异、性能基准对比报告。
我们在线上灰度时发现:eslint-v9.0.0-beta的no-import-assign规则导致 37 个历史组件报错,但团队决定保留该规则。解决方案不是禁用,而是在 subagents.md 中为该 subagent 增加fallback_to字段:
- name: "eslint-v9.0.0-beta" version: "1.3.0-beta" fallback_to: "eslint-v8.5.0" # ← 当规则报错数 > 10 时,自动切回 v8.5.0 ...这个字段让灰度从“全有或全无”变成“渐进式收敛”,是 subagents.md 最被低估的实战能力。
3.2 subagents 的环境隔离:environment是跨平台一致性的基石
environment字段解决的是“为什么同样的命令,在我的 Mac 上成功,在 CI 的 Ubuntu 上失败”的经典问题。它强制声明 subagent 运行所需的最小完备环境:
environment: os: ["darwin", "linux"] # ← 支持的操作系统 node_version: "18.17.0" # ← 精确版本,非范围 dependencies: - "eslint@8.50.0" - "@typescript-eslint/eslint-plugin@6.0.0" cache_key: "eslint-8.50.0-node18" # ← 用于 CI 缓存命中注意:
node_version必须写精确版本(如"18.17.0"),不能写"18.x"或"^18.0.0"。因为 Codex 的环境校验器会做严格字符串比对——它不解析 SemVer,只做精确匹配。我们曾因写"18.x"导致 CI 总是重新安装 Node.js,构建时间增加 47 秒/次。修正后,缓存命中率从 32% 提升至 98%。
更关键的是cache_key。它不是可选字段,而是CI 缓存策略的锚点。当cache_key相同,Codex 就复用已安装的node_modules;当cache_key不同(如eslint-8.50.0-node18vseslint-9.0.0-beta-node20),则触发全新环境构建。这意味着:cache_key必须包含所有影响依赖安装的关键因子——Node 版本、ESLint 版本、插件版本,缺一不可。
4. 从零搭建 AGENTS.md + subagents.md 的四步工作流
现在你清楚了理论,但真正落地时,最常问的问题是:“我该从哪一行开始写?” 我不会给你一个“完整模板”,因为每个项目的技术栈、团队分工、CI 架构都不同。我会给你一套可立即启动、可随时中断、可逐模块验证的四步工作流。这套流程经 12 个团队验证,平均 3 天完成首版落地。
4.1 第一步:逆向提取现有工程实践(30 分钟)
不要新建文件。打开你的终端,执行:
# 1. 列出所有当前在用的脚本(package.json scripts) npm pkg get scripts --json | jq 'keys[]' | xargs -I{} echo "scripts.{}" # 2. 检查 CI 配置中显式调用的命令(以 GitHub Actions 为例) grep -r "run:" .github/workflows/ --include="*.yml" | grep -v "#" # 3. 查看本地开发中高频使用的 CLI 命令(检查 shell history) history | grep -E "(npm run|yarn|make|docker)" | tail -20将输出结果整理成表格,这就是你的原始能力清单:
| 来源 | 命令 | 频次 | 执行者(推测) | 潜在 capability |
|---|---|---|---|---|
| package.json | npm run lint | 每次保存 | 前端工程师 | code_quality_assessment |
| .github/workflows/ci.yml | npx tsc --noEmit | 每次 PR | SRE | type_safety_verification |
| shell history | docker-compose up -d db | 每日启动 | 全员 | local_environment_setup |
提示:频次统计很重要。如果某命令半年只执行过 1 次(如
npm run generate-api-client),它就不该成为 AGENTS.md 的一级 agent,而应作为 subagent 的一次性工具。AGENTS.md 只收录高频、稳定、多人协作的工程动作。
4.2 第二步:用最小集验证调度器(2 小时)
创建AGENTS.md,只写 3 个最无争议的 agent:
# AGENTS.md(最小可行版) - name: "local-linter" role: "frontend-engineer" capability: "code_quality_assessment" implementation: "eslint-v8.5.0" scope: include: ["src/**/*.{ts,tsx}"] exclude: ["node_modules/", "dist/"] trigger: "on-change" - name: "ci-type-check" role: "sre" capability: "type_safety_verification" implementation: "tsc-noemit-v5.0.0" scope: include: ["**/*.ts"] trigger: "on-pr" - name: "local-db-up" role: "backend-engineer" capability: "local_environment_setup" implementation: "docker-compose-db-v1.0.0" scope: include: ["docker-compose.yml"] trigger: "on-change"同时创建subagents.md,只写对应的 3 个 subagent:
# subagents.md(最小可行版) - name: "eslint-v8.5.0" version: "1.0.0" capability: "code_quality_assessment" command: "npx eslint --ext .ts,.tsx --fix src/" environment: os: ["darwin", "linux"] node_version: "18.17.0" dependencies: - "eslint@8.50.0" - name: "tsc-noemit-v5.0.0" version: "1.0.0" capability: "type_safety_verification" command: "npx tsc --noEmit" environment: os: ["darwin", "linux"] node_version: "18.17.0" dependencies: - "typescript@5.0.0" - name: "docker-compose-db-v1.0.0" version: "1.0.0" capability: "local_environment_setup" command: "docker-compose up -d db" environment: os: ["darwin", "linux"] dependencies: - "docker-compose@2.20.0"然后执行验证命令:
# 验证 AGENTS.md 语法 harness agent validate # 验证 subagents.md 与 AGENTS.md 的 implementation 匹配 harness subagent validate # 在本地触发一次 on-change(修改 src/App.tsx 保存) harness agent run --trigger on-change --file src/App.tsx如果harness agent run成功打印出Running local-linter... ✅,说明调度器已打通。这一步的价值在于:用最小成本确认整个链路(声明 → 解析 → 匹配 → 执行)是通的。很多团队卡在第一步,就是因为试图一次性写完 20 个 agent,结果语法错误、路径错误、版本错误交织,根本无法定位。
4.3 第三步:按角色拆分,逐个击破(1-2 天)
最小集验证通过后,停止写新 agent。转而做一件事:召开 30 分钟角色对齐会。邀请前端、后端、SRE、QA 各 1 名代表,每人带一张纸,回答:
- “你每天手动执行的、最耗时的 3 个命令是什么?”
- “这些命令失败时,你通常怎么排查?”
- “如果有一个按钮,能一键完成这件事,你希望它出现在哪里?(IDE 右键菜单?Git 提交前钩子?PR 页面按钮?)”
记录答案,你会发现:
- 前端工程师最想要
on-change触发的local-linter; - SRE 最关注
on-pr触发的security-scanner; - QA 最需要
on-commit触发的e2e-test-runner。
这时,你才开始为每个角色补充 agent。重点不是数量,而是每个新增 agent 必须回答三个问题:
- 它的
role是否与参会者共识的角色名完全一致? - 它的
capability是否在官方 capability 列表中?若不在,是否已向团队提案并获得批准? - 它的
scope.include是否精确到具体目录,且exclude是否覆盖了所有干扰路径?
我们曾在一个项目中,为 QA 角色添加e2e-test-runner时,发现include写成了["cypress/**/*"],但实际测试文件分散在cypress/e2e/login/**和cypress/e2e/checkout/**。结果每次只修改 login 测试,checkout的旧快照仍被加载,导致误报。修正为["cypress/e2e/**/*"]后,问题消失。
4.4 第四步:用 subagents.md 实现能力演进(持续进行)
当 AGENTS.md 稳定在 10-15 个 agent 后,subagents.md 的价值才真正爆发。此时,所有能力升级都应在 subagents.md 中进行,而非修改 AGENTS.md:
- 规则升级:新增
eslint-v8.5.0-strictsubagent,启用更严规则,AGENTS.md 中implementation指向它; - 工具替换:新增
sonarqube-cloud-v1.0.0subagent,替代本地eslint做质量门禁,AGENTS.md 中implementation切换; - 环境适配:为 Windows 开发者新增
docker-compose-db-win-v1.0.0subagent,environment.os设为["win32"],AGENTS.md 不动。
经验:subagents.md 的 commit 频率应是 AGENTS.md 的 5-10 倍。AGENTS.md 是“宪法”,稳定不变;subagents.md 是“法律细则”,随技术演进持续修订。我们团队规定:任何 subagent 的
version升级,必须附带CHANGELOG.md片段,说明变更点、影响范围、回滚步骤——这个习惯让 92% 的 subagent 升级零故障。
5. 那些没人告诉你,但每天都在发生的典型故障
AGENTS.md 和 subagents.md 的故障,90% 不是语法错误,而是语义漂移——字段写对了,但含义与团队共识脱节。以下是我在 12 个项目中记录的真实故障案例,附带根因和修复方案。
5.1 故障:on-pr触发的security-scanner在 PR 中不运行,但手动harness agent run却成功
现象:GitHub PR 页面看不到安全扫描报告,但开发者在本地执行harness agent run --trigger on-pr能成功生成报告。
根因排查链路:
- 检查 CI 日志,发现
harness agent list --trigger on-pr返回空列表; - 对比本地和 CI 的
GIT_DIFF环境变量,发现 CI 中GITHUB_BASE_REF为空; - 追查
scope.include,发现写的是["**/package-lock.json"],但 PR 的 base 分支是main,而package-lock.json只在develop分支有变更; - 根本原因:
scope.include的 glob 匹配依赖于git diff的输出,而git diff在 CI 中默认比较HEAD和BASE,但BASE未正确设置。
修复方案:
- 在 AGENTS.md 中,为
security-scanner显式声明diff_base: "main":- name: "security-scanner" ... scope: include: ["**/package-lock.json"] diff_base: "main" # ← 强制以 main 分支为基准 - 同时在 CI 配置中,确保
GITHUB_BASE_REF被正确传递(GitHub Actions 需设置base-ref: ${{ github.base_ref }})。
提示:
diff_base字段是 Harness Engineering 2.3.0 新增的,但很多团队不知道。它解决的是“PR 基准分支不明确”导致的触发失效问题,比修改scope更治本。
5.2 故障:subagents.md中的docker-compose-db-v1.0.0在 macOS 上成功,在 Linux CI 上失败,报错command not found: docker-compose
现象:本地开发一切正常,CI 构建失败,错误日志显示docker-compose命令不存在。
根因排查链路:
- 检查 CI 环境,发现使用的是
docker compose(Docker 2.0+ 的新命令),而非docker-compose(旧命令); - 查看
subagents.md,command字段写的是docker-compose up -d db; - 检查
environment.dependencies,写的是docker-compose@2.20.0,但 CI 镜像中安装的是docker@24.0.0,其内置docker compose命令; - 根本原因:
environment.dependencies声明的包名与实际安装的二进制名不一致,且command未做平台适配。
修复方案:
- 在
subagents.md中,为docker-compose-db-v1.0.0增加platform_command字段:- name: "docker-compose-db-v1.0.0" ... platform_command: darwin: "docker-compose up -d db" linux: "docker compose up -d db" win32: "docker-compose.exe up -d db" - 同时,
environment.dependencies改为docker@24.0.0,删除docker-compose@2.20.0,因为新 Docker 已内置 compose 功能。
注意:
platform_command优先级高于command。当存在platform_command时,command字段被忽略。这是跨平台兼容的官方推荐方案,比写 shell 脚本判断 OS 更可靠。
5.3 故障:AGENTS.md中role: "qa-automation"的 agent,被role: "frontend-engineer"的开发者在 IDE 中意外触发
现象:前端工程师在 VS Code 中保存.ts文件,IDE Copilot 弹出窗口,建议运行e2e-test-runner(role: "qa-automation"),但该工程师无权访问测试环境。
根因排查链路:
- 检查
e2e-test-runner的scope.include,发现是["**/*.spec.ts"]; - 检查前端工程师的本地文件,发现他新建了一个
src/utils/date.spec.ts,用于单元测试; - 根本原因:
**/*.spec.ts的 glob 匹配过于宽泛,未限定目录,导致所有.spec.ts文件变更都触发qa-automationagent。
修复方案:
- 重构
scope.include,精确到 QA 专用目录:scope: include: ["cypress/e2e/**/*", "tests/e2e/**/*"] # ← 仅限 e2e 目录 exclude: ["src/**/*", "packages/**/*"] # ← 明确排除开发源码 - 同时,在团队规范中明确:
.spec.ts仅用于单元测试(归frontend-engineer),E2E 测试必须用.cy.ts(Cypress)或.test.ts(Playwright),并更新scope.include为["**/*.cy.ts", "**/*.test.ts"]。
经验:
scope的include和exclude必须形成“正交切割”。我们后来制定了一条铁律:任何include路径,必须有至少一个exclude路径与之对称。例如include: ["cypress/**/*"]必须配exclude: ["cypress/fixtures/**/*", "cypress/support/**/*"],否则 fixtures 的变更也会触发测试。
6. 我的个人经验:AGENTS.md 是团队认知的镜像,不是技术文档
写完这篇,我翻出自己第一个项目的 AGENTS.md 版本(2022 年 3 月),对比现在正在维护的版本(2024 年 7 月),最大的变化不是字段增多,而是语言越来越“人话”。早期版本充斥着cmd,exec,hook这类技术术语,而现在满是frontend-engineer,security-reviewer,release-manager这样的角色名,以及code_quality_assessment,compliance_approval这样的能力名。
这印证了一个事实:AGENTS.md 的成熟度,不取决于它覆盖了多少命令,而取决于它多大程度上反映了团队真实的协作模式。当一个新成员加入,他不需要读厚厚的操作手册,只要看 AGENTS.md,就能立刻明白:“哦,前端工程师负责代码质量,SRE 负责安全扫描,QA 负责 E2E 测试——而且每个人都知道自己的动作会在什么时机、什么条件下被触发。”
所以,别把它当成一份待填写的表格。下次你打开编辑器,光标悬停在 AGENTS.md 的空白处时,试着问自己一个问题:
“如果我现在要向一个刚入职的同事,用一句话解释我们团队的工程协作规则,这句话会是什么?”
把这句话,写成第一行role。
把这句话里提到的“动作”,拆成capability。
把这句话里隐含的“发生场景”,转化为scope.include。
剩下的,只是让 Codex 听懂人话而已。
