diff --git a/cloud/harness2.py b/cloud/harness2.py new file mode 100644 index 0000000..fbc8655 --- /dev/null +++ b/cloud/harness2.py @@ -0,0 +1,1469 @@ +"""Harness² 式递归改进,融合进 codex-app-server 的方法层。 + +来源:Google Research 的 harness2(Apache 2.0,论文 arXiv:2610.14224), +只引它的方法,不引它的部署默认。本模块是按上游思想重写的适配实现, +不是把上游代码原样拷进来:接口按 codex-app-server 的任务/成本模型改过, +提示词是按本项目重写的短版,上游的长提示词、benchmark 适配器、OpenCode / +Claude CLI 通道都没有搬。 + +一句话:模型权重不动,只改模型周围的 harness(系统提示、技能、脚本、 +子代理、钩子),用“提议—探针—合成”循环拿行为证据说话,再把被采纳的 +经验写进分领域的经验库,供后面的任务参考。 + +核心循环(上游叫 Propose-Probe-Compose): + 1. Propose:对同一任务并行提两类候选 harness——INV(通用做法:流程、 + 检查、工具用法)与 DOM(领域做法:该行当的专业知识)。 + 2. Probe:每个候选 harness 都在同一任务上真实跑一次(探针),只看 + 行为对比,不看任何标准答案——没有标准答案可对,这是开世界任务 + 的前提。 + 3. Compose:合成器读全部探针轨迹,把有行为证据支撑的改动合成 + 一个终版 harness;没有证据的改动不进终版。 + +两种放量方式(上游的 parallel / sequential): + - parallel:一次提 2k 个候选,探针后合成 k 个终版,探得广; + - sequential:k 轮,每轮提 INV+DOM 各 1 个、合成一个检查点, + 下一轮在检查点上继续改,改得深。 + +选终版时只比“产物”:候选产物先匿名化(A/B/C……,按任务+k+模式 +定种子洗牌,可复现),选择器看不到哪个产物出自哪个 harness;选不 +出来时回落到已交付的终版,绝不抛异常把整轮炸掉。k=1 且没有选择 +器时直接走 trivial:唯一的终版就是答案,不必再选一次。 + +经验库(上游叫 harnessing-level recursion): + 采纳后,反思器只写一条有证据的经验(cue / lesson / evidence 三项 + 齐全才入库,缺一项就不存),连同被采纳 harness 的完整树一起存进 + //harnesses//,并重建 INDEX.md。下个同领域 + 任务的提议器能读到这些条目——但注意本项目的默认:入库不等于自动 + 生效(见下文安全偏差)。 + +与上游刻意不同的地方(都是为了不破坏本项目已有的安全与计费): + 1. 不抄上游的执行默认。上游 README 明写:执行器网络不受限, + Codex 以 sandbox 禁用方式跑。本模块不跑任何命令、不拼任何 + codex exec 参数;执行全部走调用方提供的 adapter,而本项目的 + 真实 adapter 必须走 Worker 已有的 Docker / gVisor 沙箱、租户 + 隔离与 egress 白名单,绝不加 --dangerously-bypass-*。 + 2. 不自动把采纳的 harness 铺到别的任务。codex-app-server 是多租户 + 服务,一个编辑器写出的 AGENTS.md 自动改写别人的任务既是质量 + 风险也是越权风险。所以 library 默认只是“待评审的经验”:只有 + 项目显式开启 auto_apply(后续服务端接线时再加开关,默认关), + 下个任务的 base harness 才会从库里取最新采纳树;默认情况下 + base 永远是项目自己的 base harness,库只给提议器当参考。 + 3. 不引 benchmark。任务就是 codex-app-server 里一个普通任务 + (prompt + 产物),没有 JobBench / LAB / WorkBuddy 适配器, + 没有评分器、没有 rubric;delivered 的定义由 adapter 说了算 + (例如“产物文件存在且非空”),选择器只按产物本身判断。 + 4. 成本有上限。k 硬上限 3(parallel 一轮最多 1 个 base + 2k 候选 + + k 终版 = 10 次执行;sequential 最多 1 + 3k = 10 次),另有 + 可选的 budget_usd:超支后编辑器不再被调用、探针不再执行, + 剩余阶段直接回落到当前 harness,采纳结果也不入库——超支的 + 轮次不配进经验库。 + 5. 编辑器/选择器/反思器不是本模块内置的模型调用,而是调用方注入 + 的三个协议(Editor / Selector / Reflector)。真实接线见 + cloud/harness2_tasks.py:它们各自是一个 codex-app-server 任务, + prompt 就是本模块生成的 mission,证据以附件 seed 进任务 + worktree(context/ 前缀),编辑器把改动的文件以文件块形式写 + 在最终回复里由接线层解析回树;选择器与反思器同理,只解析 + 最终回复,不读 worktree 文件(跨主机读不到,见 docs/harness2.md)。 + +可编辑面(上游 codex substrate 的 surface,原样照搬): + - AGENTS.md:常驻文件,每回合都注入,硬上限 8000 字符(上游 + 注明 codex 还会在 32768 处静默截断,8000 是留足余量的做法)。 + - .agents/skills//SKILL.md:技能,frontmatter 必须有 + description,否则加载时会被静默丢弃;且必须在 AGENTS.md 里 + 有触发行,否则技能永远不会被调用(本模块会自动补触发行, + 补不下——会超 8000 上限——时跳过,不硬塞)。 + - sub_agents//AGENT.md:子代理角色,同样需要 description。 + - scripts/*.py:确定性辅助脚本,必须能通过 compile 检查; + AGENTS.md / 技能里引用的脚本必须真实存在于树里。 + - .codex/hooks.json:生命周期钩子,只接受框架实际派发的事件名, + 且钩子必须是 command 类型、命令非空(格式不对的钩子在框架侧 + 是静默不触发,比报错更危险,所以在提议阶段就拦住)。 + 不在上述白名单里的路径一律不读、不写、不采纳;想逃出树外的 + 路径(..、绝对路径)在规范化时直接拒绝。 + +用法(确定性演示见 examples/harness2_demo.py,不花模型钱): + result = improve_task(adapter, editor, selector, reflector, + work_dir=Path("runs/demo"), k=1, mode="parallel") +""" + +from __future__ import annotations + +import hashlib +import json +import random +import re +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol, Sequence + +# --------------------------------------------------------------------------- +# 可编辑面:codex substrate +# --------------------------------------------------------------------------- + +SKILL_DIR = ".agents/skills" +SUBAGENT_DIR = "sub_agents" +SCRIPT_DIR = "scripts" +HOOKS_FILE = ".codex/hooks.json" +ALWAYS_ON = ("AGENTS.md",) +ALWAYS_ON_MAX = 8000 +HOOK_EVENTS = frozenset( + { + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "PreCompact", + "PostCompact", + "SessionStart", + "SubagentStart", + "SubagentStop", + "UserPromptSubmit", + "Stop", + } +) + +_NAME = r"[a-z0-9][a-z0-9_\-]*" +_SKILL_RX = re.compile(rf"{re.escape(SKILL_DIR)}/({_NAME})/SKILL\.md") +_SKILL_SCRIPT_RX = re.compile(rf"{re.escape(SKILL_DIR)}/{_NAME}/scripts/[A-Za-z0-9_.\-]+") +_SUBAGENT_RX = re.compile(rf"{SUBAGENT_DIR}/({_NAME})/AGENT\.md") +_SCRIPT_RX = re.compile(r"scripts/[a-z0-9_][a-z0-9_]*\.py") +_SCRIPT_REF_RX = re.compile(rf"(?:python3?\s+)((?:{re.escape(SKILL_DIR)}/[\w\-]+/)?scripts/[\w.\-]+\.py)") +_CHOICE_RX = re.compile(r"```choice\s*\n?\s*([A-Za-z0-9_-]+)\s*\n?\s*```", re.S) +_WHY_RX = re.compile(r"(.*?)", re.S) +_NOTES_RX = re.compile(r"(.*?)", re.S) +_NO_CHANGE_RX = re.compile(r"(.*?)", re.S) +_EXP_RX = re.compile(r"(.*?)", re.S) +_EDITS_RX = re.compile(r"(.*?)", re.S) + +Tree = dict[str, str] + +# 编辑任务最终回复里的文件块格式(接线层 cloud/harness2_tasks.py 与 +# 编辑任务的 prompt 共用这一格式,改一处就要同步另一处): +# === HARNESS FILE: <白名单内的相对路径> === +# <该文件的完整内容> +# === END HARNESS FILE === +# 只输出改动的文件,没改的文件省略即继承(上游同款“省略即继承” +# 契约)。文件内容里若恰好有整行 === END HARNESS FILE === 会被 +# 提前截断——极罕见,校验与人工复核是兜底,见 docs/harness2.md。 +_HARNESS_FILE_START_RX = re.compile(r"^=== HARNESS FILE:\s*(.+?)\s*===$") +_HARNESS_FILE_END = "=== END HARNESS FILE ===" + + +def parse_choice(text: str) -> str | None: + """从选择器最终回复里取 ```choice 块中的匿名 id;没有返回 None。""" + m = _CHOICE_RX.search(text or "") + return m.group(1).strip() if m else None + + +def parse_why(text: str) -> str: + m = _WHY_RX.search(text or "") + return m.group(1).strip() if m else "" + + +def parse_notes(text: str) -> str: + m = _NOTES_RX.search(text or "") + return m.group(1).strip() if m else "" + + +def parse_no_change(text: str) -> str: + """从编辑任务最终回复里取 标记中的理由;没有返回 ""。 + + 2026-10-29 两轮真实轮次的教训:编辑在 里声称“已修 + 正”,却一个文件块都没发,旧契约把这种 notes-only 回复当成 + “无需改动”采纳,导致改动无声丢失。从本版起,无改动必须用 + 这个显式标记表态,notes-only 不再算成功(见接线层 edit 的 + 格式重试);标记与文件块同时出现属于自相矛盾,同样算格式错。 + """ + m = _NO_CHANGE_RX.search(text or "") + return m.group(1).strip() if m else "" + + +def parse_experience(text: str) -> dict: + """从反思器最终回复里取 与 。 + + cue / lesson / evidence 三项不全,或回复写明 NO_ENTRY 时返回 {}, + 表示没有可入库的经验——调用方不得把半条经验写进库。 + """ + text = text or "" + if "NO_ENTRY" in text: + return {} + m = _EXP_RX.search(text) + if not m: + return {} + out: dict = {} + for line in m.group(1).strip().splitlines(): + if ":" in line: + key, _, val = line.partition(":") + key = key.strip().lower() + if key in ("cue", "lesson", "evidence", "confidence"): + out[key] = val.strip() + if not all(out.get(k) for k in ("cue", "lesson", "evidence")): + return {} + edits = _EDITS_RX.search(text) + if edits: + out["edits"] = edits.group(1).strip() + return out + + +def parse_harness_files(text: str) -> dict[str, str]: + """解析编辑任务最终回复里的文件块,返回 {相对路径: 完整内容}。 + + 路径不在可编辑白名单上时直接抛 ValueError——接线层把这当成 + 该阶段失败(重试后回落),绝不静默丢掉一块就当没这回事。 + 同一路径出现两次时后一块覆盖前一块,与最终回复“最后一次 + 表态为准”的直觉一致。 + """ + out: dict[str, str] = {} + cur_path: str | None = None + cur_lines: list[str] = [] + for line in (text or "").splitlines(): + m = _HARNESS_FILE_START_RX.match(line.strip()) + if m: + if cur_path is not None: + raise ValueError(f"harness file block for {cur_path!r} was not closed") + cur_path = m.group(1).strip() + if not is_allowed(cur_path): + raise ValueError(f"harness file path is off the editable surface: {cur_path!r}") + cur_lines = [] + continue + if line.strip() == _HARNESS_FILE_END: + if cur_path is None: + raise ValueError("stray === END HARNESS FILE === without a start line") + out[cur_path] = "\n".join(cur_lines) + ("\n" if cur_lines else "") + cur_path = None + cur_lines = [] + continue + if cur_path is not None: + cur_lines.append(line) + if cur_path is not None: + raise ValueError(f"harness file block for {cur_path!r} was not closed") + return out + + +MAX_K = 3 +STAGE_ATTEMPTS = 2 # 提议被拒后给一次按反馈修正的机会,再拒就回落 + + +def normalize_rel(rel: str) -> str: + """规范化树内相对路径;逃出树外的路径直接拒绝。""" + r = str(rel).strip().replace("\\", "/") + if r.startswith("/") or ".." in r.split("/"): + raise ValueError(f"path escapes the harness: {rel!r}") + while r.startswith("./"): + r = r[2:] + return r.strip("/") + + +def is_allowed(rel: str) -> bool: + """该路径是否在可编辑白名单上(永不抛异常,只回答是/否)。""" + try: + rel = normalize_rel(rel) + except ValueError: + return False + if rel in ALWAYS_ON or rel == HOOKS_FILE: + return True + return bool( + _SKILL_RX.fullmatch(rel) + or _SKILL_SCRIPT_RX.fullmatch(rel) + or _SUBAGENT_RX.fullmatch(rel) + or _SCRIPT_RX.fullmatch(rel) + ) + + +def skill_names(tree: Tree) -> list[str]: + return sorted({m.group(1) for rel in tree if (m := _SKILL_RX.fullmatch(rel))}) + + +def subagent_names(tree: Tree) -> list[str]: + return sorted({m.group(1) for rel in tree if (m := _SUBAGENT_RX.fullmatch(rel))}) + + +def always_on_text(tree: Tree) -> str: + return "\n\n".join(tree.get(a, "") for a in ALWAYS_ON) + + +def read_tree(d: Path | str, *, rejected: list[str] | None = None) -> Tree: + """读一个目录里的可编辑树;白名单外的文件记入 rejected,不读进来。""" + d = Path(d) + out: Tree = {} + if not d.is_dir(): + return out + for p in sorted(d.rglob("*")): + if not p.is_file() or "__pycache__" in p.parts: + continue + rel = str(p.relative_to(d)) + if not is_allowed(rel): + if rejected is not None: + rejected.append(rel) + continue + out[rel] = p.read_text(encoding="utf-8", errors="replace") + return out + + +def write_tree(tree: Tree, dest: Path | str) -> Path: + """把一棵树原样落盘:先清掉目标里旧的可编辑文件,再写新文件。 + + 目标里白名单外的文件不动——harness 目录里可能还放着任务产物, + 不能因为换 harness 就删掉它们。 + """ + dest = Path(dest) + dest.mkdir(parents=True, exist_ok=True) + for p in sorted(dest.rglob("*"), reverse=True): + if p.is_file() and is_allowed(str(p.relative_to(dest))): + p.unlink() + for rel, content in tree.items(): + p = dest / normalize_rel(rel) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return dest + + +def fingerprint(tree: Tree) -> str: + """一棵树的摘要:排序后的 路径\\0内容 拼起来算 sha256,前 16 位。""" + h = hashlib.sha256() + for rel in sorted(tree): + h.update(rel.encode()) + h.update(b"\0") + h.update(tree[rel].encode("utf-8", errors="replace")) + h.update(b"\0") + return h.hexdigest()[:16] + + +def autotrigger(tree: Tree) -> list[str]: + """给每个技能在 AGENTS.md 里补触发行;会超 8000 上限的行跳过。 + + 返回补了触发行的技能名。触发行的描述取技能 frontmatter 的 + description 第一行,取不到时用通用措辞。 + """ + fixed: list[str] = [] + for name in skill_names(tree): + if name in always_on_text(tree): + continue + fm = tree.get(f"{SKILL_DIR}/{name}/SKILL.md", "")[:600] + m = re.search(r"^description:\s*(.+)$", fm, re.M) + desc = m.group(1).strip().rstrip(".") if m else f"the {name} workflow applies" + line = f"- When {desc}, read {SKILL_DIR}/{name}/SKILL.md and follow it.\n" + if len(always_on_text(tree)) + len(line) + 2 > ALWAYS_ON_MAX: + continue + cur = tree.get("AGENTS.md", "") + tree["AGENTS.md"] = (cur.rstrip() + "\n" + line) if cur.strip() else line + fixed.append(name) + return fixed + + +def _check_hooks(src: str, errs: list[str]) -> None: + try: + doc = json.loads(src) + except json.JSONDecodeError as exc: + errs.append(f"{HOOKS_FILE} is not valid JSON: {exc}") + return + hooks = doc.get("hooks") if isinstance(doc, dict) else None + if not isinstance(hooks, dict) or not hooks: + errs.append(f'{HOOKS_FILE} needs a top-level {{"hooks": {{"": [...]}}}} object') + return + for event, entries in hooks.items(): + if event not in HOOK_EVENTS: + errs.append(f"{HOOKS_FILE}: unknown event {event!r}; dispatched events are " + f"{', '.join(sorted(HOOK_EVENTS))} (PascalCase)") + continue + if not isinstance(entries, list): + errs.append(f"{HOOKS_FILE}: {event} must map to a LIST of matcher entries") + continue + for i, entry in enumerate(entries): + if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list): + errs.append(f"{HOOKS_FILE}: {event}[{i}] needs a `hooks` list") + continue + for h in entry["hooks"]: + if not isinstance(h, dict) or h.get("type") != "command": + errs.append(f'{HOOKS_FILE}: {event}[{i}] hook type must be "command"') + elif not str(h.get("command", "")).strip(): + errs.append(f"{HOOKS_FILE}: {event}[{i}] has an empty `command`") + + +def validate(tree: Tree, *, reserved_scripts: frozenset[str] = frozenset()) -> dict: + """校验一棵树;有问题就抛 ValueError,把全部问题一次列清。 + + 返回值是树的普查(文件数、技能数、子代理数、脚本数、常驻字符数), + 供日志与测试断言用。校验失败时不返回普查——调用方应按“提议被拒” + 处理,而不是拿半棵树继续跑。 + """ + errs: list[str] = [] + body = always_on_text(tree) + if len(body) > ALWAYS_ON_MAX: + errs.append(f"always-on content is {len(body)} chars, over the {ALWAYS_ON_MAX} cap; " + "AGENTS.md is injected whole into EVERY turn") + if not body.strip(): + errs.append("AGENTS.md is empty — it is the only always-on file, " + "so an empty one is a harness that does not exist") + for name in skill_names(tree): + fm = tree[f"{SKILL_DIR}/{name}/SKILL.md"][:600] + if not re.search(r"^description:\s*\S", fm, re.M): + errs.append(f"{SKILL_DIR}/{name}/SKILL.md needs a `description:` in its " + "frontmatter, or the skill is dropped SILENTLY at load") + m_name = re.search(r"^name:\s*(\S+)\s*$", fm, re.M) + if m_name and m_name.group(1) != name: + errs.append(f"{SKILL_DIR}/{name}/SKILL.md frontmatter `name: " + f"{m_name.group(1)}` OVERRIDES the directory name at load, so " + f"every trigger line naming `{name}` stops matching") + if name not in body: + errs.append(f"skill '{name}' is never named in the always-on text, so the " + "agent will not know to invoke it — an untriggered skill is " + "dead weight") + for name in subagent_names(tree): + fm = tree[f"{SUBAGENT_DIR}/{name}/AGENT.md"][:600] + if not re.search(r"^description:\s*\S", fm, re.M): + errs.append(f"{SUBAGENT_DIR}/{name}/AGENT.md needs a `description:`") + for rel, src in tree.items(): + if _SCRIPT_RX.fullmatch(rel) or ( + _SKILL_SCRIPT_RX.fullmatch(rel) and rel.endswith(".py") + ): + try: + compile(src, rel, "exec") + except SyntaxError as exc: + errs.append(f"{rel} does not compile: {exc.msg} (line {exc.lineno})") + if HOOKS_FILE in tree: + _check_hooks(tree[HOOKS_FILE], errs) + have = {rel for rel in tree if _SCRIPT_RX.fullmatch(rel) or _SKILL_SCRIPT_RX.fullmatch(rel)} + for rel, src in tree.items(): + if not (_SKILL_RX.fullmatch(rel) or rel in ALWAYS_ON): + continue + for ref in _SCRIPT_REF_RX.findall(src): + if ref.rsplit("/", 1)[-1] in reserved_scripts: + continue + if ref not in have: + errs.append(f"{rel} invokes `{ref}` but that file is not in the harness") + if errs: + raise ValueError(" | ".join(errs)) + return { + "always_on_chars": len(body), + "n_files": len(tree), + "skills": len(skill_names(tree)), + "sub_agents": len(subagent_names(tree)), + "scripts": sum(1 for rel in tree if _SCRIPT_RX.fullmatch(rel)), + "hooks": 1 if HOOKS_FILE in tree else 0, + } + + +def diff_summary(before: Tree, after: Tree) -> dict: + return { + "added": sorted(set(after) - set(before)), + "removed": sorted(set(before) - set(after)), + "modified": sorted(rel for rel in set(before) & set(after) if before[rel] != after[rel]), + "always_on_char_delta": len(always_on_text(after)) - len(always_on_text(before)), + } + + +def diversity(trees: dict[str, Tree]) -> dict: + """候选树之间的两两行 Jaccard 相似度:全塌成同一棵树时能看出来。""" + names = sorted(trees) + if len(names) < 2: + return {"n": len(names), "n_distinct": len(names), "mean_line_jaccard": 1.0} + + def lines(t: Tree) -> set[str]: + return {f"{rel}:{ln}" for rel, c in t.items() for ln in c.splitlines() if ln.strip()} + + sets = {n: lines(trees[n]) for n in names} + sims = [] + for i, a in enumerate(names): + for b in names[i + 1:]: + u = sets[a] | sets[b] + sims.append(len(sets[a] & sets[b]) / len(u) if u else 1.0) + return { + "n": len(names), + "n_distinct": len({tuple(sorted(t.items())) for t in trees.values()}), + "mean_line_jaccard": round(sum(sims) / len(sims), 4), + } + + +# --------------------------------------------------------------------------- +# 协议:执行 / 编辑 / 选择 / 反思,全部由调用方注入 +# --------------------------------------------------------------------------- + + +@dataclass +class Rollout: + label: str + ok: bool + delivered: bool + deliverable_text: str = "" + trajectory: str = "" + cost_usd: float = 0.0 + error: str = "" + + +@dataclass +class EditResult: + """一次编辑会话的结果。tree 为 None 时,调用方应从 context_dir/harness + 里把编辑器改完的树读回(真实 codex 任务接线就是这个方式)。""" + + tree: Tree | None + notes: str = "" + cost_usd: float = 0.0 + cached: bool = False + + +class TaskAdapter(Protocol): + """一个任务的执行面:base harness 从哪来、一次探针怎么跑。""" + + task_id: str + domain: str + prompt: str + + def base_harness(self) -> Tree: + """未改动的 base harness。""" + ... + + def execute(self, label: str, harness: Tree) -> Rollout: + """用给定 harness 跑一次任务;失败也返回 Rollout,永不抛异常 + (抛了也由 _roll 兜住,记为一次死探针)。""" + ... + + def validate_tree(self, harness: Tree) -> None: + """项目特有的额外不变式;默认什么都不拦。""" + ... + + +class Editor(Protocol): + def edit(self, mission: str, context_dir: Path, label: str) -> EditResult: + """读 context_dir 里的证据(task.md / rollouts / deliverables / + harness / library),改 context/harness/,返回改完的树。""" + ... + + +class Selector(Protocol): + def select(self, mission: str, context_dir: Path) -> tuple[str, str, float]: + """返回 (匿名候选 id, 选择理由, 花费 USD)。id 必须是 mission 里 + 列出的 A/B/C…… 之一,否则按选择失败回落。""" + ... + + +class Reflector(Protocol): + def reflect(self, mission: str, context_dir: Path) -> dict: + """返回经验 dict,键至少含 cue / lesson / evidence,可含 + confidence / edits;三项不全时返回 {} 表示没有可入库的经验。""" + ... + + +def _adapter_validate(adapter: TaskAdapter, tree: Tree) -> None: + fn = getattr(adapter, "validate_tree", None) + if callable(fn): + fn(tree) + + +# --------------------------------------------------------------------------- +# 证据上下文:给模型看的文件,而不是把证据塞进 prompt +# --------------------------------------------------------------------------- + + +def build_context( + root: Path, + *, + task_prompt: str, + rollouts: Sequence[Rollout], + harness: Tree, + library_dir: Path | None = None, + pool: Sequence[tuple[str, Path, Rollout | None]] = (), + decisions: dict[str, str] | None = None, +) -> str: + """在 root 下建 context/ 证据树,返回 mission 里用的布局说明。 + + 上游的关键做法照搬:证据以文件形式给,prompt 只写指针,这样 + 证据不会为了塞进 prompt 而被截断——截断过的轨迹会编造缺陷。 + """ + root = Path(root) + root.mkdir(parents=True, exist_ok=True) + (root / "task.md").write_text(task_prompt, encoding="utf-8") + lines = ["context/task.md the task as the agent received it"] + if rollouts: + d = root / "rollouts" + d.mkdir(parents=True, exist_ok=True) + for r in rollouts: + (d / f"{r.label}.md").write_text(r.trajectory or "(no trajectory)", encoding="utf-8") + names = ", ".join(f"{r.label}.md" for r in rollouts[:6]) + more = f" (+{len(rollouts) - 6} more)" if len(rollouts) > 6 else "" + lines.append(f"context/rollouts/ FULL trajectories: {names}{more}") + dd = root / "deliverables" + dd.mkdir(parents=True, exist_ok=True) + for r in rollouts: + (dd / f"{r.label}.md").write_text( + r.deliverable_text or "(no deliverable)", encoding="utf-8" + ) + lines.append("context/deliverables/ what each rollout produced") + 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(): + shutil.copytree(library_dir, root / "library", dirs_exist_ok=True) + lines.append("context/library/ earlier tasks in THIS domain + their harnesses") + if pool: + pd = root / "pool" + pd.mkdir(parents=True, exist_ok=True) + for label, hdir, rv in pool: + dest = pd / label + dest.mkdir(parents=True, exist_ok=True) + if Path(hdir).is_dir(): + shutil.copytree(hdir, dest / "harness", dirs_exist_ok=True) + if rv is not None: + (dest / "rollout.md").write_text(rv.trajectory or "", encoding="utf-8") + (dest / "deliverable.md").write_text(rv.deliverable_text or "", encoding="utf-8") + lines.append("context/pool/