GET /tasks/{id}/files previously returned 409 for all docker tasks
on the grounds that tenant docker shares /app/work per project.
Production inspection shows the worker creates a per-task
host-worktrees/<task-id> subdirectory under the tenant work dir
(62 such dirs under the wwww tenant), so per-task reads are
attributable after all: the endpoint now resolves tenant docker
tasks via tenants/<project>/work/host-worktrees/<task-id> (local
worktree as fallback), never serves the shared /app/work mount
itself, and still 409s cloud/cross-host tasks. Docs and the
harness2_tasks boundary notes are updated to match.
189 lines
14 KiB
Markdown
189 lines
14 KiB
Markdown
# Harness² 融合说明
|
||
|
||
状态:方法层(`cloud/harness2.py` + `cloud/tests/test_harness2.py` +
|
||
`examples/harness2_demo.py`)与接线层(`cloud/harness2_tasks.py` +
|
||
`cloud/tests/test_harness2_tasks.py` + `examples/harness2_run.py`)
|
||
均已落地,在独立分支 `feat-harness2`(已 rebase 到生产 #133 的
|
||
c757810)上,不碰主线与生产,**未合并、未部署**。控制台评审页
|
||
与 `auto_apply` 开关仍是下一步,见文末。
|
||
|
||
## 上游是什么
|
||
|
||
Google Research 的 harness2(Apache 2.0,arXiv:2610.14224):模型权重
|
||
不动,只优化模型周围的 harness——系统提示、guardrails、记忆、工具
|
||
指引、子代理、技能、脚本、插件。两层递归:
|
||
|
||
- 任务层:Propose–Probe–Compose。对同一任务提 INV(通用做法)与 DOM
|
||
(领域做法)两类候选 harness,每个候选真实跑一次(探针),合成器
|
||
只把有行为证据支撑的改动合成终版;没有标准答案,靠行为对比。
|
||
- 经验层:采纳后反思出一条有证据的经验(cue / lesson / evidence),
|
||
连同被采纳 harness 的完整树存进分领域经验库,供后续任务参考。
|
||
|
||
上游报告:单步改进在三个专业 benchmark 上最高 +11.5 / +4.5 /
|
||
+11.8 个点;k=5 并行递归在 WorkBuddy 上到 80.8。这些数字是上游
|
||
benchmark 上的,不是本项目的承诺——本项目没有 benchmark,接线后
|
||
必须先在自己的重复任务集上跑 base 对照,才能谈收益。
|
||
|
||
## 融合映射:照搬 / 改写 / 不引
|
||
|
||
| 上游东西 | 本项目做法 |
|
||
|---|---|
|
||
| c1–c8 可编辑树(codex surface) | 照搬:AGENTS.md(8000 字符上限)、`.agents/skills/*/SKILL.md`、`.agents/skills/*/scripts/*`、`sub_agents/*/AGENT.md`、`scripts/*.py`、`.codex/hooks.json`;白名单外一律不读不写;校验含技能 description、触发行、脚本 compile、脚本引用存在、钩子 JSON 与事件名 |
|
||
| Propose–Probe–Compose,parallel / sequential | 照搬语义,改写实现:`run_parallel` / `run_sequential`,提议被拒给一次按反馈修正的机会,再拒回落到当前 harness;探针失败记为死探针,不炸整轮 |
|
||
| 匿名选择(只比产物) | 照搬:终版产物按任务+k+模式定种子洗牌匿名化,选择器看不到 harness 来源;选失败回落到已交付终版;k=1 且终版池只有唯一候选时 trivial 直出(与选择器是否提供无关;2026-10-29 对照纠正了此前“无选择器才 trivial”的误写) |
|
||
| 经验库(分领域、整树 + EXPERIENCE.md + INDEX.md) | 照搬存储格式,改写生效语义:入库 ≠ 自动生效(见下) |
|
||
| Ledger(会话/探针账本)与成本 | 改写:成本汇总进 `ModeResult.total_cost_usd`,由调用方接现有 `cloud/cost.py` / 租户配额计费;另有 `budget_usd` 超支门与 `max_sessions` 会话数门(计探针 + 阶段尝试会话;edit 内 fmt2 纠正与选择/反思会话不计入):任一超支后不再调用编辑器/探针,剩余阶段回落,且该轮不入库;上游的 R=4k/P=3k 事后强校验未引(本项目结构与上游计数不完全同构,门控代替断言) |
|
||
| run_tag 形状与建任务前预检 | 对照补齐:`validate_run_tag`(字符集、首字符不为 -、不以 s 数字段开头)与 `preflight_checks`(base 目录与 AGENTS.md、source 目录、提示、tag、控制面 health 探针);CLI `--check` 只预检不建任务 |
|
||
| 分角色模型(SOLVER / IMPROVER / JUDGE) | 已引(机制):`run_improvement_via_tasks` 的 `role_models`(probe / editor / selector / reflector)+ CLI `--model-<角色>`;非空值覆盖该角色,缺键回落共享 `--model`,两者都空走服务端默认——默认行为不变,模型选择权仍在调用方(有价模型产生真实成本) |
|
||
| 产物资产(deliverable_files / deliverable_assets / images)与选择 assets note | 已引(两层):①服务端 `GET /tasks/{id}/files`(任务 worktree 读取,路径安全解析、文本 ≤256KB、列表 ≤200 条;本地任务走本地 worktree,租户 Docker 任务走 `tenants/<项目>/work/host-worktrees/<任务id>`——worker 按任务建子目录,只 serves 任务级子目录、绝不 serves 共享的 `/app/work` 本身;cloud/跨机任务 409);②探针回复里的 `DELIVERABLE FILE` 块(所有执行器可用,端点结果优先、坏块丢弃不炸轮次)。产物进 `Rollout.deliverable_files`,并写入选择/编辑/反思上下文的 `deliverables/<label>/files/`。images(二进制产物)未引:端点只读 UTF-8 文本 |
|
||
| sweep 层(多任务批量、跨任务 library restore、dead 隔离、report 汇总) | 已引:`run_sweep` + CLI `examples/harness2_sweep.py`(共享 library_root、单轮失败隔离、sweep-report.json 汇总;manifest 预检不过不建任务,默认只打印计划,`--yes` 才真跑) |
|
||
| 提示词 | 改写短版(中文项目语境下的英文任务提示,结构同上游,措辞自写),不抄上游长提示 |
|
||
| Benchmark 适配器(JobBench / LAB / WorkBuddy)、评分器、rubric | 不引。任务就是一个普通 codex 任务,delivered 由 adapter 定义(如产物存在且非空) |
|
||
| Proposer 通道(Claude CLI / OpenCode)、Vertex 配置 | 不引。编辑器/选择器/反思器是调用方注入的协议,真实接线时是 codex 任务(见下) |
|
||
| 上游执行默认:网络不受限、Codex sandbox 禁用 | 明确不引,见安全一节 |
|
||
| 上游 ThreadPool 并行探针 | 不引。核心循环串行跑探针;并行是调用方在 adapter 层把每个探针发成独立 codex 任务来实现,避免核心循环抢同一个 Worker/DB |
|
||
|
||
## 安全偏差(刻意与上游不同,勿“纠回”上游)
|
||
|
||
1. **执行面与本模块解耦。** `cloud/harness2.py` 不跑任何命令、不拼
|
||
任何 codex exec 参数。真实 adapter 必须走 Worker 已有的 Docker /
|
||
gVisor 沙箱、租户隔离与 egress 白名单,绝不加
|
||
`--dangerously-bypass-approvals-and-sandbox`。上游 README 明写其
|
||
Codex 执行是 sandbox 禁用、网络不受限——那是 benchmark 实验的
|
||
默认,不是生产服务的默认。
|
||
2. **采纳 ≠ 自动铺开。** 多租户下,一个编辑器写出的 AGENTS.md 若
|
||
自动改写别人的任务,是质量与越权双重风险。所以 `improve_task`
|
||
的 `auto_apply` 默认关:库只是“待评审的经验”,提议器可读,
|
||
但任何任务的 base 永远是项目自己的 base harness;只有项目显式
|
||
开启 auto_apply 后,调用方才在下一轮的 `base_harness()` 里取库
|
||
中最新采纳树。这个开关的服务端接线(项目设置 + 控制台评审页)
|
||
是下一步。
|
||
3. **预算是硬门。** k 硬上限 3;parallel 一轮最多 10 次执行
|
||
(1 base + 2k 候选 + k 终版),sequential 同样最多 10 次。
|
||
`budget_usd` 超支后不再花钱,且该轮不入库——超支轮次的“经验”
|
||
是不完整证据,不配进库。
|
||
4. **编辑器写出的东西先校验后采纳。** 校验失败的提议给一次修正
|
||
机会,再失败就回落,绝不把坏树(空 AGENTS.md、坏钩子、不编译
|
||
的脚本、无触发行的技能)铺到探针里。
|
||
|
||
## 接口(调用方视角)
|
||
|
||
```python
|
||
from cloud.harness2 import improve_task, Library
|
||
from pathlib import Path
|
||
|
||
result = improve_task(adapter, editor,
|
||
selector=selector, reflector=reflector,
|
||
work_dir=Path("runs/h2-task-1"), k=1, mode="parallel",
|
||
library=Library(Path("runs/library"), domain="accounting"),
|
||
budget_usd=5.0)
|
||
result.to_dict() # 阶段、回落、选择、经验、入库位置、总成本
|
||
```
|
||
|
||
- `TaskAdapter`:`task_id / domain / prompt` 属性;`base_harness()`;
|
||
`execute(label, harness) -> Rollout`(失败也返回 Rollout,不抛);
|
||
可选 `validate_tree()` 做项目特有校验。
|
||
- `Editor.edit(mission, context_dir, label) -> EditResult`:读
|
||
`context_dir` 下的 `task.md / rollouts / deliverables / harness /
|
||
library`,改 `context/harness/`,返回改完的树(`tree=None` 时
|
||
框架自动从 `context/harness/` 读回——真实 codex 任务接线就用这条
|
||
路);`notes` 是编辑器最终消息里 `<notes>` 块的解析结果,由调用
|
||
方在构造 `EditResult` 时填好。
|
||
- `Selector.select(mission, context_dir) -> (id, why, cost)`:读
|
||
`context_dir/candidates/<id>.md`(匿名产物),返回 choice id。
|
||
- `Reflector.reflect(mission, context_dir) -> dict`:返回含 cue /
|
||
lesson / evidence(可含 confidence / edits)的经验,三项不全
|
||
返回 `{}` 即表示无可入库经验。
|
||
|
||
证据上下文由 `build_context` 生成:证据以文件形式给模型,prompt
|
||
只写指针,不把轨迹截断后塞进 prompt(截断过的轨迹会编造缺陷,
|
||
这是上游明确反对的做法)。
|
||
|
||
## 接线层(已落地,`cloud/harness2_tasks.py`)
|
||
|
||
只用控制面已有的 API,不新增任何服务端端点:`POST /uploads` →
|
||
`POST /tasks`(`upload_id` 绑定 + `idempotency_key` 幂等)→
|
||
`GET /tasks/{id}` 轮询终态 → `GET /tasks/{id}/trace` 读轨迹 →
|
||
`GET /tasks/{id}/cost` 读 `usd_micros` → 超时 `POST /tasks/{id}/cancel`。
|
||
认证是 `X-API-Key` 请求头;密钥只由调用方持有,接线层不读、不存、
|
||
不打印密钥。
|
||
|
||
- **探针**:bundle = 源文件 + harness 树,Worker 派发时经
|
||
`host.py --seed-dir` 拷进 worktree,harness 文件覆盖仓库同名文件
|
||
——同一任务在不同 harness 下跑。prompt = 原任务 prompt + 交付脚注。
|
||
- **结果读取**:控制面返回的 `result_text` 是一层 JSON 信封
|
||
(task / thread_id / cwd / status / text / usage),agent 的最终
|
||
回复在 `text` 字段里。`run_task` 是四路共用的 choke point,
|
||
在此用 `unwrap_result_text` 拆信封;直接拿信封当回复会让
|
||
行首锚定的文件块解析永远看不见块(2026-10-29 三轮真实轮次
|
||
的真根因:编辑明明发了块,却被判成无改动/格式错误)。
|
||
- **编辑**:bundle = `context/` 前缀的证据树;agent 在 worktree 的
|
||
`context/harness/` 里改文件,最终回复用文件块(`=== HARNESS FILE:
|
||
路径 ===` … `=== END HARNESS FILE ===`)只输出改动文件,没改的
|
||
省略即继承,外加 `<notes>` 块;接线层解析后落到 fallback 树上再
|
||
走核心校验。格式契约(2026-10-29 两轮真实轮次后收紧):只在
|
||
`<notes>` 里声称改动、却不发文件块,不再算“无需改动”——无改动
|
||
必须用显式 `<no-change>理由</no-change>` 标记;文件块与标记同
|
||
时出现、路径越界、块未闭合都算格式错误,接线层用独立幂等键
|
||
带反馈重试一次(`.fmt2`),两次都错才如实回落,绝不静默采纳。
|
||
- **选择 / 反思**:同款 bundle + 最终回复解析(` ```choice ` +
|
||
`<why>`;`<experience>` + `<edits>`),不读 worktree 文件。
|
||
- **幂等**:`idempotency_key = h2-{run_tag}-{role}-{label}`,同一
|
||
run_tag 重跑复用已有任务(断点续跑);想从头重跑必须换 run_tag,
|
||
换了 harness 内容却沿用旧 run_tag 时旧结果会被复用、白跑一轮。
|
||
- **成本与审批**:接线层不拼任何 codex 参数、不改任务默认值;
|
||
model / effort / approval / environment_id / executor 全由调用方
|
||
透传,缺省走 Worker 默认值;任务走与普通任务完全相同的执行器、
|
||
沙箱、租户隔离、配额门与审计;单任务超时默认 3600 秒,超时先
|
||
取消再记失败/回落;长任务走 #133 的自动续跑。
|
||
- **接线层最大的适用边界**:探针交付物 = 最终回复文本。文件型
|
||
产物(代码 diff、报告文件)目前只能靠 agent 在最终回复里复述/
|
||
摘要:worktree 快照 API 只给 diff 截断(20000 字)与未跟踪文件
|
||
列表、不给完整文件内容,跨主机还读不到。文件产物评审需要新增
|
||
文件读取端点,另行评审,不在本层假装已解决。
|
||
|
||
用真实任务跑一轮(先 dry-run 看计划,确认后加 `--yes`):
|
||
|
||
```bash
|
||
export CODEX_API_KEY='...' # 密钥只走环境变量,不接受命令行参数
|
||
python3 examples/harness2_run.py --base-url http://127.0.0.1:8765 \
|
||
--project-id <测试项目> --prompt-file task.md \
|
||
--base-harness-dir ./my-base-harness --source-dir ./task-source \
|
||
--mode parallel --k 1 --budget 5 --output runs/h2-real-1
|
||
# 计划无误后,加 --yes 重跑;同一 run_tag 可断点续跑
|
||
```
|
||
|
||
## 仍未做的事(需另行评审)
|
||
|
||
1. **人工关口**:采纳的 harness 先在控制台以“待评审经验”展示,
|
||
项目负责人点“采纳为 base”后才影响后续任务;`auto_apply` 开关
|
||
默认关,开启需项目负责人显式操作并记审计。目前
|
||
`run_improvement_via_tasks` 的 `auto_apply` 恒为 False,库只是
|
||
待评审经验。
|
||
2. **文件产物评审**:新增读取已终态任务 worktree 文件的端点(权限、
|
||
跨主机、大小上限都要定),或让探针任务把产物文件内容写进最终
|
||
回复的约定成为正式契约——前者是服务端改动,后者是提示词契约,
|
||
各有利弊,需评审。
|
||
3. **效果验证**:先在 3–5 个重复性任务(如固定格式的对账、周报
|
||
生成)上跑 base 对照:同一任务、同一模型,base 产物 vs 改进后
|
||
产物由人工或固定检查脚本判定,记录执行次数与成本;没有对照
|
||
就不宣称收益。
|
||
|
||
## 演示与测试(无模型、无网络)
|
||
|
||
```bash
|
||
python3 examples/harness2_demo.py --mode parallel # 应输出 Total: 85.15 并入库
|
||
python3 examples/harness2_demo.py --mode sequential
|
||
python3 -m unittest discover -s cloud/tests -p "test_harness2*.py" -v # 25 项
|
||
```
|
||
|
||
接线层单测用进程内假客户端(探针真跑 bundle 里的脚本、编辑按
|
||
文件块格式回改动)跑通完整轮次,并对本地假服务端实测了 HTTP
|
||
客户端(建任务、轮询、成本、轨迹、密钥头、multipart 与服务端
|
||
解析器往返)。真实任务链路尚未实测:第一次真实运行请用测试项目,
|
||
并先跑 `examples/harness2_run.py` 不带 `--yes` 的计划模式。
|
||
|
||
演示账本 100.25 / -20.10 / 5.00:base 脚本只加正数得 105.25,
|
||
改进后带符号和为 85.15;演示用的编辑器/选择器/反思器是确定性的,
|
||
只为证明循环本身,真实收益必须用第 4 步的对照来量。
|