harness2: per-role models, sweep batch, task file assets endpoint
- Role models: run_improvement_via_tasks gains role_models
(probe/editor/selector/reflector) plus --model-<role> CLI flags;
missing/empty falls back to shared --model, both empty means
server default, so default behavior is unchanged.
- Sweep: run_sweep + examples/harness2_sweep.py manifest CLI with
shared library_root (library restore), per-run failure isolation
and sweep-report.json summary; preflight gates task creation.
- Deliverable assets: GET /tasks/{id}/files for local-executor
tasks (safe path resolution, UTF-8 text <=256KB, listing <=200;
docker/cloud/cross-host 409 with explicit reason, since tenant
Docker shares one /app/work per project and per-task attribution
would misattribute); probe rollout deliverable_files from the
endpoint plus DELIVERABLE FILE reply blocks (endpoint wins on
collision, bad blocks dropped), surfaced in
deliverables/<label>/files/ context and full_result summary.
- Docs: harness2.md comparison table updated for all three items.
This commit is contained in:
1 parent
9e444eca38
commit
c20192bce8
8 files changed
+856
-28
No files matched your search
+145
-13
@@ -1,6 +1,6 @@
|
||||
"""Harness² 接线层:把 cloud/harness2.py 的三个协议落到真实 codex 任务上。
|
||||
|
||||
只用控制面已有的 API,不新增任何服务端端点:
|
||||
控制面 API:
|
||||
POST /uploads(multipart + paths JSON)-> upload_id
|
||||
POST /tasks(upload_id 绑定 + idempotency_key 幂等)-> 任务排队
|
||||
GET /tasks/{id} 轮询终态(completed / failed / interrupted /
|
||||
@@ -8,6 +8,8 @@
|
||||
GET /tasks/{id}/trace 轨迹(探针的 trajectory 来源)
|
||||
GET /tasks/{id}/cost 成本(usd_micros / 1e6;无价 token 不计入 USD,
|
||||
这是现有 cost 模型的边界,不是本层漏算)
|
||||
GET /tasks/{id}/files 读本地任务 worktree 文件(产物资产端点;
|
||||
Docker/跨机任务回 409,见下)
|
||||
POST /tasks/{id}/cancel 超时取消,不留孤儿任务继续烧钱
|
||||
认证:X-API-Key 请求头;密钥只由调用方持有,本模块不读、不存、
|
||||
不打印密钥。
|
||||
@@ -18,12 +20,13 @@
|
||||
文件会覆盖仓库同名文件——这正是探针要的效果:同一任务在不同
|
||||
harness 下跑。prompt = 原任务 prompt + 一段交付脚注(见
|
||||
_PROBE_DELIVERABLE_FOOTER),因为评审只看最终回复:agent 必须
|
||||
把交付物完整写在最终回复里,不能只说“文件已写好”。这就是本层
|
||||
最大的适用边界:交付物是最终回复文本的任务;文件型产物(代码
|
||||
diff、报告文件)目前只能靠最终回复复述/摘要,worktree 快照 API
|
||||
只给 diff 截断与未跟踪文件列表、不给完整文件内容,跨主机还读
|
||||
不到——文件产物评审需要新增文件读取端点,另行评审,不在本层
|
||||
假装已解决。
|
||||
把交付物完整写在最终回复里,不能只说“文件已写好”。
|
||||
文件型产物走两层(产物资产):①本地任务经 GET /tasks/{id}/files
|
||||
读 worktree 新增/改动文件(端点为准);②所有执行器都可用
|
||||
回复里的 DELIVERABLE FILE 块(agent 自报,端点结果优先)。
|
||||
边界仍在:租户 Docker 下 /app/work 是整项目共享目录,按任务
|
||||
归属读文件会错指,端点对 Docker 任务一律 409——这时只有第
|
||||
②层可用,且内容以 agent 自报为准(可能截断,截断有标记)。
|
||||
- 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md /
|
||||
rollouts / deliverables / harness / library / pool / decisions)。
|
||||
agent 在 worktree 的 context/harness/ 里改文件,最终回复里用
|
||||
@@ -65,6 +68,7 @@ import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -77,6 +81,7 @@ from .harness2 import (
|
||||
Tree,
|
||||
improve_task,
|
||||
parse_choice,
|
||||
parse_deliverable_files,
|
||||
parse_experience,
|
||||
parse_harness_files,
|
||||
parse_no_change,
|
||||
@@ -185,6 +190,11 @@ 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.
|
||||
If you created deliverable files (a page, a report, an image manifest),
|
||||
also paste each file's full content in your final reply as a block:
|
||||
=== DELIVERABLE FILE: <relative path> ===
|
||||
<complete file content>
|
||||
=== END DELIVERABLE FILE ===
|
||||
"""
|
||||
|
||||
_EDITOR_OUTPUT_FORMAT = """
|
||||
@@ -278,6 +288,12 @@ class TaskClient(Protocol):
|
||||
"""轨迹渲染文本;读不到时返回空串(调用方用最终回复兜底)。"""
|
||||
...
|
||||
|
||||
def get_task_files(self, task_id: str, path: str = "") -> dict:
|
||||
"""GET /tasks/{id}/files:不传 path 列 worktree 文件,传 path
|
||||
读单个文件。服务端对 Docker/跨机任务回 409,调用方须把
|
||||
409 当成“无文件通道”、不得当成探针失败。"""
|
||||
...
|
||||
|
||||
def get_cost_usd(self, task_id: str) -> float:
|
||||
"""有价成本 USD;读不到时返回 0.0,不炸轮次。"""
|
||||
...
|
||||
@@ -452,6 +468,10 @@ class HttpTaskClient:
|
||||
rendered = "\n".join(lines).strip()
|
||||
return rendered[:TRACE_MAX_CHARS] if rendered else ""
|
||||
|
||||
def get_task_files(self, task_id: str, path: str = "") -> dict:
|
||||
q = f"?path={urllib.parse.quote(path)}" if path else ""
|
||||
return self._request("GET", f"/tasks/{task_id}/files{q}")
|
||||
|
||||
def get_cost_usd(self, task_id: str) -> float:
|
||||
try:
|
||||
obj = self._request("GET", f"/tasks/{task_id}/cost")
|
||||
@@ -596,6 +616,12 @@ class CodexProbeAdapter:
|
||||
error=f"{type(exc).__name__}: {exc}"[:400])
|
||||
state = done.state
|
||||
delivered = state.status == "completed" and bool(state.result_text.strip())
|
||||
deliverable_files = self._collect_deliverable_files(done.task_id, files)
|
||||
# 回复里的产物文件块是补充通道(所有执行器都可用);
|
||||
# 端点读到的文件为准,冲突时不覆盖端点结果。
|
||||
for name, content in parse_deliverable_files(
|
||||
state.result_text or "").items():
|
||||
deliverable_files.setdefault(name, content)
|
||||
return Rollout(
|
||||
label=label, ok=state.status == "completed", delivered=delivered,
|
||||
deliverable_text=state.result_text,
|
||||
@@ -603,8 +629,46 @@ class CodexProbeAdapter:
|
||||
cost_usd=done.cost_usd,
|
||||
error="" if state.status == "completed" else (
|
||||
state.error_text or f"task ended as {state.status}")[:400],
|
||||
deliverable_files=deliverable_files,
|
||||
)
|
||||
|
||||
def _collect_deliverable_files(self, task_id: str,
|
||||
seed_files: dict[str, bytes]) -> dict[str, str]:
|
||||
"""经 GET /tasks/{id}/files 读探针 worktree 里新增的文件。
|
||||
|
||||
取 seed 里没有的新文件,以及内容被改过的 seed 文件(与
|
||||
seed 字节比对,不一致才算产物);跳过隐藏文件、最多收
|
||||
8 个、最多读 16 个;端点不可用(Docker/跨机 409、读错)
|
||||
时返回空 dict——文件产物是 enrichment,绝不能因此炸轮次。
|
||||
"""
|
||||
try:
|
||||
listing = self.client.get_task_files(task_id)
|
||||
except Exception:
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
reads = 0
|
||||
for entry in listing.get("entries") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
path = entry.get("path") or ""
|
||||
if not path or path.startswith("."):
|
||||
continue
|
||||
if len(out) >= 8 or reads >= 16:
|
||||
break
|
||||
try:
|
||||
data = self.client.get_task_files(task_id, path)
|
||||
except Exception:
|
||||
continue
|
||||
reads += 1
|
||||
content = data.get("content")
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
seed = seed_files.get(path)
|
||||
if seed is not None and seed.decode("utf-8", errors="replace") == content:
|
||||
continue
|
||||
out[path] = content
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexEditor:
|
||||
@@ -773,6 +837,7 @@ def run_improvement_via_tasks(
|
||||
budget_usd: float | None = None,
|
||||
max_sessions: int | None = None,
|
||||
model: str | None = None,
|
||||
role_models: dict[str, str | None] | None = None,
|
||||
effort: str | None = None,
|
||||
approval: str | None = None,
|
||||
environment_id: str | None = None,
|
||||
@@ -785,22 +850,37 @@ def run_improvement_via_tasks(
|
||||
library_root 为空时不建经验库(只跑改进、不入库);非空时按
|
||||
domain 建库。auto_apply 恒为 False——采纳的树只入库待评审,
|
||||
不自动成为后续任务的 base(见 docs/harness2.md 安全一节)。
|
||||
|
||||
分角色模型:role_models 键为 probe / editor / selector /
|
||||
reflector,非空值覆盖该角色的 model;缺键或空值时回落到
|
||||
共享 model;两者都空时不传 model(服务端默认模型)。默认
|
||||
行为与此前完全一致——本参数只提供分流能力,不替用户选
|
||||
模型(有价模型会产生真实成本,见 docs/harness2.md)。
|
||||
"""
|
||||
from .harness2 import Library
|
||||
|
||||
roles = role_models or {}
|
||||
|
||||
def _role_model(role: str) -> str | None:
|
||||
return roles.get(role) or model
|
||||
|
||||
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,
|
||||
executor=executor, model=_role_model("probe"), 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,
|
||||
shared = dict(environment_id=environment_id, executor=executor,
|
||||
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)
|
||||
editor = CodexEditor(client=client, project_id=project_id, run_tag=run_tag,
|
||||
model=_role_model("editor"), **shared)
|
||||
selector = CodexSelector(client=client, project_id=project_id, run_tag=run_tag,
|
||||
model=_role_model("selector"), **shared)
|
||||
reflector = CodexReflector(client=client, project_id=project_id, run_tag=run_tag,
|
||||
model=_role_model("reflector"), **shared)
|
||||
library = Library(library_root, domain) if library_root is not None else None
|
||||
validate_run_tag(run_tag)
|
||||
return improve_task(
|
||||
@@ -809,10 +889,62 @@ def run_improvement_via_tasks(
|
||||
auto_apply=False, budget_usd=budget_usd, max_sessions=max_sessions)
|
||||
|
||||
|
||||
def run_sweep(client, *, project_id, runs, work_root, library_root=None):
|
||||
"""批量跑多轮改进:共享经验库、单轮失败隔离、汇总报告。
|
||||
|
||||
runs:每轮一个 dict,键为 run_improvement_via_tasks 的参数,
|
||||
run_tag 必填;client / project_id / work_dir / library_root
|
||||
由本函数统管,出现在 spec 里会被忽略(不报错)。每轮
|
||||
work_dir = work_root/<run_tag>。library_root 非空时所有轮
|
||||
共享同一库——已入库的经验会被后续轮读到,即上游 sweep 的
|
||||
library restore 语义;为空时各轮都不入库(仍汇总)。
|
||||
|
||||
单轮抛错只记入报告的 error,不中断后续轮(dead 隔离)。
|
||||
返回报告 dict:runs 逐轮(ok / chosen_label / deliverable
|
||||
前 500 字 / cost / library_entry / error)+ totals(轮数、
|
||||
成功轮数、总成本)。
|
||||
"""
|
||||
work_root = Path(work_root)
|
||||
report_runs = []
|
||||
total_cost = 0.0
|
||||
n_ok = 0
|
||||
for spec in runs:
|
||||
spec = dict(spec)
|
||||
run_tag = spec.pop("run_tag", "")
|
||||
for owned in ("client", "project_id", "work_dir", "library_root"):
|
||||
spec.pop(owned, None)
|
||||
entry = {"run_tag": run_tag, "ok": False, "chosen_label": None,
|
||||
"deliverable_text": "", "total_cost_usd": 0.0,
|
||||
"library_entry": None, "error": None}
|
||||
try:
|
||||
if not run_tag:
|
||||
raise ValueError("sweep 每轮必须给 run_tag(用作 work_dir 子目录)")
|
||||
validate_run_tag(run_tag)
|
||||
result = run_improvement_via_tasks(
|
||||
client, project_id=project_id, run_tag=run_tag,
|
||||
work_dir=work_root / run_tag,
|
||||
library_root=library_root, **spec)
|
||||
entry["ok"] = bool(result.ok)
|
||||
entry["chosen_label"] = result.selection.chosen_label
|
||||
chosen = result.mode_result.rollouts.get(result.selection.chosen_label)
|
||||
if chosen is not None:
|
||||
entry["deliverable_text"] = (chosen.deliverable_text or "")[:500]
|
||||
entry["total_cost_usd"] = result.mode_result.total_cost_usd
|
||||
entry["library_entry"] = result.library_entry
|
||||
total_cost += result.mode_result.total_cost_usd
|
||||
n_ok += 1 if result.ok else 0
|
||||
except Exception as exc:
|
||||
entry["error"] = f"{type(exc).__name__}: {exc}"[:400]
|
||||
report_runs.append(entry)
|
||||
return {"runs": report_runs,
|
||||
"totals": {"n_runs": len(report_runs), "n_ok": n_ok,
|
||||
"total_cost_usd": total_cost}}
|
||||
|
||||
|
||||
__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", "validate_run_tag", "preflight_checks",
|
||||
"run_improvement_via_tasks", "run_sweep", "validate_run_tag", "preflight_checks",
|
||||
]
|
||||
Reference in new issue
Block a user