把「设计团队超时降级 / writer 撞轮数墙 / step_cap 熔断 / ¥ 软预算软停 / 墙钟超时」这类
编排层事件从 studio 运行态穿到批跑台账,让退路树能一眼归因。此前两败局其实 100% 集中在
「设计团队超时降级路」,但台账 fail_system 空 → decide_n5 按 fail_system 看"分散"出
R3_conditional,主持人只能人工翻日志归因。
做法(不塞 fail_system,游戏系统桶语义不污染;decide_n5 的 R1/R2 分流逻辑一字不动):
- RunRecord 新增 infra_flags: list[str](field(default_factory=list);受控值集 INFRA_FLAGS
五项常量 design_team_degraded/writer_iter_wall/step_cap_tripped/soft_budget_tripped/
wall_timeout,注释写明可扩)+ 纯映射 infra_flags_from_runtime(信号→值集,去重稳定序);
- studio 运行态采集:设计团队降级点(捕获异常,.kind=='timeout' 归 wall_timeout)、外层
resume 轮数墙、熔断返回 kind、¥ 软预算软停标记,经 _infra_flags_from_runtime 归一进 result;
design_team.DesignTeamError 加 kind 区分墙钟超时;
- batch_run.result_to_record 从 result 抽 infra_flags 落台账(防御式复制);
- decide_n5 只把各轮 infra_flags 分布打进 reasons(如「r1 infra 分布={design_team_degraded:2,
wall_timeout:2}」),不分支、不改任何出口。
向后兼容:旧 jsonl 行无 infra_flags → from_jsonl_line 读空列表不炸;r{N} 轮分组正则不动;
middleware.py 不动;退路树出口枚举与判定不动;decide_n5 签名不破坏。
验收:新增单测 5(纯映射/studio fake 运行态采集/batch 落账/旧行兼容/decide_n5 分布行+出口不变
含 --no-strong-baseline 语义原样);全套 102 passed(97 基线 + 5);run_record 与 fallback_tree
的 __main__ 自检、decide_n5_from_jsonl 新旧混合行端到端均通过。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
726 lines
46 KiB
Python
726 lines
46 KiB
Python
"""agent_loop/studio.py —— tier2 富游戏单写 ReAct 编排(on AgentScope v2.0.2)。
|
||
|
||
闭环(对照 wg1 studio.py 的「单轮 + 外层 Python for repair」,tier2 沿用「内层 ReAct + 有界外层 resume 续修」范式——
|
||
spike 实测推翻了「纯 Agent 内多轮自治」:原生 ReAct 一见 decision=fix 就产空文本收尾、过早放弃):
|
||
阶段 1:设计 agent(默认走工作室 Agent Team 星形,use_team=True)把题面 → 富游戏设计稿(承袭 wg1 design 范式)。
|
||
阶段 2:单写 agent —— 一个 Agent 配九工具 Toolkit + 放开 max_iters,在 ReAct 循环内自调
|
||
scaffold_init/write_source/build/run_gates/finish;门没绿 + 有预算时由本文件的**有界外层 resume**带 verdict
|
||
反馈踹回续修(见下方 400 行开外的自纠循环)。注:护城河生产路的续修已迁进 on_reasoning 的 RepairMiddleware
|
||
(单 POST 内 finish 点拦截、取代 control_plane 外层循环);本文件外层 resume 为本地 runner 的并存 fallback。
|
||
|
||
框架接缝(2.0.2,从 wg1 种子平移 + 升级):
|
||
- L32/33 平移:`from agentscope.agent import Agent, ReActConfig` / `from agentscope.message import UserMsg`。
|
||
- 关键升级:`ReActConfig(max_iters=1)`(wg1 单轮)→ 放开(单写 ≤ writer_max_iters);配 `toolkit=`(九工具面);
|
||
model `OpenAIChatModel` → `AnthropicChatModel`(M3 路,config.build_model)。
|
||
|
||
自治化两个必备(源码核验):
|
||
① 权限:FunctionTool 默认 check_permissions 返回 ASK(_adapters.py:99),无人值守会卡死 →
|
||
用 `PermissionMode.BYPASS`(沙箱无人值守的官方档位,permission/_types.py)经 AgentState 注入,跳过 ASK。
|
||
② 熔断:四道熔断做成 CircuitBreakerMiddleware(on_reply 洋葱钩子),挂中间件洋葱;触发抛
|
||
Tier2CircuitBreak,本编排 catch → 落 verdict.breakerKind。
|
||
|
||
设计依据:docs/architecture/架构/生成引擎/tier2细节图说-{C,D,E,F}.md;契约 tier2/contracts/toolkit-signatures.md。
|
||
"""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import base64
|
||
import functools
|
||
import json
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
# 包内/直跑兼容导入(直跑时把 tier2/ 加进 sys.path,使 `gen_worker.*` 可解析)。
|
||
try:
|
||
from .. import config, roles, run, genconfig, run_record
|
||
from ..toolkit import Tier2Session, build_toolkit
|
||
from ..middleware import CircuitBreakerMiddleware, Tier2CircuitBreak, Tier2TraceMiddleware
|
||
from . import design_team # 阶段 1 工作室星形多 agent 设计团队
|
||
from . import work_memory # U2:AgentState 三字段工作记忆 + checkpoint(resume 读回)
|
||
except ImportError: # pragma: no cover —— 直接 python studio.py 兜底
|
||
# 本文件在 tier2/gen-worker/worker/agent_loop/studio.py;把 gen-worker/ 加进 sys.path,
|
||
# 使顶层包 `worker` 可解析(包名 worker 合法;目录名 gen-worker 含连字符不可直接 import)。
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from worker import config, roles, run, genconfig, run_record # type: ignore
|
||
from worker.toolkit import Tier2Session, build_toolkit # type: ignore
|
||
from worker.middleware import ( # type: ignore
|
||
CircuitBreakerMiddleware, Tier2CircuitBreak, Tier2TraceMiddleware)
|
||
from worker.agent_loop import design_team # type: ignore
|
||
from worker.agent_loop import work_memory # type: ignore
|
||
|
||
# 成本接线(H3):RecordingChatModel.records → cost_for_run 折¥;new-api 计费参数活读取(取不到回落显式参数)。
|
||
# observability 是 gen-worker 顶层包(非 worker 子包);直跑兜底里已把 gen-worker/ 加进 sys.path。
|
||
try:
|
||
from observability.cost import tokens_by_model_from_records, cost_for_run
|
||
from observability.newapi_pricing import fetch_pricing_params, DEFAULT_QUOTA_PER_UNIT, DEFAULT_USD_RATE
|
||
from observability.trace import make_jsonl_sink
|
||
except Exception: # pragma: no cover —— 直跑/路径未就位兜底
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from observability.cost import tokens_by_model_from_records, cost_for_run # type: ignore
|
||
from observability.newapi_pricing import ( # type: ignore
|
||
fetch_pricing_params, DEFAULT_QUOTA_PER_UNIT, DEFAULT_USD_RATE)
|
||
from observability.trace import make_jsonl_sink # type: ignore
|
||
|
||
# 框架接缝(2.0.2):Agent + ReActConfig + UserMsg + AgentState + BYPASS 权限。
|
||
from agentscope.agent import Agent, ReActConfig
|
||
from agentscope.message import UserMsg
|
||
# L3 视觉软检多模态消息块(2.0.2 源码核验:UserMsg.content 允许 text/data 两类块;
|
||
# 图走 DataBlock(source=Base64Source(media_type='image/png')),AnthropicChatFormatter 会转成
|
||
# Anthropic image base64 入参,见 formatter/_anthropic_formatter.py:227 _format_data_block)。
|
||
from agentscope.message import TextBlock, DataBlock, Base64Source
|
||
from agentscope.state import AgentState
|
||
from agentscope.permission import PermissionContext, PermissionMode
|
||
|
||
|
||
def user_msg(text: str) -> UserMsg:
|
||
"""2.0.1+:UserMsg 必须带 name;字符串 content 会被包成 TextBlock。"""
|
||
return UserMsg(name="user", content=text)
|
||
|
||
|
||
def text_of(resp) -> str:
|
||
"""从 AgentScope Msg 抽纯文本(content 是 block 列表,兼容 dict/对象)。"""
|
||
blocks = getattr(resp, "content", None)
|
||
if blocks is None and hasattr(resp, "get"):
|
||
blocks = resp.get("content")
|
||
out = []
|
||
for b in (blocks or []):
|
||
if isinstance(b, dict):
|
||
if b.get("type") == "text":
|
||
out.append(b.get("text", "") or "")
|
||
elif getattr(b, "type", None) == "text":
|
||
out.append(getattr(b, "text", "") or "")
|
||
return "".join(out)
|
||
|
||
|
||
async def _design_single_agent(brief: str, model) -> str:
|
||
"""阶段 1 兜底:单 design agent(单轮,无工具)把题面 → 富游戏设计稿(原始范式)。
|
||
|
||
这是 design_team 失败/超预算时的 degrade 回落路径——绝不让设计阶段挂掉中断主链(图说 C1:
|
||
设计阶段是过门头号杠杆,但它本身不能成为新的失败点)。
|
||
"""
|
||
agent = Agent(
|
||
name="design",
|
||
system_prompt=roles.DESIGN_SYSTEM,
|
||
model=model,
|
||
react_config=ReActConfig(max_iters=1), # 设计只发散一轮,不自调工具。
|
||
)
|
||
resp = await agent.reply(user_msg(brief))
|
||
return text_of(resp)
|
||
|
||
|
||
async def _design_stage(brief: str, model_factory, *, model_sink: list,
|
||
use_team: bool = True) -> tuple[str, Exception | None]:
|
||
"""阶段 1:产富游戏设计稿。优先工作室星形多 agent 团队(过门头号杠杆),失败 degrade 回单 agent。
|
||
|
||
产物形状 = (连贯设计稿 str, 降级异常 or None)。设计稿与原单 agent 完全一致 → 阶段 2 单写消费接口
|
||
(roles.writer_system 的 design_text)零改、爆炸半径最小化。第二个返回值是**编排层 infra 归因**用:
|
||
团队降级时把捕获的异常带回(None=未降级),run_studio 据它(及 .kind=='timeout')采集 infra_flags 的
|
||
design_team_degraded / wall_timeout(治 F-2 R1 生产盲区)。design_team 造的所有 model(leader + 专家)
|
||
都进 model_sink,供 run_studio 汇总 token 成本(多 agent 烧的 token 必须被成本台账抓到,P0 纪律)。
|
||
|
||
Args:
|
||
brief: 题面。
|
||
model_factory: 零参 callable,每次返回一个新 ChatModel(供 team 给 leader/各专家取独立客户端;
|
||
单 agent 兜底也从它取一个)。
|
||
model_sink: 收集本阶段造的所有 model(team 的 + 兜底单 agent 的),供成本汇总。
|
||
use_team: True → 先试工作室多 agent 团队(默认);False → 直接走单 agent(留旁路/调试用)。
|
||
"""
|
||
design_error: Exception | None = None # 团队降级时捕获的异常(供 infra 归因;None=未降级)
|
||
if use_team:
|
||
try:
|
||
# 工作室星形多 agent 设计团队(worker-as-tool;预算约束 + 超限 degrade 在 design_team 内)。
|
||
return await design_team.run_design_team(brief, model_factory, model_sink=model_sink), None
|
||
except design_team.DesignTeamError as e:
|
||
# 团队失败(超时/预算耗尽/leader 无有效产出)→ degrade 回单 agent,绝不中断主链。
|
||
# 捕获异常带回(其 .kind=='timeout' 标记设计团队墙钟超时),供 infra_flags 采集归因。
|
||
design_error = e
|
||
print(f"[tier2-studio] 阶段 1 工作室多 agent 团队失败,degrade 回单 agent 设计:{e}", flush=True)
|
||
except Exception as e: # noqa: BLE001 —— 任何意外也 degrade(设计阶段绝不能成为新失败点)
|
||
design_error = e
|
||
print(f"[tier2-studio] 阶段 1 工作室多 agent 团队异常,degrade 回单 agent 设计:"
|
||
f"{type(e).__name__}: {e}", flush=True)
|
||
# degrade / 旁路:单 design agent 单轮。它造的 model 也进 sink 供成本汇总。
|
||
m = model_factory()
|
||
model_sink.append(m)
|
||
return await _design_single_agent(brief, m), design_error
|
||
|
||
|
||
def _infra_flags_from_runtime(*, design_error: Exception | None, breaker,
|
||
breaker_tripped: dict | None,
|
||
hit_writer_iter_wall: bool) -> list[str]:
|
||
"""据 studio 运行态收集编排层 infra_flags(observe-only;委托 run_record 纯映射,绝不碰 fail_system)。
|
||
|
||
把四类编排层运行态信号抽成原语后交 run_record.infra_flags_from_runtime 归一(去重、稳定序):
|
||
- design_error:设计阶段降级时捕获的异常(None=未降级;.kind=='timeout' → 设计团队墙钟超时);
|
||
- hit_writer_iter_wall:单写外层 resume 轮数墙是否撞到(预算耗尽仍未收敛);
|
||
- breaker_tripped:熔断返回 dict {'kind':...}(None=未熔断;kind ∈ step_cap/budget/stuck/timeout);
|
||
- breaker.budget_soft_tripped:¥ 软预算是否软停触发(soft 档越软停线,不断链)。
|
||
|
||
单独抽成函数便于用「构造 fake 运行态」单测(不必真跑 agentscope ReAct 全链)。
|
||
"""
|
||
return run_record.infra_flags_from_runtime(
|
||
design_degraded=design_error is not None,
|
||
design_timeout=(getattr(design_error, "kind", None) == "timeout"),
|
||
writer_iter_wall=bool(hit_writer_iter_wall),
|
||
breaker_kind=(breaker_tripped or {}).get("kind") if breaker_tripped else None,
|
||
budget_soft_tripped=bool(getattr(breaker, "budget_soft_tripped", False)),
|
||
)
|
||
|
||
|
||
def _bypass_state() -> AgentState:
|
||
"""造一个权限=BYPASS 的 AgentState,使单写 agent 无人值守自调工具不卡在 ASK。
|
||
|
||
BYPASS = 沙箱/无人值守档位(permission/_types.py):跳过工具 ASK,只认用户显式 deny/ask 规则。
|
||
本线运行在受控生成环境(工具只写工程 workdir / 调本地子进程),适用 BYPASS。
|
||
"""
|
||
return AgentState(permission_context=PermissionContext(mode=PermissionMode.BYPASS))
|
||
|
||
|
||
# ── L3 视觉软检(observe-only · 绝不当门 · 防 Goodhart)──────────────────────────
|
||
# 设计依据:tier2细节图说-D-三层校验与九门.md 图 D1 三层校验全景。
|
||
# L3 = 效果层:特效/美观/好不好玩,处置「只评分、绝不解决、绝不阻塞拒发」。工具 = M3 多模态视觉软检
|
||
# (看截图打分),产出只进质量趋势/告警/给人工终审减负。**永不参与 decision**——decision 只由 L1 硬门裁。
|
||
# 为何死活不当门:扩确定性门判好玩→可被刷的代理指标;纯 LLM 当裁判→Goodhart 红线(优化代理指标偏离真目标)。
|
||
# verdict schema 把 L3.scoreOnly 焊成 const true,本实现只写 score/notes,绝不回写 decision/L1。
|
||
|
||
# L3 玩家人格(单一视觉软检位;wg1 player panel 同款「急性子休闲玩家」口径,凭第一眼观感+反馈下判断)。
|
||
L3_PERSONA = "急性子休闲玩家,凭第一眼观感和系统联动反馈下判断,只信看到的证据、不脑补"
|
||
|
||
|
||
def _b64_data_block(png_path: Path) -> DataBlock | None:
|
||
"""把一张本地 PNG 读成 L3 多模态消息块(DataBlock + Base64Source);读不到/空文件返 None。
|
||
|
||
截图只取证、绝不进硬门(防 Goodhart);本函数只为 L3 软检喂图。任何 IO 异常都吞成 None
|
||
(L3 是 observe-only,缺图绝不能中断主链),由调用方据 None 走「无图」兜底。
|
||
"""
|
||
try:
|
||
if not png_path.exists() or png_path.stat().st_size == 0:
|
||
return None
|
||
data = base64.b64encode(png_path.read_bytes()).decode("ascii")
|
||
return DataBlock(source=Base64Source(media_type="image/png", data=data))
|
||
except Exception as e: # noqa: BLE001 —— 读图失败绝不抛(L3 observe-only),记日志后走无图兜底
|
||
print(f"[tier2-studio][L3] 读截图失败(走无图兜底):{png_path} {type(e).__name__}: {e}", flush=True)
|
||
return None
|
||
|
||
|
||
def _l3_play_summary(verdict: dict | None) -> str:
|
||
"""把 verdict 的真玩取证(playReport / 富游戏门)摘成给 L3 玩家 agent 的运行数据文字。
|
||
|
||
只喂确定性 harness 的客观观测(是否真渲染/真玩到终态/终局语义 state),让玩家 agent 据证据判,
|
||
而非脑补。注意:L3 看的是「真玩取证 + 截图」,judge 的硬门结论(pass/fail)不喂给它当答案
|
||
(出题的和被考的分离)。
|
||
"""
|
||
if not verdict:
|
||
return "(无 verdict:疑似装载即崩,无真玩取证)"
|
||
pr = verdict.get("playReport") or {}
|
||
rich = (((verdict.get("layerResults") or {}).get("L1") or {}).get("richGameGates")) or {}
|
||
snap = pr.get("stateSnapshot") or {}
|
||
lines = [
|
||
f"- 是否捕获 boot 就绪(loaded):{pr.get('loaded')}",
|
||
f"- harness 真输入次数(drivenInputs):{pr.get('drivenInputs')}",
|
||
f"- 是否驱动到终态(reachedTerminal):{pr.get('reachedTerminal')} 终态 phase={pr.get('terminalPhase')}",
|
||
f"- 终局语义 state 快照:coins={snap.get('coins')} ingredients={snap.get('ingredients')} "
|
||
f"orders={snap.get('orders')} phase={snap.get('phase')}",
|
||
]
|
||
# 富游戏三门的子检查名(只给「测了哪些联动」的客观项名,不给过/不过当答案)。
|
||
for name in ("tripleLink", "economy", "latch"):
|
||
g = rich.get(name)
|
||
if isinstance(g, dict):
|
||
sub = [c.get("name") for c in (g.get("checks") or [])]
|
||
lines.append(f"- 富游戏门 {name} 观测子项:{sub}")
|
||
return "确定性 harness 真玩取证:\n" + "\n".join(lines)
|
||
|
||
|
||
def _parse_l3_judge(raw: str) -> dict:
|
||
"""容错解析 L3 玩家 agent 的 JSON 评判({completeness,fun,problems,verdict,note})。
|
||
|
||
便宜模型/M3 偶尔吐带前后缀的脏 JSON,故先抠出第一个 {...} 再 json.loads;全失败 → 给一个
|
||
score=null 的诚实空判(observe-only,绝不因解析失败中断或翻 GREEN)。
|
||
"""
|
||
obj = None
|
||
try:
|
||
obj = json.loads(raw)
|
||
except Exception:
|
||
# 抠出首个花括号块再试(去掉模型可能加的 ```json 包裹 / 解说前后缀)。
|
||
try:
|
||
s = raw.find("{")
|
||
e = raw.rfind("}")
|
||
if s >= 0 and e > s:
|
||
obj = json.loads(raw[s:e + 1])
|
||
except Exception:
|
||
obj = None
|
||
if not isinstance(obj, dict):
|
||
return {"completeness": None, "fun": None, "problems": [], "verdict": "?",
|
||
"note": (raw or "")[:160]}
|
||
probs = obj.get("problems")
|
||
if not isinstance(probs, list):
|
||
obj["problems"] = [str(probs)] if probs else []
|
||
return obj
|
||
|
||
|
||
def _fun_to_score(fun) -> float | None:
|
||
"""把玩家 agent 的 fun(1-5 锚)线性映射到 verdict.L3.score(0-100;schema 允许 number|null)。
|
||
|
||
映射:fun=1→0,fun=5→100(score=(fun-1)/4*100)。非法/缺失 → None(诚实留空,不编分)。
|
||
"""
|
||
try:
|
||
f = float(fun)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if f < 1 or f > 5:
|
||
return None
|
||
return round((f - 1) / 4 * 100, 1)
|
||
|
||
|
||
async def _l3_player_panel(game_id: str, brief: str, verdict: dict | None, model) -> dict:
|
||
"""L3 视觉软检:player_system 提示词 + 真截图 + 真玩取证,调一次 M3 produce score/note。
|
||
|
||
**observe-only 铁律**:本函数只产 {score, notes, raw}(写进 verdict.L3),绝不回写 decision/L1/accept;
|
||
任何失败(截不到图/模型超时/解析失败)→ score=None + notes 记错,绝不中断、绝不翻 GREEN。
|
||
|
||
Args:
|
||
game_id: 工程标识(定位 evidence 目录里的真截图)。
|
||
brief: 题面(给玩家 agent 上下文)。
|
||
verdict: 最近一次 run_gates 的 verdict(摘真玩取证;无则只凭截图判)。
|
||
model: 复用单写/设计同档 M3 模型客户端(AnthropicChatModel;含 thinking,多模态由 formatter 适配)。
|
||
|
||
Returns:
|
||
{score: float|None, notes: [str], raw: {...}}。score 即 verdict.L3.score 的取值。
|
||
"""
|
||
notes: list[str] = []
|
||
# ① 取真截图:优先玩后帧(after-play.png,更能看出系统联动/进展),回落首帧(first-paint.png)。
|
||
# 真图由 CDP harness 在 run_gates 真玩时落盘(mini-desktop);6c6g 无 chrome → 无图,走无图软检。
|
||
ev_dir = run._workdir(game_id) / "evidence"
|
||
img_block = None
|
||
used_shot = None
|
||
for fn in ("after-play.png", "first-paint.png"):
|
||
blk = _b64_data_block(ev_dir / fn)
|
||
if blk is not None:
|
||
img_block, used_shot = blk, fn
|
||
break
|
||
if used_shot:
|
||
notes.append(f"L3 看图:{used_shot}")
|
||
else:
|
||
notes.append("L3 无截图(6c6g 无 chrome 或 harness 未产图),仅凭真玩取证软评")
|
||
|
||
# ② 组装多模态 UserMsg:文本(题面 + 真玩取证)+ 截图块(若有)。
|
||
summary = _l3_play_summary(verdict)
|
||
user_text = (f"游戏题面:\n{(brief or '').strip()}\n\n{summary}\n\n"
|
||
"请基于以上确定性真玩取证(及截图,若附)给出你的玩家评判,严格只输出约定 JSON。")
|
||
content: list = [TextBlock(text=user_text)]
|
||
if img_block is not None:
|
||
content.append(TextBlock(text="【玩后截图 after-play.png(如缺则为首帧)】"))
|
||
content.append(img_block)
|
||
user = UserMsg(name="user", content=content)
|
||
|
||
# ③ 调一次 M3 玩家 agent(单轮、无工具;system = player_system 提示词)。任何异常吞成 score=None。
|
||
try:
|
||
agent = Agent(
|
||
name="l3-player",
|
||
system_prompt=roles.player_system(L3_PERSONA),
|
||
model=model,
|
||
react_config=ReActConfig(max_iters=1), # 软检只发散一轮、不调工具(它也没 toolkit)
|
||
)
|
||
resp = await agent.reply(user)
|
||
raw = text_of(resp)
|
||
except Exception as e: # noqa: BLE001 —— 模型超时/网关 502 等绝不中断主链(L3 observe-only)
|
||
print(f"[tier2-studio][L3] 玩家 agent 调用失败(score=null 兜底):{type(e).__name__}: {e}", flush=True)
|
||
notes.append(f"L3 模型调用失败:{type(e).__name__}: {e}")
|
||
return {"score": None, "notes": notes, "raw": None}
|
||
|
||
# ④ 解析评判 → fun 映射 score;problems/note 进 notes(给人工终审减负,非判定)。
|
||
judge = _parse_l3_judge(raw)
|
||
score = _fun_to_score(judge.get("fun"))
|
||
if score is None:
|
||
notes.append(f"L3 fun 缺失/非法(fun={judge.get('fun')}),score 留 null")
|
||
if judge.get("note"):
|
||
notes.append(f"L3 总评:{str(judge.get('note'))[:160]}")
|
||
for p in (judge.get("problems") or [])[:5]:
|
||
notes.append(f"L3 劣化信号:{str(p)[:120]}")
|
||
print(f"[tier2-studio][L3] game={game_id} fun={judge.get('fun')} completeness={judge.get('completeness')} "
|
||
f"→ score={score}(observe-only,不参与 decision)", flush=True)
|
||
return {"score": score, "notes": notes,
|
||
"raw": {"completeness": judge.get("completeness"), "fun": judge.get("fun"),
|
||
"verdict": judge.get("verdict")}}
|
||
|
||
|
||
async def run_studio(
|
||
game_id: str,
|
||
brief: str,
|
||
play_spec: dict | None = None,
|
||
*,
|
||
model_name: str | None = None,
|
||
max_tokens: int | None = None,
|
||
thinking_budget: int | None = None,
|
||
writer_max_iters: int | None = None,
|
||
fixture_hint: str = "",
|
||
do_design: bool = True,
|
||
resume_from_checkpoint: bool = False,
|
||
) -> dict:
|
||
"""tier2 富游戏单写主编排。返回结果 dict(含 design / 源工程交付 / verdict / 熔断)。
|
||
|
||
Args:
|
||
game_id: 工程标识(决定 workdir = game-runtime/games/_tier2-gen/<game_id>)。
|
||
brief: 一句话/题面。
|
||
play_spec: 真玩驱动规格(driver/assertAfterPlay/expectLatch;run_gates 用)。
|
||
model_name: M3 模型名(默认 env TIER2_MODEL 或 MiniMax-M3)。
|
||
max_tokens / thinking_budget: M3 输出上限 / thinking 预算(硬约束 max_tokens > thinking_budget)。
|
||
None → 透传给 config.build_model 由它运行时读 generation.yaml(默认 16000 / 8000)。
|
||
writer_max_iters: 单写 ReAct 放开的最大轮数(C3 建议整局 ≤40)。None → 运行时读
|
||
generation.yaml 的 iteration.writer_max_iters(默认 40)。
|
||
fixture_hint: 品类/靶子提示(如 mini-肥鹅 三系统约定)。
|
||
do_design: 是否先跑阶段 1 设计(默认 True)。
|
||
resume_from_checkpoint: True → 开局先读回上次工作记忆 checkpoint(A2 resume 路径①:从落库的
|
||
state 读回而非只靠内存),把已写文件/锁定文件/数据表状态拼进首条 kick 让续跑 agent 立刻对齐。
|
||
读不到则照常全新开局(work_memory.load/resume_brief 均 best-effort、绝不抛)。
|
||
"""
|
||
t0 = time.perf_counter()
|
||
mname = model_name or config.model_name_from_env()
|
||
# 旋钮外置(运行时读):writer_max_iters 未显式传入(None)→ 读 generation.yaml 的 iteration.writer_max_iters
|
||
# (default=现值 40);它要传给 ReActConfig(max_iters=...),不能是 None,故在此解析。max_tokens /
|
||
# thinking_budget 为 None 时直接透传给 config.build_model(由它运行时读,口径统一,不在此重复解析)。
|
||
if writer_max_iters is None:
|
||
writer_max_iters = genconfig.get("iteration", "writer_max_iters", 40)
|
||
|
||
# ── 阶段 1:工作室星形多 agent 设计(过门头号杠杆;失败 degrade 回单 agent)──
|
||
# model_factory:零参偏函数,每次返回一个新 M3 客户端(同档 mname);team 给 leader/各专家各取一个独立
|
||
# 客户端,兜底单 agent 也从它取一个。design_models 收集本阶段造的所有客户端,供下方成本汇总(多 agent
|
||
# 烧的 token 必须进台账,P0 纪律)。design_text 形状 = str,与原单 agent 一致 → 阶段 2 消费接口零改。
|
||
design_text = ""
|
||
design_error: Exception | None = None # 设计团队降级时捕获的异常(供 infra_flags 归因;None=未降级)
|
||
design_models: list = [] # 阶段 1 造的所有模型客户端(team leader + 专家 + 兜底单 agent),供成本汇总
|
||
if do_design:
|
||
design_model_factory = functools.partial(
|
||
config.build_model, mname, max_tokens=max_tokens, thinking_budget=thinking_budget)
|
||
design_text, design_error = await _design_stage(
|
||
brief, design_model_factory, model_sink=design_models)
|
||
|
||
# ── 阶段 2:单写 ReAct(放开 max_iters + 九工具 + 四熔断 + trace 接线 + BYPASS 权限)──
|
||
session = Tier2Session(game_id, play_spec=play_spec)
|
||
toolkit = build_toolkit(session)
|
||
breaker = CircuitBreakerMiddleware() # 四道熔断 + 软刹(on_reply / on_system_prompt)
|
||
# trace 接线(H1/H2):traceId 用 game_id(贯穿本次生成,对接 verdict.evidence.traceId / 成本关联键)。
|
||
# JSONL sink → 把每条 trace step(五字段 schema:traceId/step/cost/verdict/timestamp + ext)
|
||
# 追加写到产物 workdir/trace.jsonl,供成本对账 / replay 用;sink 失败 best-effort 只告警不阻断主链。
|
||
# 单实例对应单 agent;它与 breaker 都挂 on_reply,由框架按 middlewares 列表序串成洋葱链(trace 在外、纯旁路)。
|
||
_trace_jsonl_path = run._workdir(game_id) / "trace.jsonl"
|
||
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=make_jsonl_sink(_trace_jsonl_path))
|
||
writer_model = config.build_model(
|
||
mname, max_tokens=max_tokens, thinking_budget=thinking_budget)
|
||
|
||
writer = Agent(
|
||
name="writer",
|
||
system_prompt=roles.writer_system(brief, design_text, fixture_hint=fixture_hint),
|
||
model=writer_model,
|
||
toolkit=toolkit,
|
||
# trace middleware 列在 breaker 前 → 它是更外层洋葱:先 ingest 事件(纯旁路),再进熔断巡检。
|
||
# 熔断抛 Tier2CircuitBreak 时,已 ingest 的轨迹仍在 adapter 内(可收口反查),不丢。
|
||
middlewares=[tracer, breaker],
|
||
state=_bypass_state(),
|
||
react_config=ReActConfig(max_iters=writer_max_iters),
|
||
# 历史压缩配置(U2;图说 C4):2.0.2 Agent 每轮 reason 前自动调 compress_context,超阈值才触发;
|
||
# 这里换成 tier2 富游戏语义的压缩提示词 + summary 模板,保证长程多文件生成被压缩时,平台锁定
|
||
# 文件清单 / 数据表 schema / 已定设计约定优先进 summary,不被当普通早期对话丢(防 12+ 文件丢约定)。
|
||
context_config=config.build_context_config(),
|
||
)
|
||
|
||
# ── resume 读回(A2 路径①;U2)──
|
||
# 若开了 resume_from_checkpoint,先把上次工作记忆 checkpoint 读回,渲染成一段续跑提示拼进首条 kick,
|
||
# 让续跑 agent 立刻知道之前干到哪、哪些文件锁死、数据表填没填(从落库 state 读回,非只靠内存)。
|
||
# cumulative_iter 也从 checkpoint 续上(2.0.2 state.cur_iter 每 reply 归零,累计值由本编排层维护)。
|
||
cumulative_iter = 0
|
||
resume_ckpt = work_memory.load(game_id) if resume_from_checkpoint else None
|
||
if resume_ckpt:
|
||
try:
|
||
cumulative_iter = int(resume_ckpt.get("cumulativeIter") or 0)
|
||
except (TypeError, ValueError):
|
||
cumulative_iter = 0
|
||
|
||
breaker_tripped = None
|
||
hit_writer_iter_wall = False # 单写外层 resume 轮数墙:预算耗尽仍未收敛(infra_flags 采集用)
|
||
final_text = ""
|
||
# ── 外层有界自纠循环(spike feie-001 根因 1)──
|
||
# AgentScope 2.0.2 原生 ReAct 在「模型产出无 tool_call 的纯文本回合」即退出(_agent.py:612)——
|
||
# 实测 M3 调一次 run_gates 看到 decision=fix 就产空文本收尾、循环退出(没到 max_iters、没熔断),
|
||
# = 看一次 verdict 就放弃、不自纠。纯内层 ReAct 给不了「停了再踹回去继续修」的保证。
|
||
# 故在编排层补一道有界 resume:agent 停下后,若【没真 finish】且【门没绿】且【还有预算】,
|
||
# 就带着 verdict 失败反馈再 reply 一次(AgentScope agent 跨 reply 保留 memory,等于原地续修),
|
||
# 直到 finish / 门绿 / resume 预算耗尽 / 熔断。这是 wg1「外层 Python repair」范式的回归——
|
||
# 实证表明纯自治 ReAct 会过早放弃,有界外层踹更稳,且不放松任何验收门(门仍是 judge 纯代码判)。
|
||
# 外层 resume 上限(每次 resume 内层 ReAct 仍可多轮;总轮数受 middleware 硬熔断兜底)。
|
||
# 旋钮外置(运行时读 generation.yaml iteration.max_resumes;default=现值 6)。
|
||
max_resumes = genconfig.get("iteration", "max_resumes", 6)
|
||
kick_text = (
|
||
"开始实现这款富游戏。先 scaffold_init 起手,然后在循环里 write_source→(validate_datatable)→"
|
||
"build→run_gates→read_verdict→针对失败门 write_source 修→再 run_gates……门全绿后调 finish 交付。"
|
||
"切记:run_gates 的 verdict 是机器判的,看到 decision=fix 不要停下来收尾,要按失败门继续修。")
|
||
# resume:把上次 checkpoint 渲染成续跑提示拼到首条 kick 前(让续跑 agent 立刻对齐已有进度/锁定文件)。
|
||
_resume_hint = work_memory.resume_brief(resume_ckpt)
|
||
if _resume_hint:
|
||
kick_text = _resume_hint + "\n\n" + kick_text
|
||
try:
|
||
for attempt in range(max_resumes + 1):
|
||
resp = await writer.reply(user_msg(kick_text))
|
||
final_text = text_of(resp)
|
||
# ── 工作记忆 checkpoint(A2:每轮 checkpoint 的 spike 落地;U2)──
|
||
# 每次外层 reply 结束累加本程 ReAct 轮次(2.0.2 state.cur_iter 每 reply 归零,故累计由本层维护),
|
||
# 并快照三字段工作记忆(落 JSON checkpoint + 镜像进 AgentState.tasks_context)。best-effort,绝不抛。
|
||
try:
|
||
cumulative_iter += int(getattr(writer.state, "cur_iter", 0) or 0)
|
||
except Exception: # noqa: BLE001 —— 取 cur_iter 失败不影响主链
|
||
pass
|
||
work_memory.snapshot(writer, session, cumulative_iter=cumulative_iter, brief=brief)
|
||
# 真 finish(finish 工具组装了源工程)→ 收敛,退出外层。
|
||
if session.finished is not None:
|
||
break
|
||
# 读最近 verdict:门绿则不必再踹(理论上应已 finish,但容错处理)。
|
||
v = session.last_verdict or {}
|
||
l1 = ((v.get("layerResults") or {}).get("L1") or {})
|
||
if v.get("decision") == "accept" and l1.get("passed"):
|
||
print(f"[tier2-studio] game={game_id} 门已绿但未 finish(attempt={attempt});"
|
||
"踹一脚让它 finish。", flush=True)
|
||
kick_text = "验收门已全绿。现在直接调 finish 交付源工程,不要再改。"
|
||
continue
|
||
if attempt >= max_resumes:
|
||
# 撞外层 resume 轮数墙:预算耗尽仍未收敛 → 置 infra 标记(writer_iter_wall),供退路树归因。
|
||
hit_writer_iter_wall = True
|
||
print(f"[tier2-studio] game={game_id} resume 预算耗尽({max_resumes} 次)仍未收敛,停。",
|
||
flush=True)
|
||
break
|
||
# 门没绿 + agent 自行停下 → 带 verdict 失败反馈踹回去继续修(头号自纠机制;
|
||
# 带 game_id 使经济门失败附三个数数值证据 + H 门附断言清单,F-1 反馈契约)。
|
||
fb = run.verdict_feedback(v, game_id=game_id) if v else "尚无 verdict;先 run_gates 真玩取证。"
|
||
print(f"[tier2-studio] game={game_id} attempt={attempt} agent 过早停下(门未绿),"
|
||
f"带反馈 resume 续修。decision={v.get('decision')}", flush=True)
|
||
kick_text = (
|
||
"你刚才停下了,但验收门还没全绿——不要放弃。这是上次 run_gates 的失败门:\n"
|
||
f"{fb}\n"
|
||
"请在循环里:据失败门 write_source 针对性修(数据表 schema 错就先 validate_datatable 看平台要的 key),"
|
||
"build→run_gates→read_verdict,直到门绿再 finish。一步步来,先修最关键的致命门。")
|
||
except Tier2CircuitBreak as e:
|
||
# 熔断触发:据 kind 落 breakerKind(对接 tier2-verdict)。
|
||
breaker_tripped = {"kind": e.kind, "reason": e.reason}
|
||
print(f"[tier2-studio] game={game_id} 熔断 kind={e.kind}: {e.reason}", flush=True)
|
||
except Exception as e: # noqa: BLE001 —— 其余异常落结果、不静默吞(可追溯)
|
||
breaker_tripped = {"kind": None, "reason": f"未捕获异常:{type(e).__name__}: {e}"}
|
||
print(f"[tier2-studio] game={game_id} 异常:{type(e).__name__}: {e}", flush=True)
|
||
|
||
# ── 收口落库(F1 要素⑦ / 版本寻址 · 加性,不改 GEN_DIR 落盘)──────────────────────────
|
||
# ReAct 收敛后:若真 finish 了(session.finished = 源工程七要素形状),把它经 store 持久化进落库面,
|
||
# 返回 {id, versionId, sourceHash} 写进 result(改源重建会得新 versionId,旧版本仍可按 versionId 取回)。
|
||
# spike 期 store = LocalFsStore(本地真落 _store/);产线接后端切 BackendStore(seam)。
|
||
# 时间戳显式传入(与 derive_version_id 同口径);未 finish → addressing=None(不伪造落库)。best-effort,绝不抛。
|
||
store_addressing = run.persist_source_project(
|
||
game_id, session.finished, session.file_list(), now_ts=time.time())
|
||
|
||
# ── L2 设计符合层 full(observe-only · 收口后算一次 · 零 LLM · 绝不参与 accept/reject)──────
|
||
# 时机:ReAct 收敛、跑过门后(无论 finish/kill/熔断)算一次。它从已落盘源工程(数据表 + src/systems/)
|
||
# 确定性算「设计声明的系统在不在 / 合成链 DAG / 订单可达 / 经济胜负自洽」四项硬结构信号 + 设计稿弱对账,
|
||
# 写进 verdict.layerResults.L2.{passed(advisory),signals,mismatches}。本阶段纪律:enforced 恒 false、
|
||
# **绝不改 decision**——只为 observe→enforce 积累信号。金标 fixture 先天满足四项判据,故金标 L2 必 passed=true,
|
||
# 这是将来提门时「金标必须过」的校准基线。失败兜底:数据表不可读 → passed=False + mismatch,绝不中断主链。
|
||
l2_passed = None
|
||
l2_signals: list[str] = []
|
||
l2_mismatches: list[str] = []
|
||
try:
|
||
l2 = run.compute_l2_signals(game_id, design_text)
|
||
l2_passed = l2.get("passed")
|
||
l2_signals = l2.get("signals") or []
|
||
l2_mismatches = l2.get("mismatches") or []
|
||
v = session.last_verdict
|
||
if isinstance(v, dict):
|
||
lr = v.setdefault("layerResults", {})
|
||
l2seg = lr.setdefault("L2", {})
|
||
l2seg["enforced"] = False # spike 期恒 observe-only(不计入 decision)
|
||
l2seg["passed"] = l2_passed # boolean|null;advisory,不影响 decision
|
||
# signals 累加(保留 harness 已写的 l2Signals,再并入 full 计算信号 + 不符项)。
|
||
prev = l2seg.get("signals") or []
|
||
merged = list(prev)
|
||
for s in (l2_signals + [f"[不符] {m}" for m in l2_mismatches]):
|
||
if s not in merged:
|
||
merged.append(s)
|
||
l2seg["signals"] = merged
|
||
print(f"[tier2-studio][L2] game={game_id} passed(advisory)={l2_passed} "
|
||
f"signals={len(l2_signals)} mismatches={len(l2_mismatches)}"
|
||
"(observe-only,不参与 decision)", flush=True)
|
||
except Exception as e: # noqa: BLE001 —— L2 整体兜底:任何异常都不中断主链、不翻 decision
|
||
print(f"[tier2-studio][L2] 设计符合层计算异常(不影响 decision):"
|
||
f"{type(e).__name__}: {e}", flush=True)
|
||
l2_mismatches = [f"L2 计算异常:{type(e).__name__}: {e}"]
|
||
|
||
# ── L3 视觉软检(observe-only · 收口后调一次 · 绝不参与 accept/reject)───────────────
|
||
# 时机:过完 L1 九门/富游戏门、ReAct 收敛后(无论 finish/kill/熔断)调一次。
|
||
# 它用 player_system 提示词 + 真截图 + 真玩取证产 score/简评,只写进 verdict.L3.score/notes。
|
||
# **不改 decision、不改 L1/runnableOk/accept**(裁决仍只看 L1 硬门)——保金标 GREEN 不被 L3 翻动(防 Goodhart)。
|
||
# 失败兜底:截不到图/模型超时/解析失败 → score=None + log,绝不中断、绝不翻 GREEN。
|
||
# 复用 writer_model(同档 M3 客户端);其 token 计入本 run 成本(L3 也是这次生成的真实开销)。
|
||
l3_score = None
|
||
l3_notes: list[str] = []
|
||
try:
|
||
l3 = await _l3_player_panel(game_id, brief, session.last_verdict, writer_model)
|
||
l3_score = l3.get("score")
|
||
l3_notes = l3.get("notes") or []
|
||
# observe-only:把 L3 结果写回最近 verdict 的 layerResults.L3(scoreOnly 恒 true,只填 score/notes)。
|
||
# 只在已有 verdict(真跑过门)时回写;verdict 缺失则 L3 结果只留在 result.l3(下方),不伪造 verdict。
|
||
# L3 升门准备(确定性恶性渲染判据):吃单元 A 的 render-sanity / render-reflects-state
|
||
# (harness 真玩落进 verdict.humanPlayability),提「漏底文本 / 空棋盘」这类**确定性**恶性失败,
|
||
# 整理进 L3.notes 并标 egregious=true(信号:建议主会话校准后提 fatal)。**仍 advisory、不改 decision**。
|
||
egr = run.derive_l3_egregious(session.last_verdict)
|
||
l3_egregious_notes = egr.get("notes") or []
|
||
if l3_egregious_notes:
|
||
l3_notes = list(l3_notes) + l3_egregious_notes # 并入软检 notes(下方写回 verdict 与 result.l3)
|
||
v = session.last_verdict
|
||
if isinstance(v, dict):
|
||
lr = v.setdefault("layerResults", {})
|
||
l3seg = lr.setdefault("L3", {})
|
||
l3seg["scoreOnly"] = True # schema const true 不变量(防 L3 退化成阻塞门)
|
||
l3seg["score"] = l3_score # number|null;observe-only,不影响 decision
|
||
# notes 累加(保留 harness 已写的 l3Notes,再并入软检备注 + 恶性渲染确定性条目)。
|
||
prev = l3seg.get("notes") or []
|
||
l3seg["notes"] = list(prev) + [n for n in l3_notes if n not in prev]
|
||
# 恶性渲染确定性判据写成结构化 advisory 段(machine-readable 升门信号;仍不改 decision/scoreOnly)。
|
||
l3seg["egregiousRenderFailure"] = {
|
||
"egregious": bool(egr.get("egregious")),
|
||
"suggestFatal": bool(egr.get("suggestFatal")),
|
||
"reasons": egr.get("reasons") or [],
|
||
}
|
||
if egr.get("egregious"):
|
||
# 仅日志告警(给主会话升门校准信号);不写任何字段进 decision/L1/runnableOk,严守 advisory。
|
||
print(f"[tier2-studio][L3] game={game_id} 检出确定性恶性渲染失败 egregious=true "
|
||
f"reasons={egr.get('reasons')} —— 建议主会话校准后提 fatal(当前 advisory,不改 decision)。",
|
||
flush=True)
|
||
except Exception as e: # noqa: BLE001 —— L3 整体兜底:任何异常都不中断主链、不翻 GREEN
|
||
print(f"[tier2-studio][L3] 软检整体异常(score=null 兜底,不影响 decision):"
|
||
f"{type(e).__name__}: {e}", flush=True)
|
||
l3_notes = [f"L3 软检整体异常:{type(e).__name__}: {e}"]
|
||
|
||
# ── 汇总结果 ──
|
||
wall_s = round(time.perf_counter() - t0, 1)
|
||
# 成本取证:RecordingChatModel.records → (in, out, cached) 汇总(M3 路 + 设计阶段;按模型分列供折¥)。
|
||
# 一次 run 跨多个模型客户端:阶段 1 = 工作室多 agent 团队的 leader + 各专家(或 degrade 后的单 agent),
|
||
# 全收在 design_models;阶段 2 = 单写 writer_model。本期同档 mname,但各客户端各一份 records。
|
||
# 多 agent 烧的 token 必须全部进台账(P0 纪律)——故遍历 design_models 全员 + writer_model,
|
||
# 按模型名聚合成 records_by_model 再汇总,与 cost.tokens_by_model_from_records 的入参形状对齐。
|
||
tin = tout = tcached = 0
|
||
records_by_model: dict[str, list] = {}
|
||
for m in (list(design_models) + [writer_model]):
|
||
recs = getattr(m, "records", None) or []
|
||
for r in recs:
|
||
tin += r[0]; tout += r[1]; tcached += (r[2] if len(r) >= 3 else 0)
|
||
if recs:
|
||
# 同档多客户端(设计团队全员 + 单写)的 records 合并到同一模型名下(本期 mname 单档)。
|
||
records_by_model.setdefault(mname, []).extend(recs)
|
||
|
||
# ── 成本接线(H3):tokens_by_model → cost_for_run 折¥,写进 result['cost'] ──
|
||
# 计费参数活读取:优先 new-api /api/pricing + /api/status 权威活值;取不到则回落显式默认参数 + 告警
|
||
# (best-effort 铁律:取价失败绝不中断主链;回落口径见下)。折算本身是纯函数,绝不抛(cost.compute 内部容错)。
|
||
tokens_by_model = tokens_by_model_from_records(records_by_model)
|
||
params = fetch_pricing_params() # None = 活读取失败(已在内部 best-effort 告警)
|
||
if params is not None:
|
||
# 活读取成功:用 new-api 权威倍率折¥。
|
||
pricing, qpu, usd_rate = params["pricing"], params["qpu"], params["usd_rate"]
|
||
pricing_source = "newapi-live"
|
||
else:
|
||
# 回落:无活倍率则 pricing 空表(cost.compute 对缺 model 取保守默认 model_ratio=0 → ¥=0),
|
||
# qpu/usd_rate 用 new-api 编译默认(500000 / 7.3)。¥ 折不出真值但不中断、不抛;
|
||
# 接线方(RunRecord B2)据 pricing_source=fallback 知道这笔 ¥ 不可信(待补真倍率重算)。
|
||
pricing, qpu, usd_rate = {}, DEFAULT_QUOTA_PER_UNIT, DEFAULT_USD_RATE
|
||
pricing_source = "fallback"
|
||
print(f"[tier2-studio] game={game_id} 成本折算回落显式默认参数(pricing 空表,¥ 不可信);"
|
||
"真值待编排器据 new-api pricing 重算。", flush=True)
|
||
cost = cost_for_run(tokens_by_model, pricing, qpu, usd_rate)
|
||
# cost = {cost_rmb, tokens_by_model, by_model};附带本次折算用的计费口径(审计追溯 + 标注 ¥ 是否可信)。
|
||
cost["pricingSource"] = pricing_source
|
||
cost["qpu"] = qpu
|
||
cost["usdRate"] = usd_rate
|
||
|
||
# ── trace 收口(H1/H2):读 tracer 摘要(traceId / 步数 / 丢弃数),供编排器对账反查 ──
|
||
trace_summary = tracer.summary()
|
||
|
||
# ── 编排层 infra 归因采集(observe-only;治 F-2 R1 生产盲区)──────────────────────────
|
||
# 把本次 run 的四类编排层运行态信号(设计团队降级 / 单写撞轮数墙 / 熔断返回 kind / ¥ 软预算软停)
|
||
# 归一成 infra_flags 受控值集,写进 result 供 batch_run 落账、退路树 decide_n5 打印分布归因。
|
||
# **绝不塞 fail_system**(游戏系统桶),与退路树 R1/R2 分流键语义正交;绝不参与 decision。
|
||
infra_flags = _infra_flags_from_runtime(
|
||
design_error=design_error, breaker=breaker,
|
||
breaker_tripped=breaker_tripped, hit_writer_iter_wall=hit_writer_iter_wall)
|
||
if infra_flags:
|
||
print(f"[tier2-studio] game={game_id} 编排层 infra_flags={infra_flags}"
|
||
"(observe-only,退路树归因用,不改 decision)", flush=True)
|
||
|
||
result = {
|
||
"game_id": game_id,
|
||
"model": mname,
|
||
"design_text": design_text,
|
||
"writer_final_text": final_text,
|
||
# 单写 agent 交付的源工程(finish 工具组装的 A3 形状;未 finish 则 None)。
|
||
"source_project": session.finished,
|
||
"finished": session.finished is not None,
|
||
# 收口落库寻址(F1 要素⑦):{id, versionId, sourceHash} 或 None(未 finish / 落库失败)。
|
||
# id=game_id(同款多次改源重建挂同一 id);versionId 改源重建即新;sourceHash=内容指纹幂等键。
|
||
# spike 期落本地 LocalFsStore;产线接后端 BackendStore。接线方(RunRecord)可据此关联落库版本。
|
||
"store_addressing": store_addressing,
|
||
# 最近一次 run_gates 的 verdict(tier2-verdict 形状;judge 纯代码产出,零自评;若真跑过门,
|
||
# 其 layerResults.L3.score/notes 已由上面 L3 软检 observe-only 回填)。
|
||
"last_verdict": session.last_verdict,
|
||
# L2 设计符合层结果(observe-only,绝不参与 decision;enforced 恒 false,passed 是 advisory 判定)。
|
||
# verdict 缺失时仍在此可见(不伪造 verdict);金标 fixture 应 passed=true(将来提门校准基线)。
|
||
"l2": {"passed": l2_passed, "signals": l2_signals, "mismatches": l2_mismatches},
|
||
# L3 视觉软检结果(observe-only,绝不参与 decision;verdict 缺失时仍在此可见,不伪造 verdict)。
|
||
"l3": {"score": l3_score, "notes": l3_notes},
|
||
# 四熔断触发记录(对接 verdict.breakerKind:step_cap/budget/stuck/timeout)。
|
||
"circuit_break": breaker_tripped,
|
||
"breaker_counters": {"tool_calls": breaker.tool_calls, "model_calls": breaker.model_calls},
|
||
# 编排层 infra 归因(observe-only;取值 ∈ run_record.INFRA_FLAGS 受控值集,可空)。
|
||
# RunRecord 接线(batch_run.result_to_record)从此抽 infra_flags 落台账,供退路树 decide_n5
|
||
# 打印各轮分布归因(治 F-2 R1「设计团队超时降级路」被误判分散的生产盲区);绝不参与 decision。
|
||
"infra_flags": infra_flags,
|
||
# 工作记忆 checkpoint 摘要(U2,A2:跨 resume 累计轮次 + 是否从 checkpoint 续跑)。
|
||
# 累计轮次由本编排层维护(2.0.2 state.cur_iter 每 reply 归零);checkpoint 详情落 workdir/.tier2-work-memory.json。
|
||
"work_memory": {"cumulativeIter": cumulative_iter, "resumedFromCheckpoint": resume_ckpt is not None},
|
||
# 成本取证(M3 路 token;扁平汇总,向后兼容既有消费方)。
|
||
"tokens": {"prompt": tin, "completion": tout, "cached": tcached},
|
||
# 成本接线(H3,新增):run 级折¥ + per-model token 明细 + 计费口径
|
||
# (cost_rmb=run 级总¥;by_model=逐档折算明细;pricingSource=newapi-live/fallback)。
|
||
# RunRecord 接线(B2)从此抽 cost_yuan / tokens_by_model 填 G4 采集字段。
|
||
"cost": cost,
|
||
# trace 收口摘要(H1/H2,新增):{traceId, steps, dropped, middlewareDropped}。
|
||
# trace step 既留 tracer.adapter.steps 内存(供本摘要 / replay),又经 JsonlFileSink 落盘
|
||
# 到 workdir/trace.jsonl(控制面 phase-1 已接,见上文 make_jsonl_sink 接线点)。
|
||
"trace": trace_summary,
|
||
"wall_s": wall_s,
|
||
"file_tree": session.file_tree(),
|
||
}
|
||
return result
|
||
|
||
|
||
def main() -> None:
|
||
"""CLI:tier2 单写 studio(真跑需 mini-desktop:chrome + esbuild;6c6g 仅静态校验)。"""
|
||
ap = argparse.ArgumentParser(description="tier2 富游戏单写 ReAct studio")
|
||
ap.add_argument("game_id")
|
||
ap.add_argument("--brief", required=True)
|
||
ap.add_argument("--model", default=None, help="M3 模型名(默认 env TIER2_MODEL / MiniMax-M3)")
|
||
ap.add_argument("--max-tokens", type=int, default=16000)
|
||
ap.add_argument("--thinking-budget", type=int, default=8000)
|
||
ap.add_argument("--max-iters", type=int, default=40, help="单写 ReAct 放开的最大轮数")
|
||
ap.add_argument("--no-design", action="store_true", help="跳过阶段 1 设计")
|
||
ap.add_argument("--resume", action="store_true",
|
||
help="从上次工作记忆 checkpoint 续跑(A2 resume:读回已写文件/锁定文件/数据表状态拼进首条 kick)")
|
||
args = ap.parse_args()
|
||
|
||
res = asyncio.run(run_studio(
|
||
args.game_id, args.brief, play_spec=None,
|
||
model_name=args.model, max_tokens=args.max_tokens,
|
||
thinking_budget=args.thinking_budget, writer_max_iters=args.max_iters,
|
||
do_design=not args.no_design, resume_from_checkpoint=args.resume))
|
||
cb = res.get("circuit_break")
|
||
print(f"\n[tier2-studio] game={res['game_id']} finished={res['finished']} "
|
||
f"wall={res['wall_s']}s tokens(in/out)={res['tokens']['prompt']}/{res['tokens']['completion']} "
|
||
f"{'熔断=' + cb['kind'] if cb else ''}")
|
||
sys.exit(0 if res["finished"] else 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|