Files
codex-app-server/cloud/harness2_tasks.py
T
host 42abe3cc5b harness2: recursive harness optimization wired to real codex tasks
Port Google Research's Harness^2 method layer (model weights frozen,
only the surrounding harness is optimized): Propose-Probe-Compose with
INV/DOM/AGG editors, parallel/sequential modes, per-domain experience
library, anonymous-product selection, k<=3 hard cap and budget gate.
Execution goes only through the existing control-plane API (upload ->
task with idempotency key -> poll -> trace/cost -> cancel on timeout);
no new server endpoints, no codex flags composed, tasks inherit the
normal executor/sandbox/quota/audit path.

Wiring lessons from four real rounds against production (project
wwww, ledger task): HttpTaskClient retries transient disconnects and
502/503/429 (3 attempts); editors get a worked file-block example, an
explicit <no-change> marker and one feedback retry (.fmt2) instead of
silently accepting notes-only replies; run_task unwraps the server's
result_text JSON envelope (text field holds the real reply) — without
this, line-anchored file-block parsing could never see blocks the
agents were actually emitting. Final round (real4) fully green:
all stages landed, selection non-fallback, experience入库, probe
delivers the correct signed total 85.15.

Tests: 35 harness tests (17 method + 18 wiring incl. fake-control-plane
full round, idempotent replay, retry, format retry, envelope round).
Deliberate deviations from upstream are documented in docs/harness2.md
(no network-unrestricted/sandbox-disabled defaults, auto_apply off,
no benchmark/scorer, probe deliverable = final reply text).
2026-10-29 14:21:18 +08:00

