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.js(4)
- 判据 = check PASS + build PASS(本产线**不跑** README 里的 node --test)。**check 与 build 都 PASS 后,过一遍下条收尾自查,随即调 finish**(summary 一句话)——自查是 30 秒对照,不是无限打磨的许可。 - 判据 = check PASS + build PASS(本产线**不跑** README 里的 node --test)。**check 与 build 都 PASS 后,过一遍下条收尾自查,随即调 finish**(summary 一句话)——自查是 30 秒对照,不是无限打磨的许可。
- **收尾自查(finish 前对照设计注释块念一遍)**:⑨ 判定句的三个空是否真映射到代码路径(判断点与代价都在逻辑里,不只在注释里);⑩ 的三段难度是否落在数值上;结算/暴击时刻的 juice 反馈是否在。**check/build 只是地板**——好玩以 10 条自检与品类 rubric 为准,别拿「能跑」当完成线。 - **收尾自查(finish 前对照设计注释块念一遍)**:⑨ 判定句的三个空是否真映射到代码路径(判断点与代价都在逻辑里,不只在注释里);⑩ 的三段难度是否落在数值上;结算/暴击时刻的 juice 反馈是否在。**check/build 只是地板**——好玩以 10 条自检与品类 rubric 为准,别拿「能跑」当完成线。
- **别做**:别写/改任何 test/ 文件、别写额外脚本、别加 brief/README 没要求的东西。 - **别做**:别写/改任何 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 黄金骨架传各自描述覆盖, # 默认脚手架描述(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)} 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①-A(2026-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 / build(shell-out node tools.mjs)───────────────────────── # ───────────────────────── check / build(shell-out node tools.mjs)─────────────────────────
def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess: 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) r = cheap_run.write_file(session.game_id, path, content)
return f"OK {r['bytes']}B 已写" if r["ok"] else "ERROR: " + r["error"] 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: async def check() -> str:
"""循环内快校验:node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。""" """循环内快校验:node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。"""
session.last_check = cheap_run.check(session.game_id) session.last_check = cheap_run.check(session.game_id)
@ -117,6 +138,6 @@ def build_toolkit(session: CheapSession, *, write_whitelist=None):
tools = [ tools = [
FunctionTool(read_file), FunctionTool(list_dir), FunctionTool(write_file), 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) 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) DEFAULT_RMB_HARD_LIMIT = genconfig.get("budget", "rmb_hard_limit", 50.0) # 富档软停线 ¥50(创始人 2026-07-03 两段式裁决;改值走 YAML)
# 软停后仍放行的是收尾类动作(finish/构建/跑门/只读);这些是「新增生成面」工具——越软停线后拦截 # 软停后仍放行的是收尾类动作(finish/构建/跑门/只读);这些是「新增生成面」工具——越软停线后拦截
# (创始人 2026-07-03 裁决:越线只许收尾类动作,禁新增大额生成调用)。cheap 面=write_file(写游戏代码), # (创始人 2026-07-03 裁决:越线只许收尾类动作,禁新增大额生成调用)。cheap 面=write_file/edit_file(写/改游戏代码,
# tier2 面=write_source(写源文件)/scaffold_init(重开工程)。read/list 零成本只读、check/build/run_gates/ # edit_file 死圈修复 fix①-A 2026-07-08 新增,同属生成面写工具须一并拦),tier2 面=write_source(写源文件)/
# finish 是收尾链本体,均放行。 # scaffold_init(重开工程)。read/list 零成本只读、check/build/run_gates/finish 是收尾链本体,均放行。
DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "write_source", "scaffold_init"}) DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "edit_file", "write_source", "scaffold_init"})
# ── 软停后「finish 逼近」递进升压(工单 c)── # ── 软停后「finish 逼近」递进升压(工单 c)──
# 软停(budget_soft_tripped)已由 on_system_prompt 注入「立即 finish」强提醒 + on_acting 拦新增生成类工具, # 软停(budget_soft_tripped)已由 on_system_prompt 注入「立即 finish」强提醒 + on_acting 拦新增生成类工具,