feat(cheap-gen): 死圈修复 fix①-A 增量 edit 工具 + fix②-prompt 克隆纪律/check-early
死圈根因(双评审坐实):产物带小红线违规→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:
parent
d9660c8ef1
commit
596ea13666
@ -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 秒对照,不是无限打磨的许可。
|
||||
- **收尾自查(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 黄金骨架传各自描述覆盖,
|
||||
|
||||
@ -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①-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)─────────────────────────
|
||||
|
||||
def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess:
|
||||
|
||||
@ -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)
|
||||
|
||||
88
cheap-worker/tests/test_edit_file_toolkit.py
Normal file
88
cheap-worker/tests/test_edit_file_toolkit.py
Normal 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"]))
|
||||
@ -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 拦新增生成类工具,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user