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:
host committed 2026-10-29 16:32:58 +08:00
1 parent 9e444eca38
commit c20192bce8
8 files changed
+856 -28

No files matched your search

+58 -1
View File
@@ -241,6 +241,56 @@ def parse_harness_files(text: str) -> dict[str, str]:
return out
_DELIVERABLE_FILE_START_RX = re.compile(r"^=== DELIVERABLE FILE:\s*(.+?)\s*===$")
_DELIVERABLE_FILE_END = "=== END DELIVERABLE FILE ==="
_DELIVERABLE_FILE_MAX = 8
_DELIVERABLE_FILE_MAX_CHARS = 65536
def _is_safe_deliverable_path(path: str) -> bool:
if not path or path.startswith("/") or "\x00" in path:
return False
return all(p not in ("", ".", "..") for p in path.split("/"))
def parse_deliverable_files(text: str) -> dict[str, str]:
"""解析探针回复里的产物文件块,返回 {相对路径: 内容}。
与编辑文件块的严格语义不同:这里故意宽容——坏块(未闭合、
非法路径、超量、超长)只丢弃该块、不抛错,因为产物文件是
交付文本之外的 enrichment,绝不能因为一块坏内容毁掉整轮
交付物。超长内容截断并打截断标记,让评审知道它不完整;
超过 8 块时只取前 8 块。同一路径出现两次时后一块覆盖前一块。
"""
out: dict[str, str] = {}
cur_path: str | None = None
cur_lines: list[str] = []
for line in (text or "").splitlines():
m = _DELIVERABLE_FILE_START_RX.match(line.strip())
if m:
if cur_path is not None:
cur_path = None
cur_lines = []
path = m.group(1).strip()
if _is_safe_deliverable_path(path) and len(out) < _DELIVERABLE_FILE_MAX:
cur_path = path
cur_lines = []
continue
if line.strip() == _DELIVERABLE_FILE_END:
if cur_path is not None:
content = "\n".join(cur_lines) + ("\n" if cur_lines else "")
if len(content) > _DELIVERABLE_FILE_MAX_CHARS:
content = (content[:_DELIVERABLE_FILE_MAX_CHARS]
+ "\n...[truncated]")
out[cur_path] = content
cur_path = None
cur_lines = []
continue
if cur_path is not None:
cur_lines.append(line)
return out
MAX_K = 3
STAGE_ATTEMPTS = 2 # 提议被拒后给一次按反馈修正的机会,再拒就回落
@@ -490,6 +540,7 @@ class Rollout:
trajectory: str = ""
cost_usd: float = 0.0
error: str = ""
deliverable_files: dict[str, str] = field(default_factory=dict)
@dataclass
@@ -589,7 +640,12 @@ def build_context(
(dd / f"{r.label}.md").write_text(
r.deliverable_text or "(no deliverable)", encoding="utf-8"
)
lines.append("context/deliverables/ what each rollout produced")
for name, content in (r.deliverable_files or {}).items():
dest = dd / r.label / "files" / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
lines.append("context/deliverables/ what each rollout produced"
" (+ <label>/files/ when it produced files)")
write_tree(harness, root / "harness")
lines.append("context/harness/ the harness tree you may edit, and ONLY this")
if library_dir and Path(library_dir).is_dir():
@@ -842,6 +898,7 @@ class ModeResult:
"delivered": r.delivered,
"error": (r.error or "")[:400],
"deliverable_text": (r.deliverable_text or "")[:500],
"deliverable_files": sorted(r.deliverable_files or {}),
}
for lbl, r in self.rollouts.items()
},