lili 14c79dbfdd feat(studio): A11 切片三 M4 模块重生成执行(有界单文件 LLM 重写 game-logic.js)
两段式第二段的 regenerate-module 类:改玩法 = 据 intent 有界重写 game-logic.js,不动其余。

复用机制(参数化、不复制 resume/熔断/收口):
- cheap_studio.run_studio 加 4 个默认 None 可选参(system_prompt/initial_kick/write_whitelist/prepare)
  切到 modify 态,create 路零行为变化(params=None 等价原逻辑)
- cheap_toolkit.build_toolkit 加 write_whitelist(非白名单 basename 写直接拒)
- cheap_roles.build_modify_system_prompt(复用 create 红线契约块)

cheap_modify.execute_regenerate_modify:
- 取 intent(空→failed)→ materialize base 源 → 重写前后对非目标文件算 hash
- 有界重写(写边界收窄到只 game-logic.js)→ 九门
- status=succeeded ⟺ 九门过 ∧ 非目标稳;manifest{file,kind:behavior,intent,changed,untouchedStable}(断言②地基)

worker_service._process_regenerate_job(regen_fn):把 M3 的 regenerate-module 显式 failed 占位换成真执行;
deterministic/create/生成路一字未动。

测试:test_a11_m4_regenerate 10/10(全注入桩零 LLM)+ 全回归独立复跑 73 测试全绿。
一次真 LLM smoke(单跑):intent=连击递增 → status=succeeded、untouchedStable=True、九门 pass、
attempts=1、¥0.31、137s;独立 diff 佐证 5 非目标文件字节相同、仅 game-logic.js 变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:16:22 -07:00

