lili 9bd428040f fix(cheap-gen): 死圈修复 fix①-B 重实——改挂 resume 续修层(on_acting 版无效)
n=5 收敛环 iteration 3 坐实:上一版 fix①-B(on_acting 拦截畸形参数)日志 0 触发=无效。
源码核实根因:AgentScope _agent.py:1376 jsonschema.validate 失败 → _handle_error_tool_call
→ return(Step1),在 Step3 工具执行/on_acting 钩子之前,故畸形调用【根本不进 on_acting】,
上一版是死代码(单测 mock next_handler 吐错误、但线上错误不经它)。已 revert。

重实挂到真能触达 agent 的层:cheap_studio resume 续修循环 wrap writer.reply,catch
Tier2CircuitBreak——【仅 missing-param 类 stuck】(reason 含 'is a required property'=MiniMax 漏
必填 path 撞满阈值)不当终态杀,还有 resume 预算 + 未越 ¥ 软停就带贴脸补参反馈续修(与 gate-fail
resume 同机制、跨 reply memory 保留=原地续修)。只清 stuck 连击计数(_fail_repeat/_last_fail_sig,
不动 spent_rmb/tool_calls 防误清预算);非漏参 stuck(反复 check 红线/build 编译错=真死圈)/budget/
其它熔断照旧向上抛终态、不放行。整环验证随 n=5 re-run(iteration 4)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00

