feat(cheap-gen): 死圈修复 fix①-A 增量 edit 工具 + fix②-prompt 克隆纪律/check-early
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

死圈根因(双评审坐实):产物带小红线违规→check FAIL→agent 想修但只有整文件覆盖的
write_file→改3行变重写22KB→MiniMax 漏 path 连撞→stuck 熔断→九门没跑→ok=False。

fix①-A:cheap_run.edit_file(读现文件→校验 old 唯一命中→精确替换→经 write_file
回写继承 L1_FIXED/game_dir 保护)+ cheap_toolkit edit_file 工具(whitelist 把关)注册进
七工具面;edit_file 加入软停 blocked-tools(middleware.py,越软停线同 write_file 拦)。
edit 自身失败面(old 未找到/不唯一)给结构化提示。6 单测(命中/未找到/不唯一/L1拒改/
越界/缺文件)+ 现有 toolkit 8 测全绿。

fix②-prompt(cheap_roles.py):① 工具清单纠正为七工具、明确 edit_file 小改优先(修
之前"没有 edit_file"的错误引导——否则 agent 不会用新工具);② 克隆纪律"rng/nowMs 照
_template 逐字克隆、禁自造 Math.random/Date.now 防御回退、check 函数内裸调照拦";
③ check-early"先写最小核心→立即 check→edit_file 修红线→再扩玩法"。

待续(本计划剩项):fix②-check 报错行号(tools.mjs)、fix③ brief 落 evidence、
fix①-B 共享 tier2 熔断窄口径纠偏(最险另做)、n=5 收敛环(ok=True 判据)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-08 01:46:22 -07:00
parent d9660c8ef1
commit 596ea13666
5 changed files with 155 additions and 7 deletions

View File