276 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
cheap_studio.py — 便宜档 ReAct 主编排spike 核心对照源Node gen.mjs 主循环 + tier2 studio.py:run_studio
组装:内置 Agent + ReActConfig + 外层有界 resume 循环(照 studio.py:404-458。done 由 cheap_toolkit 的 finish
工具触发(工具内复核 check+build 绿finish 收敛后收口 stage → smoke → ensure_play_spec → 循环外九门 play。
熔断CircuitBreakerMiddleware/ traceTier2TraceMiddleware/ 历史压缩ContextConfig经 import 复用 tier2零重写。
为什么要外层 resumeAgentScope 2.0.2 内置 ReAct 在"模型产出无 tool_call 的纯文本回合"即退出(看一次门没绿就停)。
外层在 agent 停下后,若【没真 finish】且【门没绿】且【还有预算】就带反馈再 reply()——跨 reply 保留 memory = 原地续修。
这一圈正对应 Node gen.mjs 的 while 主循环。
CLIcheap-worker/.venv/bin/python cheap-worker/cheap_studio.py --id cheap-run1 --brief "一个简单的点击得分小游戏"
"""
import json
import sys
import time
from pathlib import Path
# 跨包 import 兜底 + key 注入_bootstrap 模块级把 tier2/gen-worker 加进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent)) # → cheap-worker/CLI 直跑兼容)
import _bootstrap # noqa: E402,F401
from cheap_roles import build_system_prompt # noqa: E402
from cheap_toolkit import CheapSession, build_toolkit # noqa: E402
import cheap_run # noqa: E402
# tier2 框架层import config 触发代理旁路 + 加载 agentscope必须在裸 import agentscope/openai 之前)。
from worker import config # noqa: E402
from worker.middleware import ( # noqa: E402
CircuitBreakerMiddleware, Tier2TraceMiddleware, Tier2CircuitBreak,
)
# AgentScope此时 agentscope 已由 config import 链加载、代理旁路已装)。
from agentscope.agent import Agent, ReActConfig # noqa: E402
from agentscope.message import UserMsg # noqa: E402
from agentscope.state import AgentState # noqa: E402
from agentscope.permission import PermissionContext, PermissionMode # noqa: E402
def _rec(msg: str) -> None:
"""进度日志走 stderr保 CLI stdout 末行可作机读 JSON"""
print(f"[cheap_studio] {msg}", file=sys.stderr, flush=True)
def _bypass_state() -> AgentState:
"""无人值守跳过工具 ASK否则 FunctionTool 默认 check_permissions 返回 ASK 卡死;对 studio.py:_bypass_state"""
return AgentState(permission_context=PermissionContext(mode=PermissionMode.BYPASS))
def _user_msg(text: str) -> UserMsg:
"""2.0.1+ UserMsg 必须带 name对 studio.py:user_msg"""
return UserMsg(name="user", content=text)
def _verdict_brief(v) -> dict:
"""从九门 verdict.json 抽 {pass, failedGates}。"""
if not isinstance(v, dict):
return {"pass": None, "failedGates": None}
guards = v.get("guards") or {}
failed = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False]
return {"pass": v.get("pass"), "failedGates": failed}
def _furthest_stage(*, finished, staged, smoke_ran, played) -> str:
"""据收口流程实际到达的步,判本 run 走到的最远阶段scaffold/code/stage/smoke/play
取最远到达者scaffold 恒成功失败提前返回、不到此finished=code 已收敛staged=esbuild 打包成功;
smoke_ran=跑过冒烟无论过否played=跑过九门 play无论过否。供 9d trace.stage后端审核台读
"""
if played:
return "play"
if smoke_ran:
return "smoke"
if staged:
return "stage"
if finished:
return "code"
return "scaffold"
def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict:
"""组 run-summary 的 9d-trace 源维度additive 纯函数·可单测run_studio 收口段调)。
把收口段已算出但没存进 summary 的维度结构化产出,供 result_out 组 payload["trace"]、
后端 D11 ReadinessScorer 据此算就绪分。纯字典构造、零外部依赖、同输入恒同输出。
Args:
verdict: 完整九门 verdict含 guards其中 guards.H_progress 是嵌套对象 {pass,checks,latch,...}
play 没跑finished=False / run_gates=False / smoke 失败)时传 None。
driver_type: ensure_play_spec 产的 driver 类型ps.get("driverType"));没产 play-spec 时 None。
attempts: resume 轮数int=summary 既有字段)。本函数接收以构成完整 trace-源契约,但 repairs 由
result_out 据 summary.attempts 派生max(0,attempts-1)),故此处**不回写 attempts**
(守「不动既有键」)。
stage: 本 run 走到的最远阶段scaffold/code/stage/smoke/play 之一_furthest_stage 产)。
model: 终态模型名(便宜档单模型 = _bootstrap.SPIKE_MODEL
Returns:
dict仅新增键供 summary.updateverdictFull / driverType / models / stage。
"""
return {
# 完整 verdict保留 guards 嵌套,含 H_progress.passplay 没跑 → None。U2 在 result_out 内削成 {pass,guards}。
"verdictFull": verdict if isinstance(verdict, dict) else None,
# driver 类型ensure_play_spec 产);没产 play-spec对照路 run_gates=False→ None。
"driverType": driver_type,
# 便宜档单模型 → {code: SPIKE_MODEL} map镜像 wg1 models 多模型 map 形态)。
"models": {"code": model},
# 最远阶段。
"stage": stage,
}
async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000,
port=4320, cdp_port=9222, run_gates=True,
system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None):
"""跑便宜档一局生成scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。
run_gates=True默认spike 单局):收口跑全套 stage→smoke→ensure_play_spec→九门 play。
run_gates=Falsegeneration-only给 compare_node 对照用):收口只到 stage+smoke 为止,**不**自动产 play-spec、
**不**跑内部九门 play —— 由调用方在生成与 play 之间注入金标 spec 再单独 play把驱动器从对照变量里摘掉
Codex C1run_studio 内部已 play对照需把生成与 play 拆开)。
A11 M4 模块重生成复用本编排(同一 ReAct + resume + 熔断 + 三层校验收口,不另造),靠四个可选参切到 modify 态,
都默认 None=create 原行为create 路零改动):
· system_promptNone=create 的 build_system_promptmodify 传 build_modify_system_prompt只改玩法
· initial_kickNone=create 的「按 brief 造游戏」首条 kickmodify 传「只重写 game-logic.js 实现 intent」。
· write_whitelistNone=create 不收窄modify 传 {"game-logic.js"} 把写边界收窄到只许写玩法文件。
· prepareNone=create 的 scaffoldclone _templatemodify 传「已由上游 scaffold+materialize base 源」
的 noopgame_dir 已就绪、不要再 scaffold 覆盖掉 base 源)。签名同 scaffold(game_id)->{ok,output}。
"""
t0 = time.time()
_rec(f"model={_bootstrap.SPIKE_MODEL} id={game_id} brief=「{brief}」max_iters={max_iters} max_resumes={max_resumes}")
prep = prepare or cheap_run.scaffold # create=scaffold clone _templatemodify=上游已预备的 noop
sc = prep(game_id)
if not sc["ok"]:
return {"ok": False, "gameId": game_id, "fail": "准备失败:" + sc["output"]}
_rec(f"准备就绪 amgen-{game_id}{'scaffold clone _template + SAA 信封' if prepare is None else 'modify: base 源已预备'}")
session = CheapSession(game_id=game_id)
toolkit = build_toolkit(session, write_whitelist=write_whitelist) # modify 收窄到只许写 game-logic.js
model = _bootstrap.build_cheap_model(max_tokens=max_tokens)
# 熔断:便宜档开 ¥ 累进硬闸M1 U4 / KTD5+ 设便宜档专属 ¥10 硬上限(区别 tier2 的 ¥3 genconfig 默认)。
# on_model_call 在每次裸模型调用前按「已花 + 本次预估」判、越线即 fail-closed 抛 Tier2CircuitBreak(budget)
# 取价不可达时 _ensure_pricing 降级为次数闸(既有行为,不静默超支、不静默阻断)。步数/超时仍用 tier2 默认。
breaker = CircuitBreakerMiddleware(enable_rmb_gate=True, rmb_hard_limit=10.0)
tracer = Tier2TraceMiddleware(trace_id=game_id)
writer = Agent(
name="cheap-writer",
system_prompt=system_prompt or build_system_prompt(game_id), # modify 传 build_modify_system_prompt
model=model,
toolkit=toolkit,
middlewares=[tracer, breaker], # trace 外层、熔断内层(列表序=洋葱序)
state=_bypass_state(),
react_config=ReActConfig(max_iters=max_iters),
context_config=config.build_context_config(), # 历史压缩
)
kick = initial_kick or (
f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md"
"和你的起点 game-logic.js 再动手核心玩法实现完、check 与 build 都绿了就立即 finish。")
breaker_tripped = None
attempts = 0
try:
for attempt in range(max_resumes + 1):
attempts = attempt + 1
_rec(f"resume attempt {attempts}/{max_resumes + 1} → writer.reply …")
await writer.reply(_user_msg(kick))
# ① 真 finish → 收敛退出
if session.finished is not None:
_rec("finish 已接受check+build 绿)→ 收敛")
break
# ② 门已绿但没 finish → 踹一脚让它 finish
c_ok = bool(session.last_check and session.last_check.get("ok"))
b_ok = bool(session.last_build and session.last_build.get("ok"))
if c_ok and b_ok:
_rec("门绿但未 finish → 踹 finish")
kick = "check 与 build 都已 PASS。现在直接调 finish 交付summary 一句话),不要再改。"
continue
# ③ resume 预算耗尽 → 停
if attempt >= max_resumes:
_rec("resume 预算耗尽,停")
break
# ④ 门没绿 + agent 自停 → 带失败反馈再 reply跨 reply memory 保留=原地续修)
fb = "尚未跑出绿 check/build。"
if session.last_check and not session.last_check.get("ok"):
fb = "上次 check 失败:\n" + (session.last_check.get("output") or "")[:1500]
elif session.last_build and not session.last_build.get("ok"):
fb = "上次 build 失败:\n" + (session.last_build.get("output") or "")[:1500]
_rec(f"门没绿 + agent 自停 → 带反馈再 replyattempt {attempts}")
kick = (f"你刚才停下了,但 check/build 还没全绿——不要放弃。{fb}\n"
"请据失败 write_file 针对性修,再 check → build直到都绿再 finish。先修最关键的错。")
except Tier2CircuitBreak as e:
breaker_tripped = {"kind": e.kind, "reason": e.reason}
_rec(f"熔断 CircuitBreakkind={e.kind} reason={e.reason}")
except Exception as e: # noqa: BLE001 顶层兜底,落 breaker 信息便于诊断
breaker_tripped = {"kind": None, "reason": f"{type(e).__name__}: {e}"}
_rec(f"未捕获异常:{type(e).__name__}: {e}")
# ── 收口finish 后 stage → smoke → ensure_play_spec → 循环外九门 play ──
finished = session.finished is not None
staged = False
smoke_ok = None
verdict = None
driver_type = None # M3b U1ensure_play_spec 产的 driver 类型(对照路 run_gates=False 不产 → 保持 None
if finished:
st = cheap_run.stage(game_id)
staged = st["ok"]
_rec(f"stage {'OK' if staged else 'FAIL: ' + st['output']}")
if staged:
sm = cheap_run.smoke(game_id, port=port, cdp_port=cdp_port)
smoke_ok = sm["ok"]
_rec(f"smoke {'PASS' if smoke_ok else 'FAIL'}(抓 state 供 play-spec")
if run_gates:
ps = cheap_run.ensure_play_spec(game_id, sm.get("state"))
driver_type = ps.get("driverType") # M3b U1driver 类型存进 trace.gatespec.driver后端 D11 firstPlay 维)
_rec(f"play-spec {'已产 driver=' + ps.get('driverType', '?') if ps.get('wrote') else ps.get('reason', '?')}")
pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port)
verdict = pr["verdict"]
vb = _verdict_brief(verdict)
_rec(f"九门 play pass={vb['pass']} failedGates={vb['failedGates']}")
else:
_rec("generation-onlyrun_gates=False跳过 ensure_play_spec + 九门 play交对照方注入金标 spec 后单独 play")
tok_in, tok_out = model.usage_sum() # 成本落盘(R4)token 台账始终可取(RecordingOpenAIChatModel.records 累计)。
summary = {
"ok": finished,
"gameId": game_id,
"brief": brief,
"model": _bootstrap.SPIKE_MODEL,
"finished": finished,
"attempts": attempts,
"check": {"ok": bool(session.last_check and session.last_check.get("ok"))} if session.last_check else None,
"build": {"ok": bool(session.last_build and session.last_build.get("ok"))} if session.last_build else None,
"staged": staged,
"smoke": {"ok": smoke_ok} if smoke_ok is not None else None,
"verdict": _verdict_brief(verdict) if verdict is not None else None,
"breaker": breaker_tripped,
# 成本落盘(R4):¥ 走 breaker 同口径(new-api quota 折价 cost.compute);取价不可达时 spent_rmb=0 且
# rmbGate=degraded 标识(_ensure_pricing 已打降级日志可查)——不静默超支、不静默阻断。
"costRmb": round(breaker.spent_rmb, 4),
"rmbGate": "active" if breaker._rmb_gate_active else "degraded",
"tokens": {"in": tok_in, "out": tok_out, "total": tok_in + tok_out},
"doneSummary": session.finished.get("summary") if session.finished else None,
"wallSec": round(time.time() - t0, 1),
}
# ── M3b U1additive 富化 9d trace 源维度verdictFull/driverType/models/stage──
# 收口段已算出但没存的维度结构化进 summary供 result_out 组 payload["trace"]、后端 D11 算分;
# 全 additive新增键、不动既有键对照路run_gates=Falseverdict/driver_type 为 None 时各键自然降级。
stage = _furthest_stage(finished=finished, staged=staged,
smoke_ran=(smoke_ok is not None), played=(verdict is not None))
summary.update(build_trace_source(verdict, driver_type, attempts, stage, _bootstrap.SPIKE_MODEL))
ev_dir = cheap_run.game_dir(game_id) / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
(ev_dir / "run-summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
_rec(f"run-summary 落盘 → {ev_dir / 'run-summary.json'} wallSec={summary['wallSec']}")
# 注n=5 收敛环批跑RunRecord/batch_run是 plan deferredspike 单局 run-summary 已含等价字段。
return summary
if __name__ == "__main__":
import argparse
import asyncio
ap = argparse.ArgumentParser(description="便宜档 ReAct 主编排spike 单局)")
ap.add_argument("--id", default="cheap-run1")
ap.add_argument("--brief", default="一个简单的点击得分小游戏")
ap.add_argument("--max-iters", type=int, default=40)
ap.add_argument("--max-resumes", type=int, default=6)
ap.add_argument("--max-tokens", type=int, default=16000)
a = ap.parse_args()
result = asyncio.run(run_studio(a.id, a.brief, max_iters=a.max_iters,
max_resumes=a.max_resumes, max_tokens=a.max_tokens))
# stdout 末行单行 JSONU6 对照 shell-out 据此解析)。
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result.get("ok") else 1)