477 lines
32 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.3 内置 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 hashlib
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_budget # noqa: E402 预算两段式同源工厂(CLI 与生产 Service 读同一配置源)
import cheap_run # noqa: E402
import cheap_verify # noqa: E402 U-A2便宜档「丰富度」LLM 验证 agent非阻塞·只报告
# tier2 框架层import config 触发代理旁路 + 加载 agentscope必须在裸 import agentscope/openai 之前)。
from worker import config # noqa: E402
from worker.gate_judge import judge_cheap_verdict # noqa: E402 CLI 回喂:九门判据与生产 RepairMiddleware 同源
from worker.middleware import ( # noqa: E402 breaker 本体经 cheap_budget 同源工厂构造,此处只用 trace + 熔断异常
Tier2TraceMiddleware, Tier2CircuitBreak,
)
# C1b trace 落库:复用 tier2 observability 的 JSONL sinkcheap-worker 不另造 sink
# 非阻塞铁律:导入失败只告警,回落到无 sink 模式,绝不阻断生成主链。
try:
from observability.trace import make_jsonl_sink as _make_jsonl_sink # noqa: E402
_TRACE_SINK_AVAILABLE = True
except Exception as _import_err:
_TRACE_SINK_AVAILABLE = False
print(f"[cheap_studio] trace sink import 失败(回落无 sink 模式,不影响生成):{_import_err}",
flush=True)
# T5 压缩崩封口(CLI 路同样吃到:压缩失败 fail-open 跳过、不杀生成主链;钉 2.0.3,纪律同 m3_stream_patch)。
from ctx_compress_patch import apply_ctx_compress_patch # noqa: E402
apply_ctx_compress_patch()
# 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)
# fix③(死圈修复 2026-07-08):brief 落 evidence/brief.json 供根因取证。
# 为什么单独落而不只靠 run-summary.brief:① 早落(scaffold 后即写)——run 硬失败/挂死时 run-summary 收不了口、
# brief.json 已在盘上,取证不断链;② 带 sha256 完整性戳(核 worker 收到的 brief 与后端下发字节一致,查"是不是 brief
# 被截断/篡改害的生成翻车");③ 限长脱敏(超 PROMPT_MAX 截断 + truncated 标记),防超长 brief 撑爆落盘。
# 落 evidence/(forensics 目录,不随 src/ 进素材市场交易),绝不落工程根——源工程会被交易,brief 是用户私有输入、不得泄进可交易源。
_BRIEF_PROMPT_MAX = 1000 # brief 落盘限长(字符);超出截断 + truncated=True,sha256 仍按【完整原文】算(截断不掩盖完整性核对)
def _persist_brief_forensics(game_id: str, brief: str, source: str = "job.brief") -> None:
"""把本局 brief 落 evidence/brief.json(取证用,见上方注释)。非阻塞:任何异常只告警、绝不咬生成主链。"""
try:
text = brief or ""
raw = text.encode("utf-8")
rec = {
"brief": text[:_BRIEF_PROMPT_MAX], # 限长:超长只留前 N 字符(脱敏 + 防撑爆)
"bytes": len(raw), # 完整 brief 的 UTF-8 字节数
"sha256": hashlib.sha256(raw).hexdigest(), # 完整 brief 摘要:核 worker↔后端字节一致
"truncated": len(text) > _BRIEF_PROMPT_MAX, # 是否因限长被截
"promptMax": _BRIEF_PROMPT_MAX,
"source": source, # 来源溯源(默认 job.brief)
}
ev_dir = cheap_run.game_dir(game_id) / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
(ev_dir / "brief.json").write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
_rec(f"brief 落盘取证 → {ev_dir / 'brief.json'} bytes={rec['bytes']} truncated={rec['truncated']}")
except Exception as e: # noqa: BLE001 非阻塞铁律:取证落盘绝不影响生成主链
_rec(f"brief 落盘异常(已忽略,不影响生成):{type(e).__name__}: {e}")
# fix①-B(重实):missing-param 类 stuck 续修时给 agent 的贴脸补参反馈(比默认 schema 校验错更明确,并催改用 edit_file 短载荷)。
_MALFORMED_RESUME_FEEDBACK = (
"【重要·纠偏】你刚才反复漏了工具调用的必填参数(通常是 path),调用被拒并触发了熔断。"
"重发工具调用时【必须一次带全参数】:write_file 要 {path, content}、edit_file 要 {path, old, new}——path 绝不能省。"
"小改强烈建议用 edit_file(只发变化的几行、参数短、最不易漏 path),别对整个 game-logic.js 用 write_file"
"(内容越长越容易把 path 漏掉)。现在:先 read_file 看清你要改的文件路径,再带【完整参数】重发修改,然后 check → build → finish。"
)
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 _closeout_gates(game_id, *, port, cdp_port) -> dict:
"""CLI 收口门流水线 stage → smoke → ensure_play_spec → 九门 play(一次跑齐;回喂循环与收口段共用)。
与 Service 路 cheap_gates.run_cheap_gates 同序,多回传 staged/smoke_ok/driver_type 供 run-summary 组装
(Service 路这些由 collector/driver 另采)。起 play 前清残留 verdict(红线③,镜像 cheap_gates.py:28):
serve-and-play.sh 早退/超时不写新 verdict 也不删旧的,不清则读回上一轮绿 verdict → 假绿。
"""
out = {"staged": False, "smoke_ok": None, "driver_type": None, "verdict": None}
st = cheap_run.stage(game_id)
out["staged"] = st["ok"]
if not st["ok"]:
_rec(f"stage FAIL: {st['output'][:300]}")
return out
sm = cheap_run.smoke(game_id, port=port, cdp_port=cdp_port)
out["smoke_ok"] = sm["ok"]
_rec(f"smoke {'PASS' if sm['ok'] else 'FAIL'}(抓 state 供 play-spec)")
if not sm["ok"]:
# smoke 失败真因必须可见(2026-07-04 bake_off 实证:并发争用致 100% FAIL→全场 key-cycle 回退考卷,
# 而失败原因被吞在 raw 里,烧掉一轮诊断)——打印 raw 尾供归因。
_rec(f"smoke raw 尾:{(sm.get('raw') or '')[-300:]}")
ps = cheap_run.ensure_play_spec(game_id, sm.get("state"))
# driver 类型:本轮新产的直接带;复用旧 spec(hash 一致)时读盘补(spec 是判卷单一来源)。
dt = ps.get("driverType")
if not dt:
try:
spec = json.loads((cheap_run.wg1_game_dir(game_id) / "play-spec.json").read_text(encoding="utf-8"))
dt = (spec.get("driver") or {}).get("type")
except Exception: # noqa: BLE001 —— 读不出只降级 None
dt = None
out["driver_type"] = dt
_rec(f"play-spec {'已产 driver=' + str(dt) if ps.get('wrote') else ps.get('reason', '?')}")
(cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json").unlink(missing_ok=True) # 红线③:清陈旧 verdict
pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port)
out["verdict"] = pr["verdict"]
return out
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_template=None, scaffold_desc=None, verify_richness_enabled=True,
genre=None):
"""跑便宜档一局生成scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。
genreW-GENRE 件④,缺省 None=原行为):品类键(如 'puzzle')显式透传给丰富度 LLM 评分——同一次
judge 调用 additive 追加品类扩展条目(分母独立小计落 richness.genreNone → 按 scaffold_template
查 GENRE_BY_TEMPLATE 推断;两路都没有/条目缺 → 自动退化为纯通用底座评分。
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}")
# create=scaffold clone 模板(扩模板:scaffold_template 传 per-genre 黄金骨架名,如经营=_template-shop)modify=上游已预备的 noop
prep = prepare or (lambda gid: cheap_run.scaffold(gid, scaffold_template))
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 源已预备'}")
_persist_brief_forensics(game_id, brief) # fix③:scaffold 后即落 brief 取证(硬失败/挂死也留得下,不进可交易工程根)
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)
# 熔断:便宜档预算两段式(W-ARCH②,创始人 2026-07-03 裁决)——软停线 ¥10(越线只许收尾类动作,
# on_acting 拦新增生成调用)+ RMB 硬地板 ¥15(×1.5,fail-closed 封死上界)。经 cheap_budget 同源工厂,
# 与生产 Service(cheap_service_app)读同一 genconfig 配置源(取代旧 hard 单段 ¥10,CLI/生产口径统一)。
# 取价不可达时 _ensure_pricing 降级为次数闸(既有行为,不静默超支、不静默阻断)。步数/超时仍用 tier2 默认。
breaker = cheap_budget.build_cheap_breaker()
# C1b trace 落库把生成轨迹按五字段traceId/step/cost/verdict/timestamp + ext
# 追加写到产物目录 games/amgen-<id>/trace.jsonl供成本对账 / replay 用。
# 非阻塞铁律:接线异常只告警 + 回落无 sink 模式,绝不阻断生成主链。
# 为什么落盘路径选产物目录:与 run-summary.json / evidence/ 同目录,随产物一起留存,运维按 gameId 定位直观。
_trace_sink = None
if _TRACE_SINK_AVAILABLE:
try:
_trace_path = cheap_run.game_dir(game_id) / "trace.jsonl"
_trace_sink = _make_jsonl_sink(_trace_path)
_rec(f"trace sink 接线 → {_trace_path}")
except Exception as _sink_err:
_rec(f"trace sink 构造异常(只告警,回落无 sink不阻断生成{_sink_err}")
# 阶段四观测 波③ T3-1:CLI 路同款并接 OTLP span sink(发 Collector,service.name=cheap-gen-worker)。
# 默认关(env CHEAP_OTLP_ENDPOINT 未设)→ 原样返回上面的 jsonl sink,CLI 现有行为字节不变;
# 开则包 fan-out(jsonl 落盘照旧、OTLP 额外发一份)。best-effort:观测挂掉绝不咬生成(设计 §8)。
try:
import cheap_otlp_sink # noqa: PLC0415 函数体内惰性 import,顶层不牵 otel
_trace_sink = cheap_otlp_sink.build_trace_sink(_trace_sink, trace_id=game_id)
except Exception as _otlp_err:
_rec(f"OTLP sink 并接异常(回落原 jsonl sink不阻断生成{_otlp_err}")
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=_trace_sink)
writer = Agent(
name="cheap-writer",
system_prompt=system_prompt or build_system_prompt(game_id, scaffold_desc), # modify 传 build_modify_system_prompt扩模板传 scaffold_desc
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
closeout = None # CLI 回喂:最近一次九门收口结果(回喂循环产出,收口段复用、不重复跑门)
try:
for attempt in range(max_resumes + 1):
attempts = attempt + 1
_rec(f"resume attempt {attempts}/{max_resumes + 1} → writer.reply …")
try:
await writer.reply(_user_msg(kick))
except Tier2CircuitBreak as _cb:
# fix①-B(重实·resume 层,2026-07-08 n=5 收敛环坐实 heritage 死圈复现)——
# 根因:MiniMax 长参数下漏 write_file/edit_file 必填 path,schema 校验拒 → 连撞 stuck 阈值 → 终态杀、九门没跑。
# 先前 on_acting 版无效(_agent.py:1376 校验先于 Step3 工具执行/on_acting、畸形调用直接 return 不进钩子);
# 改挂此处:missing-param 类 stuck 不当终态,还有 resume 预算就带【贴脸补参反馈】续修——与 gate-fail resume
# 同机制、跨 reply memory 保留=原地续修、能真正触达 agent。只清 stuck 连击计数(不动 spent_rmb/tool_calls,防误清预算);
# 非 missing-param 的 stuck(反复 check 同红线 / build 同编译错=真死圈)/ budget / 其它熔断照旧向上抛(终态、不放行)。
_missing_param = _cb.kind == "stuck" and "is a required property" in (_cb.reason or "")
if _missing_param and attempt < max_resumes and not getattr(breaker, "budget_soft_tripped", False):
_rec(f"熔断 stuck(漏必填参 path)→ fix①-B 带补参反馈续修(attempt {attempts}):{(_cb.reason or '')[:70]}")
breaker._fail_repeat = 0 # 只清 stuck 连击计数(下一 attempt 干净起,不动 spent_rmb/tool_calls)
breaker._last_fail_sig = None
kick = _MALFORMED_RESUME_FEEDBACK
continue
raise # 其它熔断 / 预算耗尽 / 非漏参 stuck → 终态,交外层 except 记 breaker_tripped
# ① 真 finish:CLI 回喂对齐(W-S1 单③,对账发现六④)——收敛判据从「check+build 绿」升为
# 「九门绿」:finish 后跑九门收口,门未全绿且轮数/预算有余时带 C6 反馈 resume 续修,
# 使 lab 复验口径与生产 RepairMiddleware(finish 点拦截→跑门→judge→回喂)行为同型。
if session.finished is not None:
if not run_gates:
_rec("finish 已接受(check+build 绿)→ 收敛(generation-only,不跑九门回喂)")
break
_rec("finish 已接受(check+build 绿)→ 九门收口判(CLI 回喂对齐)")
closeout = _closeout_gates(game_id, port=port, cdp_port=cdp_port)
j = judge_cheap_verdict(closeout["verdict"], game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
vb = _verdict_brief(closeout["verdict"])
_rec(f"九门 play pass={vb['pass']} failedGates={vb['failedGates']}")
if j.passed:
break
if attempt >= max_resumes:
_rec("九门未绿,但 resume 轮数耗尽 → 交尽力产物收口")
break
# 预算判定与生产同口径:软停已触发 / 实测已花越软停线 → 只收尾、不再回喂(裁决:越线只许收尾)。
budget_out = bool(getattr(breaker, "budget_soft_tripped", False)) or (
breaker._rmb_gate_active and breaker.spent_rmb >= breaker.rmb_hard_limit)
if budget_out:
_rec(f"九门未绿,但 ¥ 软停线已到(已花 ¥{breaker.spent_rmb:.4f})→ 只收尾、不再回喂")
break
session.finished = None # 重置收敛标记,带门反馈续修(跨 reply memory 保留=原地续修)
_rec(f"九门未绿(failedGates={j.failed_gates})→ 带 C6 反馈回喂续修")
kick = j.feedback # C6 结构渲染全文(含 phaseNow/driverType/game-log 信号;与 RepairMiddleware 注入同源)
continue
# ② 门已绿但没 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 ──
# CLI 回喂对齐后,run_gates 路的九门收口已在循环内跑过(closeout),此处直接复用最后一轮结果,
# 不重复跑门;对照路(run_gates=False)保持原「只 stage+smoke」行为零改动。
finished = session.finished is not None
staged = False
smoke_ok = None
verdict = None
driver_type = None # M3b U1:ensure_play_spec 产的 driver 类型(对照路 run_gates=False 不产 → 保持 None)
if finished:
if run_gates:
cg = closeout or _closeout_gates(game_id, port=port, cdp_port=cdp_port) # 防御:正常路循环内已跑
staged = cg["staged"]
smoke_ok = cg["smoke_ok"]
driver_type = cg["driver_type"] # M3b U1:driver 类型存进 trace.gatespec.driver(后端 D11 firstPlay 维)
verdict = cg["verdict"]
else:
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 not smoke_ok:
_rec(f"smoke raw 尾:{(sm.get('raw') or '')[-300:]}") # 真因可见(同 _closeout_gates 注)
_rec("generation-only(run_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),
# C1b trace 收口摘要:{traceId, steps, dropped}供编排器对账trace.jsonl 落盘路径已在接线处 _rec
"trace": tracer.summary(),
}
# ── 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))
# ── U-A2便宜档「丰富度」LLM 评分(非阻塞·只报告·不进 verdict / 不改 ok 达标)──
# 架构红线:玩法「丰富不丰富」的校验需大模型能力,绝不写成 code-presence/正则/断言(违反创始人红线)。
# 故纯走 LLM judgecheap_verify.verify_richness结果 additive 写 summary["richness"]
# · 不进 verdict、不改 summary["ok"]/达标判定、不动任何既有键result_out._build_trace 只读 named 字段,不读 richness
# · judge 用独立 model 实例token 不污染生成成本台账costRmb 仍只算生成);
# · 非阻塞双保险verify_richness 内部已 try/except 兜底 degraded这里再包一层verify 失败绝不影响 run。
# 只在 run_gates完整 spike 收口)且 finished有真产物时跑对照路run_gates=False零改动、不触发 LLM。
if verify_richness_enabled and run_gates and finished:
try:
# 品类扩展 rubric(W-GENRE 件④):显式 genre 参数优先,否则按 scaffold_template 查
# GENRE_BY_TEMPLATE 推断;命中时同一次评分 additive 追加品类条目(分母独立小计落
# richness.genre);两路都没有/未登记 → 行为与既有完全一致。
_genre = genre or (cheap_verify.GENRE_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None)
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=_genre)
rv = summary["richness"]
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} "
f"genre={(rv.get('genre') or {}).get('key')}:{(rv.get('genre') or {}).get('score')} degraded={rv.get('degraded')}")
except Exception as e: # noqa: BLE001 非阻塞铁律richness 绝不影响生成主链
summary["richness"] = {"score": None, "degraded": True,
"reason": f"richness 接线异常:{type(e).__name__}: {e}"}
_rec(f"丰富度评分接线异常(已降级,不影响 run{type(e).__name__}: {e}")
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']}")
# ── 面二观测:发 llm_tokens{kind} 计数 + llm_cache_hit_rate(默认关 / best-effort;绝不咬生成)──
# 本路(CLI/批跑)model 是 RecordingOpenAIChatModel,records 每条 (in,out,cached) 三元组,cached 由
# usage.cache_input_tokens 归一 → 三项全可得(生产 Service 路框架默认 model 无 records,cached 拿不到、
# 见 cheap_service_app collector)。取不到 CHEAP_OTLP_ENDPOINT 即 no-op,与 trace sink 默认关同源;
# 面五(game-cloud GenMetrics)不发 token 计数,故不双计。发送异常只报告、绝不阻断生成。
try:
import cheap_otlp_sink # noqa: PLC0415 惰性 import(顶层不牵 otel)
_cached_tok = sum(r[2] for r in model.records if len(r) > 2)
cheap_otlp_sink.record_llm_token_metrics(tok_in, tok_out, _cached_tok)
except Exception as _me: # noqa: BLE001 面二 metric best-effort,绝不影响生成主链
_rec(f"面二 metric 发送异常(已忽略,不影响生成):{type(_me).__name__}: {_me}")
# 注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)