@ -91,9 +91,10 @@ A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4
- 判据 = check PASS + build PASS本产线**不跑** README 里的 node --test**check build PASS 过一遍下条收尾自查随即调 finish**summary 一句话自查是 30 秒对照不是无限打磨的许可
- **收尾自查finish 前对照设计注释块念一遍** 判定句的三个空是否真映射到代码路径判断点与代价都在逻辑里不只在注释里 的三段难度是否落在数值上结算/暴击时刻的 juice 反馈是否在**check/build 只是地板**好玩以 10 条自检与品类 rubric 为准别拿能跑当完成线
- **别做**别写/改任何 test/ 文件别写额外脚本别加 brief/README 没要求的东西
- **只有** read_file / write_file / list_dir / check / build / finish 六个工具**没有 edit_file** 改文件用 write_file 整体覆盖
- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具**小改用 edit_file**改一个函数几行 `path` + 精确复制文件原文当 `old` + `new`参数短不易漏字段**整文件才用 write_file**修一条红线改一个数值这类小修**一律优先 edit_file别动辄整文件重写**整文件重写在弱模型上易漏 `path` 参数连撞几次会被熔断打断edit_file `old` 必须逐字节匹配且唯一找不到/不唯一它会明确报错按提示加长 old 再试
- **rng/nowMs `_template` 起点逐字克隆**起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`裸回退给 `0`**照抄绝不自造 `Math.random()`/`Date.now()` 防御回退**check 按去注释后的纯文本正则命中**把裸调封装进函数内部照样被拦**别信"函数里就不拦"需要时间源走 `ctx.time.nowMs()`别裸 `Date.now()`
现在开始** read_file 读手册别直接写码核心玩法实现完check+build 绿了收尾自查过了就 finish**"""
现在开始** read_file 读手册与起点 game-logic.js别直接写码先写一版最小可玩核心 立即 check趁文件小红线好定位好小修 edit_file 修到 PASS 再扩玩法**核心玩法实现完check+build 绿了收尾自查过了就 finish"""
# 默认脚手架描述create 路 = 通用 _template「点圆得分」起点。扩模板per-genre 黄金骨架传各自描述覆盖,

View File

@ -166,6 +166,44 @@ def write_file(game_id: str, path: str, content: str) -> dict:
return {"ok": False, "error": str(e)}
def edit_file(game_id: str, path: str, old: str, new: str) -> dict:
"""edit_file增量修改——把 game 目录内某 L3 文件里出现一次的 old 串替换成 new避免"改三行→重写整文件"
死圈修复 fix①-A2026-07-08 双评审便宜档六工具原本只有整文件覆盖的 write_file
"改一个 rng 小函数"被迫变成"重写 22KB"MiniMax 在超长 tool-call 参数上反复漏 path 触发熔断
edit_file 让小修表达为短参数替换天然绕开漏参高发区
边界继承读现文件走 game_dir 内解析不走 read_file 的知识根 shadow要改的是工程盘上的真文件
回写复用 write_file继承 L1_FIXED 拒写 + games/amgen-<id>/ 越界拒写write_whitelist toolkit 层把守
old 必须在文件里唯一命中0 =没找到>1 =不唯一否则拒改并给结构化提示edit 自身失败面纳入纠偏
"""
try:
abs_p = _resolve_in_repo(path)
gd = game_dir(game_id).resolve()
if abs_p != gd and gd not in abs_p.parents:
return {"ok": False, "error": f"edit 越界:只许改 games/amgen-{game_id}/ 内(你给的是 {path}"}
if not abs_p.is_file():
return {"ok": False, "error": f"文件不存在,无法 edit{path}(先用 write_file 建它,再 edit 增量改)"}
content = abs_p.read_text(encoding="utf-8")
cnt = content.count(old)
if cnt == 0:
return {"ok": False, "error": (
"edit 未找到待替换串 old逐字节精确匹配含缩进/换行)。"
"请复制文件里的原文当 old可 read_file 核对),或改用 write_file 整体覆盖。")}
if cnt > 1:
return {"ok": False, "error": (
f"edit 的 old 串在文件里出现 {cnt} 次、不唯一,无法定位。"
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
new_content = content.replace(old, new, 1)
# 回写经 write_file继承 L1_FIXED 拒写 + game_dir 越界拒写edit 不新开绕过面)。
r = write_file(game_id, path, new_content)
if r.get("ok"):
r["message"] = "edit 已应用(替换 1 处)"
return r
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
# ───────────────────────── check / buildshell-out node tools.mjs─────────────────────────
def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess:

View File

@ -81,6 +81,27 @@ def build_toolkit(session: CheapSession, *, write_whitelist=None):
r = cheap_run.write_file(session.game_id, path, content)
return f"OK {r['bytes']}B 已写" if r["ok"] else "ERROR: " + r["error"]
async def edit_file(path: str, old: str, new: str) -> str:
"""增量改文件:把文件里出现一次的 old 串换成 new。改小处如一个函数几行用它别整文件重写。
old 必须逐字节精确匹配文件原文含缩进/换行且唯一命中找不到/不唯一会明确报错按提示加长 old 再试
边界同 write_file只许改 games/amgen-<id>/ L3 游戏文件L1 plumbing 拒改
Args:
path: game-runtime/games/amgen-<id>/src/game-logic.js
old: 要被替换的原文片段复制文件里的原样含缩进
new: 替换成的新片段
"""
# 写边界白名单:与 write_file 同口径M4"只改玩法"时非 game-logic.js 一律拒)。
if write_whitelist is not None:
base = path.rsplit("/", 1)[-1]
if base not in write_whitelist:
allowed = "/".join(sorted(write_whitelist))
return (f"ERROR: 本次是「只改玩法」的模块重生成,只许改 {allowed}"
f"{base} 属其余文件,要保持不变、不要改它。")
r = cheap_run.edit_file(session.game_id, path, old, new)
return r.get("message", "OK edit 已应用") if r["ok"] else "ERROR: " + r["error"]
async def check() -> str:
"""循环内快校验node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。"""
session.last_check = cheap_run.check(session.game_id)
@ -117,6 +138,6 @@ def build_toolkit(session: CheapSession, *, write_whitelist=None):
tools = [
FunctionTool(read_file), FunctionTool(list_dir), FunctionTool(write_file),
FunctionTool(check), FunctionTool(build), FunctionTool(finish),
FunctionTool(edit_file), FunctionTool(check), FunctionTool(build), FunctionTool(finish),
]
return Toolkit(tools=tools)

View File

@ -0,0 +1,88 @@
"""test_edit_file_toolkit.py — fix①-A 增量 edit 工具单测(死圈修复 2026-07-08 双评审)。
守的不变量:
· edit_file 命中唯一 old 替换 1 其余不动;
· old 未找到(0 )/ 不唯一(>1 ) 明确报错(edit 自身失败面,给结构化提示纳入纠偏覆盖);
· 回写继承 write_file L1_FIXED 拒改( host-config.js plumbing 被拒) game_dir 越界拒改;
· 文件不存在时 edit 拒改并提示先 write_file
用真 games/ 下临时 game_dir(amgen-editfixtest*)测真实 _resolve_in_repo + L1 路径,teardown 清理,不碰真产物
:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_edit_file_toolkit.py -q
"""
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run # noqa: E402
import pytest # noqa: E402
_GID = "editfixtest001"
def _rel(name: str) -> str:
return f"game-runtime/games/amgen-{_GID}/src/{name}"
@pytest.fixture(autouse=True)
def _fixture_game_dir():
gd = cheap_run.game_dir(_GID)
(gd / "src").mkdir(parents=True, exist_ok=True)
# "AAA" 出现两次(不唯一测)、"Math.random()" 一次(命中测)。
(gd / "src" / "game-logic.js").write_text(
"AAA\nfunction rng(){ return ctx ? ctx.random.next() : Math.random(); }\nAAA\n",
encoding="utf-8",
)
try:
yield gd
finally:
shutil.rmtree(gd, ignore_errors=True)
def test_edit_happy_replaces_once():
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), ": Math.random()", ": 0")
assert r["ok"], r
txt = (cheap_run.game_dir(_GID) / "src" / "game-logic.js").read_text(encoding="utf-8")
assert "Math.random()" not in txt
assert "ctx.random.next() : 0" in txt
assert txt.count("AAA") == 2 # 其余不动
def test_edit_old_not_found():
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "ZZZ_不存在_ZZZ", "x")
assert not r["ok"]
assert "未找到" in r["error"]
def test_edit_old_not_unique():
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "AAA", "BBB") # 出现两次
assert not r["ok"]
assert "不唯一" in r["error"]
# 拒改后文件未变
assert (cheap_run.game_dir(_GID) / "src" / "game-logic.js").read_text(encoding="utf-8").count("AAA") == 2
def test_edit_l1_rejected():
# 建一个 L1 plumbing 文件再 edit → 回写经 write_file 被 L1_FIXED 拒。
gd = cheap_run.game_dir(_GID)
(gd / "src" / "host-config.js").write_text("const HOST = 1;\n", encoding="utf-8")
r = cheap_run.edit_file(_GID, _rel("host-config.js"), "const HOST = 1;", "const HOST = 2;")
assert not r["ok"]
assert "L1" in r["error"] or "plumbing" in r["error"]
def test_edit_out_of_boundary_rejected():
r = cheap_run.edit_file(_GID, "AGENTS.md", "x", "y") # 仓根文件,越界
assert not r["ok"]
assert "越界" in r["error"]
def test_edit_missing_file_rejected():
r = cheap_run.edit_file(_GID, _rel("nonexist.js"), "x", "y")
assert not r["ok"]
assert "不存在" in r["error"]
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))

View File

@ -172,10 +172,10 @@ class Tier2CircuitBreak(Exception):
DEFAULT_RMB_HARD_LIMIT = genconfig.get("budget", "rmb_hard_limit", 50.0) # 富档软停线 ¥50(创始人 2026-07-03 两段式裁决;改值走 YAML)
# 软停后仍放行的是收尾类动作(finish/构建/跑门/只读);这些是「新增生成面」工具——越软停线后拦截
# (创始人 2026-07-03 裁决:越线只许收尾类动作,禁新增大额生成调用)。cheap 面=write_file(写游戏代码),
# tier2 面=write_source(写源文件)/scaffold_init(重开工程)。read/list 零成本只读、check/build/run_gates/
# finish 是收尾链本体,均放行。
DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "write_source", "scaffold_init"})
# (创始人 2026-07-03 裁决:越线只许收尾类动作,禁新增大额生成调用)。cheap 面=write_file/edit_file(写/改游戏代码,
# edit_file 死圈修复 fix①-A 2026-07-08 新增,同属生成面写工具须一并拦),tier2 面=write_source(写源文件)/
# scaffold_init(重开工程)。read/list 零成本只读、check/build/run_gates/finish 是收尾链本体,均放行。
DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "edit_file", "write_source", "scaffold_init"})
# ── 软停后「finish 逼近」递进升压(工单 c)──
# 软停(budget_soft_tripped)已由 on_system_prompt 注入「立即 finish」强提醒 + on_acting 拦新增生成类工具,