755 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Harness² 接线层:把 cloud/harness2.py 的三个协议落到真实 codex 任务上。
只用控制面已有的 API,不新增任何服务端端点:
POST /uploads(multipart + paths JSON)-> upload_id
POST /tasks(upload_id 绑定 + idempotency_key 幂等)-> 任务排队
GET /tasks/{id} 轮询终态(completed / failed / interrupted /
merged / merge-failed)
GET /tasks/{id}/trace 轨迹(探针的 trajectory 来源)
GET /tasks/{id}/cost 成本(usd_micros / 1e6;无价 token 不计入 USD,
这是现有 cost 模型的边界,不是本层漏算)
POST /tasks/{id}/cancel 超时取消,不留孤儿任务继续烧钱
认证:X-API-Key 请求头;密钥只由调用方持有,本模块不读、不存、
不打印密钥。
三类任务是怎么跑的:
- 探针(CodexProbeAdapter.execute):bundle = 源文件 + harness 树。
Worker 派发时把 bundle 经 host.py --seed-dir 拷进 worktree,harness
文件会覆盖仓库同名文件——这正是探针要的效果:同一任务在不同
harness 下跑。prompt = 原任务 prompt + 一段交付脚注(见
_PROBE_DELIVERABLE_FOOTER),因为评审只看最终回复:agent 必须
把交付物完整写在最终回复里,不能只说“文件已写好”。这就是本层
最大的适用边界:交付物是最终回复文本的任务;文件型产物(代码
diff、报告文件)目前只能靠最终回复复述/摘要,worktree 快照 API
只给 diff 截断与未跟踪文件列表、不给完整文件内容,跨主机还读
不到——文件产物评审需要新增文件读取端点,另行评审,不在本层
假装已解决。
- 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md /
rollouts / deliverables / harness / library / pool / decisions)。
agent 在 worktree 的 context/harness/ 里改文件,最终回复里用
文件块只输出改动的文件(没改的省略即继承),外加 <notes> 块。
接线层解析文件块、落到该阶段的 fallback 树上,再走核心校验。
最终回复里既没有文件块也没有 <notes>,或文件块路径越界、未
闭合,都算该阶段失败:核心循环给一次修正机会,再失败回落到
fallback 树——绝不把“没看懂输出格式”静默当成“无需改动”。
- 选择(CodexSelector.select)与反思(CodexReflector.reflect):同款
bundle + 最终回复解析(```choice + <why>;<experience> + <edits>),
不读 worktree 文件。选择失败时核心循环回落到已交付终版;反思
三项不全时返回 {},不入库。
幂等与断点续跑:每个任务都带 idempotency_key =
f"h2-{run_tag}-{role}-{label}"。同一 run_tag 重跑时服务端直接返回
已有任务(200 而非 201),已终态的任务直接复用结果——这是断点
续跑语义,不是 bug;想从头重跑必须换 run_tag。注意:换了 harness
内容但沿用旧 run_tag 时,旧任务结果会被复用,改进轮次就白跑了,
所以 run_tag 必须随“任务 + harness 基线 + 参数”一起变,调用方
(见 examples/harness2_run.py)默认用时间戳生成,显式 --run-tag
只用于“接着上次断点跑”。
成本与审批:本层不拼任何 codex 参数、不改任务默认值。model /
effort / approval / environment_id / executor 全部由调用方透传,
缺省时走 Worker 现有默认值;探针与编辑任务走与普通任务完全相同
的执行器、沙箱、租户隔离、配额门与审计。长任务走 #133 的自动续
跑;接线层单任务超时默认 3600 秒,超时先取消任务再记失败(探针)
或抛错走回落(编辑/选择/反思),不留任务在后台继续烧钱。
本层是同步阻塞的:improve_task 每一步都要等任务终态。k=1
parallel 一轮 = 1 base + 2 候选 + 1 终版探针 + 3 编辑 + 1 选择 +
1 反思 ≈ 9 个任务串行等待,每个都可能几分钟——这是方法本身的
成本(上游同款:4k 次执行 + 3k 次改进会话),不是本层低效。
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
from .harness2 import (
EditResult,
Rollout,
Tree,
improve_task,
parse_choice,
parse_experience,
parse_harness_files,
parse_no_change,
parse_notes,
parse_why,
read_tree,
)
TERMINAL_STATUSES = frozenset(
{"completed", "failed", "interrupted", "merged", "merge-failed"}
)
DEFAULT_TASK_TIMEOUT_SECONDS = 3600
DEFAULT_POLL_SECONDS = 10
TRACE_MAX_CHARS = 50000
def unwrap_result_text(raw: str) -> str:
"""把服务端 result_text 的 JSON 信封拆开,只取 agent 的最终回复。
2026-10-29 诊断的真根因:控制面返回的 result_text 不是 agent
原文,而是一层 JSON 信封(task / thread_id / cwd / status /
text / usage …),text 字段里才是最终回复。接线层此前原样把
信封当回复用:notes / choice / experience 的正则不锚定行首,
在信封里照样命中,所以三轮里只有行首锚定的文件块解析一直
看不见块——编辑明明发了块,却被判成 notes-only、无改动、
格式错误。拆信封放在 run_task 这一个 choke point:探针、
编辑、选择、反思四路共用它,一处拆全受益;拆不开(非
JSON、无 text 字段、text 非字符串)时原样返回,不丢内容。
"""
if not raw or not raw.lstrip().startswith("{"):
return raw
try:
obj = json.loads(raw)
except ValueError:
return raw
if isinstance(obj, dict) and isinstance(obj.get("text"), str):
return obj["text"]
return raw
_PROBE_DELIVERABLE_FOOTER = """
[DELIVERABLE — READ THIS BEFORE YOU FINISH]
Your final reply IS the deliverable that will be judged. When you finish,
write the complete deliverable in your final reply itself — do not only say
"done" or "the file is written". If the task asks for a file, also write the
file in the worktree, but the final reply must still contain the full content
a reviewer needs to judge the work.
"""
_EDITOR_OUTPUT_FORMAT = """
# OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY
Edit the files under context/harness/ in the worktree. Then, in your final
reply, output ONLY the files you changed, one block per file, in this format
(files you did not change are inherited automatically — do NOT re-emit them):
=== HARNESS FILE: <relative path on the editable surface> ===
<the file's COMPLETE new content>
=== END HARNESS FILE ===
Example (a one-line file changed in full — your blocks must contain the
COMPLETE new content, not a diff and not a description of the change):
=== HARNESS FILE: scripts/summarize.py ===
print("Total: 85.15")
=== END HARNESS FILE ===
Then a <notes>...</notes> block: what you changed, and the exact trajectory
moment that justifies each change.
HARD RULES — READ CAREFULLY, THEY ARE MACHINE-CHECKED:
1. A change described only in <notes> is NOT a change. If you changed a
file, its COMPLETE new content MUST appear in a file block above, or the
change is discarded and this attempt counts as FAILED.
2. If nothing in the evidence justifies a change, you MUST emit an explicit
<no-change>...</no-change> block with the reason, and no file blocks.
Saying "no change needed" only in <notes> is NOT enough — without the
marker the attempt counts as FAILED.
3. File blocks and a <no-change> marker in the same reply contradict each
other and the attempt counts as FAILED.
"""
_SELECTOR_OUTPUT_FORMAT = """
# OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY
Reply with a ```choice block containing only the chosen candidate id, and a
<why>...</why> block explaining the decision in one paragraph.
"""
_REFLECTOR_OUTPUT_FORMAT = """
# EVIDENCE NOTE
context/harness/ is the adopted harness; context/base_harness/ is the
unmodified base harness; context/pool/<label>/ holds each candidate harness
plus its rollout. Compare them before writing.
# OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY
Reply with an <edits>...</edits> block (one row per changed component, with
its observed effect and a verdict), then an <experience>...</experience>
block with exactly these lines: `cue: ...`, `lesson: ...`, `evidence: ...`,
`confidence: ...`. If no lesson is supported by an observed contrast, reply
with the single line NO_ENTRY instead of inventing one.
"""
# ---------------------------------------------------------------------------
# 客户端协议与 HTTP 实现
# ---------------------------------------------------------------------------
@dataclass
class TaskState:
task_id: str
status: str
result_text: str = ""
error_text: str = ""
class TaskClient(Protocol):
"""控制面客户端协议:HTTP 实现见 HttpTaskClient,测试用假实现。"""
def upload(self, files: dict[str, bytes]) -> str:
"""上传一包文件(相对路径 -> 内容),返回 upload_id。"""
...
def create_task(self, *, project_id: str, prompt: str, upload_id: str | None = None,
idempotency_key: str | None = None, model: str | None = None,
effort: str | None = None, approval: str | None = None,
environment_id: str | None = None, executor: str | None = None,
no_time_limit: bool = False) -> str:
"""建任务,返回任务 id;同幂等键返回已有任务 id,不建重复。"""
...
def get_task(self, task_id: str) -> TaskState:
...
def get_trace_text(self, task_id: str) -> str:
"""轨迹渲染文本;读不到时返回空串(调用方用最终回复兜底)。"""
...
def get_cost_usd(self, task_id: str) -> float:
"""有价成本 USD;读不到时返回 0.0,不炸轮次。"""
...
def cancel_task(self, task_id: str) -> None:
...
def build_multipart(files: dict[str, bytes]) -> tuple[bytes, str]:
"""按服务端 cloud/uploads.py 的解析器能读懂的格式编码 multipart。
paths 字段是 JSON 字符串数组,与 file part 一一对应;part 名用
file(服务端只按顺序取 files,不认 part 名)。编码结果可用
cloud.uploads.parse_multipart + parse_paths_field 原样解回,
见 test_harness2_tasks.py 的往返测试。
"""
boundary = "----harness2-" + uuid.uuid4().hex
paths = sorted(files)
parts: list[bytes] = []
parts.append(
b"--" + boundary.encode() + b"\r\n"
b'Content-Disposition: form-data; name="paths"\r\n\r\n'
+ json.dumps(paths).encode() + b"\r\n"
)
for path in paths:
parts.append(
b"--" + boundary.encode() + b"\r\n"
+ f'Content-Disposition: form-data; name="file"; filename="{path}"\r\n'.encode()
+ b"Content-Type: application/octet-stream\r\n\r\n"
+ files[path] + b"\r\n"
)
parts.append(b"--" + boundary.encode() + b"--\r\n")
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
class HttpTaskClient:
"""基于标准库 urllib 的控制面客户端(零第三方依赖,与项目风格一致)。
瞬断重试(2026-10-29 真实首跑教训):VM 到公网控制面的连接会
偶发中途断开(RemoteDisconnected / IncompleteRead),一次断开
就让整轮提前收尾,尽管服务端任务还在跑。_request 对传输错误与
502 / 503 / 429 最多重试 3 次(退避 1s、3s),其它 HTTP 错误
(401 / 403 / 400 等服务端裁决)不重试。重试安全的前提是调用
方都带 idempotency_key(本层所有建任务调用都带)与“上传包
未绑定即孤儿包”的语义:重试建任务时服务端返回已有任务,不
重复建;重试上传时最多多出一个 24 小时后过期的孤儿包。
"""
def __init__(self, base_url: str, api_key: str, *, timeout: int = 60,
max_attempts: int = 3):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.max_attempts = max(1, max_attempts)
def _request(self, method: str, path: str, body: bytes | None = None,
content_type: str | None = None) -> dict:
req = urllib.request.Request(
self.base_url + path, data=body, method=method)
req.add_header("X-API-Key", self.api_key)
if content_type:
req.add_header("Content-Type", content_type)
elif body is not None:
req.add_header("Content-Type", "application/json")
last_exc: Exception | None = None
for attempt in range(1, self.max_attempts + 1):
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
except urllib.error.HTTPError as exc:
if exc.code in (502, 503, 429) and attempt < self.max_attempts:
last_exc = exc
time.sleep(1 if attempt == 1 else 3)
continue
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(
f"{method} {path} -> HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, ConnectionError, TimeoutError,
OSError) as exc:
# RemoteDisconnected / IncompleteRead 都落在这里(前者是
# ConnectionResetError / http.client 子类,后者是
# http.client.HTTPException,而 HTTPException 不是
# OSError——见下方 except。
if attempt < self.max_attempts:
last_exc = exc
time.sleep(1 if attempt == 1 else 3)
continue
raise RuntimeError(
f"{method} {path} -> transport error: {exc}") from exc
except Exception as exc: # http.client.HTTPException 等非 OSError
if exc.__class__.__name__ in (
"RemoteDisconnected", "IncompleteRead", "BadStatusLine",
"HTTPException") and attempt < self.max_attempts:
last_exc = exc
time.sleep(1 if attempt == 1 else 3)
continue
raise
break
else:
raise RuntimeError(
f"{method} {path} -> transport error: {last_exc}")
try:
obj = json.loads(raw.decode("utf-8"))
except ValueError as exc:
raise RuntimeError(f"{method} {path} -> non-JSON response") from exc
if not isinstance(obj, dict):
raise RuntimeError(f"{method} {path} -> unexpected response shape")
return obj
def upload(self, files: dict[str, bytes]) -> str:
if not files:
raise ValueError("upload requires at least one file")
body, content_type = build_multipart(files)
obj = self._request("POST", "/uploads", body=body, content_type=content_type)
upload_id = obj.get("upload_id")
if not upload_id:
raise RuntimeError(f"/uploads did not return an upload_id: {obj!r}"[:300])
return str(upload_id)
def create_task(self, *, project_id: str, prompt: str, upload_id: str | None = None,
idempotency_key: str | None = None, model: str | None = None,
effort: str | None = None, approval: str | None = None,
environment_id: str | None = None, executor: str | None = None,
no_time_limit: bool = False) -> str:
payload: dict = {"project_id": project_id, "prompt": prompt}
if upload_id is not None:
payload["upload_id"] = upload_id
if idempotency_key is not None:
payload["idempotency_key"] = idempotency_key
if model is not None:
payload["model"] = model
if effort is not None:
payload["effort"] = effort
if approval is not None:
payload["approval"] = approval
if environment_id is not None:
payload["environment_id"] = environment_id
if executor is not None:
payload["executor"] = executor
if no_time_limit:
payload["no_time_limit"] = True
obj = self._request("POST", "/tasks", body=json.dumps(payload).encode())
task_id = obj.get("id")
if not task_id:
raise RuntimeError(f"/tasks did not return an id: {obj!r}"[:300])
return str(task_id)
def get_task(self, task_id: str) -> TaskState:
obj = self._request("GET", f"/tasks/{task_id}")
return TaskState(
task_id=task_id,
status=str(obj.get("status") or ""),
result_text=str(obj.get("result_text") or ""),
error_text=str(obj.get("error_text") or ""),
)
def get_trace_text(self, task_id: str) -> str:
try:
obj = self._request("GET", f"/tasks/{task_id}/trace")
except Exception:
return ""
entries = obj.get("entries") or []
lines: list[str] = []
for entry in entries:
if not isinstance(entry, dict):
continue
kind = entry.get("kind") or entry.get("type") or "entry"
text = entry.get("text") or entry.get("message") or ""
if not text:
text = json.dumps(entry, ensure_ascii=False)[:1000]
lines.append(f"[{kind}] {text}")
rendered = "\n".join(lines).strip()
return rendered[:TRACE_MAX_CHARS] if rendered else ""
def get_cost_usd(self, task_id: str) -> float:
try:
obj = self._request("GET", f"/tasks/{task_id}/cost")
except Exception:
return 0.0
try:
return float(obj.get("usd_micros") or 0) / 1_000_000
except (TypeError, ValueError):
return 0.0
def cancel_task(self, task_id: str) -> None:
try:
self._request("POST", f"/tasks/{task_id}/cancel", body=b"{}")
except Exception:
pass # 终态任务取消会 400,本来就不用再取消;其它错误也不炸轮次
@dataclass
class CompletedTask:
task_id: str
state: TaskState
cost_usd: float = 0.0
trace_text: str = ""
def run_task(
client: TaskClient,
*,
project_id: str,
prompt: str,
files: dict[str, bytes] | None = None,
idempotency_key: str | None = None,
model: str | None = None,
effort: str | None = None,
approval: str | None = None,
environment_id: str | None = None,
executor: str | None = None,
no_time_limit: bool = False,
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS,
poll_seconds: int = DEFAULT_POLL_SECONDS,
) -> CompletedTask:
"""建任务、轮询到终态、读成本与轨迹。超时先取消再抛 TimeoutError。"""
upload_id = client.upload(files) if files else None
task_id = client.create_task(
project_id=project_id, prompt=prompt, upload_id=upload_id,
idempotency_key=idempotency_key, model=model, effort=effort,
approval=approval, environment_id=environment_id, executor=executor,
no_time_limit=no_time_limit)
deadline = time.time() + timeout_seconds
state = client.get_task(task_id)
while state.status not in TERMINAL_STATUSES:
if time.time() >= deadline:
client.cancel_task(task_id)
raise TimeoutError(
f"task {task_id} did not reach a terminal status within "
f"{timeout_seconds}s (last status: {state.status})")
time.sleep(poll_seconds)
state = client.get_task(task_id)
# 拆信封的唯一位置:run_task 是探针、编辑、选择、反思四路的
# 共同 choke point,在这里拆一次,全路受益;各 TaskClient 的
# get_task 保持原样返回服务端原文。
state = TaskState(
task_id=state.task_id, status=state.status,
result_text=unwrap_result_text(state.result_text),
error_text=state.error_text,
)
return CompletedTask(
task_id=task_id, state=state,
cost_usd=client.get_cost_usd(task_id),
trace_text=client.get_trace_text(task_id),
)
def _flatten_dir(root: Path, prefix: str = "") -> dict[str, bytes]:
"""把一个目录展平成 {相对路径: 内容};符号链接与 __pycache__ 跳过。"""
out: dict[str, bytes] = {}
root = Path(root)
if not root.is_dir():
return out
for p in sorted(root.rglob("*")):
if not p.is_file() or p.is_symlink() or "__pycache__" in p.parts:
continue
rel = str(p.relative_to(root))
out[(prefix + rel) if prefix else rel] = p.read_bytes()
return out
def _tree_to_files(tree: Tree) -> dict[str, bytes]:
return {rel: content.encode("utf-8") for rel, content in tree.items()}
# ---------------------------------------------------------------------------
# 三个协议的真实任务实现
# ---------------------------------------------------------------------------
@dataclass
class CodexProbeAdapter:
"""探针 adapter:每个 label 跑一个真实 codex 任务。
source_files:原任务需要的源文件(如 ledger.csv),会随 harness
一起 seed 进 worktree;与 harness 路径冲突时 harness 优先(探针
的意义就是让 harness 覆盖仓库默认值)。调用方应在 source_files
里避免与 harness 路径重名——重名时源文件会被静默覆盖,本层
不为此报错(与 seed“用户文件优先”的语义一致)。
"""
client: TaskClient
project_id: str
task_id: str
prompt: str
base: Tree
run_tag: str
source_files: dict[str, bytes] = field(default_factory=dict)
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
domain: str = "general"
def base_harness(self) -> Tree:
return dict(self.base)
def execute(self, label: str, harness: Tree) -> Rollout:
files = dict(self.source_files)
files.update(_tree_to_files(harness))
try:
done = run_task(
self.client, project_id=self.project_id,
prompt=self.prompt + _PROBE_DELIVERABLE_FOOTER,
files=files,
idempotency_key=f"h2-{self.run_tag}-probe-{label}",
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
except Exception as exc:
return Rollout(label=label, ok=False, delivered=False,
error=f"{type(exc).__name__}: {exc}"[:400])
state = done.state
delivered = state.status == "completed" and bool(state.result_text.strip())
return Rollout(
label=label, ok=state.status == "completed", delivered=delivered,
deliverable_text=state.result_text,
trajectory=done.trace_text or state.result_text,
cost_usd=done.cost_usd,
error="" if state.status == "completed" else (
state.error_text or f"task ended as {state.status}")[:400],
)
@dataclass
class CodexEditor:
"""编辑器:一个 codex 任务,证据 seed 进 worktree,改动以文件块回读。"""
client: TaskClient
project_id: str
run_tag: str
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
def edit(self, mission: str, context_dir: Path, label: str) -> EditResult:
"""编辑任务:格式错误给一次带反馈的纠正机会,再错才如实回落。
背景(2026-10-29 两轮真实轮次):编辑在 <notes> 里声称改
动、却不发文件块,旧契约把 notes-only 当“无需改动”采纳,
改动无声丢失;且方法层的阶段循环里编辑异常是直接 break(不
重试),所以纠正必须发生在本层。格式重试用独立幂等键
(…-edit-{label}.fmt2),不复用首个任务、不重复计费到同
一任务;任务本身失败(非 completed)仍直接抛错、不重试。
"""
files = _flatten_dir(context_dir, prefix="context/")
files["context/MISSION.md"] = mission.encode("utf-8")
last_err = ""
total_cost = 0.0
for attempt in (1, 2):
prompt = mission + _EDITOR_OUTPUT_FORMAT
if last_err:
prompt += (
"\n\n# YOUR PREVIOUS REPLY WAS REJECTED\n\n"
f"{last_err}\n\nFix the output format and reply again: "
"changed files MUST appear as === HARNESS FILE === blocks "
"with their COMPLETE new content, or, if truly no change "
"is justified, emit <no-change>...</no-change>.")
done = run_task(
self.client, project_id=self.project_id,
prompt=prompt,
files=files,
idempotency_key=f"h2-{self.run_tag}-edit-{label}"
+ ("" if attempt == 1 else ".fmt2"),
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
state = done.state
if state.status != "completed":
raise RuntimeError(
f"editor task {done.task_id} ended as {state.status}: "
f"{state.error_text}"[:400])
total_cost += done.cost_usd
text = state.result_text or ""
try:
changed = parse_harness_files(text)
notes = parse_notes(text)
no_change = parse_no_change(text)
except ValueError as exc:
last_err = f"output-format error: {exc}"
continue
if changed and no_change:
last_err = ("output-format error: reply contains both harness "
"file blocks and a <no-change> marker")
continue
if not changed and not no_change:
last_err = ("output-format error: reply contains neither "
"harness file blocks nor a <no-change> marker; "
"a change described only in <notes> is discarded")
continue
base_tree = read_tree(Path(context_dir) / "harness")
merged = dict(base_tree)
merged.update(changed)
return EditResult(tree=merged, notes=notes, cost_usd=total_cost)
raise ValueError(
f"editor reply failed output-format validation twice: {last_err}")
@dataclass
class CodexSelector:
"""选择器:一个 codex 任务,候选产物 seed 进 worktree,只解析最终回复。"""
client: TaskClient
project_id: str
run_tag: str
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
def select(self, mission: str, context_dir: Path) -> tuple[str, str, float]:
files = _flatten_dir(context_dir, prefix="context/")
files["context/MISSION.md"] = mission.encode("utf-8")
done = run_task(
self.client, project_id=self.project_id,
prompt=mission + _SELECTOR_OUTPUT_FORMAT,
files=files,
idempotency_key=f"h2-{self.run_tag}-select-{Path(context_dir).name}",
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
state = done.state
if state.status != "completed":
raise RuntimeError(
f"selector task {done.task_id} ended as {state.status}: "
f"{state.error_text}"[:400])
choice = parse_choice(state.result_text or "")
if not choice:
raise ValueError("selector task returned no ```choice block")
return choice, parse_why(state.result_text or ""), done.cost_usd
@dataclass
class CodexReflector:
"""反思器:一个 codex 任务,解析 <experience>;三项不全返回 {}。"""
client: TaskClient
project_id: str
run_tag: str
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
def reflect(self, mission: str, context_dir: Path) -> dict:
files = _flatten_dir(context_dir, prefix="context/")
files["context/MISSION.md"] = mission.encode("utf-8")
done = run_task(
self.client, project_id=self.project_id,
prompt=mission + _REFLECTOR_OUTPUT_FORMAT,
files=files,
idempotency_key=f"h2-{self.run_tag}-reflect-{Path(context_dir).name}",
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
state = done.state
if state.status != "completed":
return {}
return parse_experience(state.result_text or "")
def run_improvement_via_tasks(
client: TaskClient,
*,
project_id: str,
task_id: str,
prompt: str,
base_harness: Tree,
run_tag: str,
work_dir: Path,
domain: str = "general",
source_files: dict[str, bytes] | None = None,
k: int = 1,
mode: str = "parallel",
library_root: Path | None = None,
budget_usd: float | None = None,
model: str | None = None,
effort: str | None = None,
approval: str | None = None,
environment_id: str | None = None,
executor: str | None = None,
no_time_limit: bool = False,
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS,
):
"""一站式:用真实 codex 任务跑一轮完整改进,返回 ImprovementResult。
library_root 为空时不建经验库(只跑改进、不入库);非空时按
domain 建库。auto_apply 恒为 False——采纳的树只入库待评审,
不自动成为后续任务的 base(见 docs/harness2.md 安全一节)。
"""
from .harness2 import Library
adapter = CodexProbeAdapter(
client=client, project_id=project_id, task_id=task_id, prompt=prompt,
base=base_harness, run_tag=run_tag,
source_files=source_files or {}, environment_id=environment_id,
executor=executor, model=model, effort=effort, approval=approval,
no_time_limit=no_time_limit, timeout_seconds=timeout_seconds,
domain=domain)
shared = dict(environment_id=environment_id, executor=executor, model=model,
effort=effort, approval=approval, no_time_limit=no_time_limit,
timeout_seconds=timeout_seconds)
editor = CodexEditor(client=client, project_id=project_id, run_tag=run_tag, **shared)
selector = CodexSelector(client=client, project_id=project_id, run_tag=run_tag, **shared)
reflector = CodexReflector(client=client, project_id=project_id, run_tag=run_tag, **shared)
library = Library(library_root, domain) if library_root is not None else None
return improve_task(
adapter, editor, selector=selector, reflector=reflector,
work_dir=work_dir, k=k, mode=mode, library=library,
auto_apply=False, budget_usd=budget_usd)
__all__ = [
"TERMINAL_STATUSES", "DEFAULT_TASK_TIMEOUT_SECONDS", "DEFAULT_POLL_SECONDS",
"TRACE_MAX_CHARS", "TaskState", "TaskClient", "build_multipart",
"HttpTaskClient", "CompletedTask", "run_task",
"CodexProbeAdapter", "CodexEditor", "CodexSelector", "CodexReflector",
"run_improvement_via_tasks",
]