裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。 - 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍 midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief, fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段; 金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批 - 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册 源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓), registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示 - 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式 反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+ 盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow) - 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订): generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限); 3 例盲案 M3 重判全 accept、¥0.026/局 - 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的 放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读 accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传; tier2 gate_judge/genconfig 同步判定配置与消费 测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
429 lines
28 KiB
Python
429 lines
28 KiB
Python
"""cheap_service_driver.py — 便宜档 · 单 POST 消费方(镜像 tier2 bootstrap + control_plane.single_post,cheap 收口)。
|
||
|
||
worker_service.py 收 §6.1 job 后,不再进程内 run_studio,而是驱动 cheap Service /chat:
|
||
注册 OpenAI 兼容凭据 → 建 agent(system prompt 的 ⟦G⟧=后端 gameId)/session → scaffold(后端 gameId,起点落
|
||
amgen-<后端 gameId>)+ 写 session→gameId 映射注册表 sidecar → 设 BYPASS → 发 kick → SSE 等这一次(内部续修多轮)
|
||
回合真结束 → 读九门 verdict + Service 收口采集 sidecar 组 run-summary → reply 外非阻塞丰富度评分。续修/门判/软预算
|
||
全在 Service 端 middleware(阶段一① 复用),消费方只发一次 POST。**C2**:产物目录/评门/回调 trace 全用后端 gameId,
|
||
Service 两工厂经会话注册表把框架分配的 session_id 解析回后端 gameId(工厂拿不到后端 gameId,故靠 sidecar 桥接)。
|
||
|
||
【惰性 import 红线】顶层零重依赖;httpx/agentscope 牵出的件在函数体内 import。
|
||
【代理旁路必保(I2)】**先 client.resolve_base_url()+install_proxy_bypass(base_url) 再 import httpx**(装 NO_PROXY 含
|
||
new-api host 与本地 Service 127.0.0.1);回调侧旁路由 worker_service._NO_PROXY_OPENER 兜。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
|
||
|
||
def _resolve_base() -> str:
|
||
"""new-api host 根(已装代理旁路);cheap 凭据在其后补 /v1(OpenAI 兼容路)。"""
|
||
from worker import client # noqa: PLC0415
|
||
|
||
return client.resolve_base_url()
|
||
|
||
|
||
def _mask_token(tok) -> str:
|
||
"""token 日志脱敏(§3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打 new-api 凭据。"""
|
||
if not tok:
|
||
return "<none>"
|
||
s = str(tok)
|
||
return "****" if len(s) <= 8 else f"{s[:4]}…{s[-4:]}"
|
||
|
||
|
||
def _resolve_key(user_token: str | None = None) -> str:
|
||
"""解析本次生成用的 new-api 调用凭据(WU2 §3.7 F)。
|
||
|
||
优先用 job 随派带来的 per-user token(user_token);缺失则回落全局 env NEWAPI_KEY
|
||
(_bootstrap 已从凭据档注入 env、client.get_api_key 从 env 读)。回落是系统/编排/bake-off 触发的正常预期
|
||
(它们无 game_player 成员身份,后端 dispatch 层已按身份旁路、不塞 token);额度门在后端 dispatch 层按身份堵死
|
||
(member create 路无 token 即 fail-fast),worker 层只做「有则用、无则回落」、不硬拒,以免误伤既有验证路。
|
||
回落时打一条 WARN:便于发现「本该带 token 的 member create job 异常走了共享计量」(§3.7)。
|
||
"""
|
||
if user_token:
|
||
return user_token
|
||
from worker import client # noqa: PLC0415
|
||
|
||
key = client.get_api_key()
|
||
# 可追溯日志(脱敏):回落全局 key。系统/编排/bake-off 属正常;若为真实 member create job 走到这里则异常。
|
||
print(f"[cheap-driver] job 无 userToken → 回落全局 env NEWAPI_KEY({_mask_token(key)})。"
|
||
f"系统/编排/bake-off 触发为正常预期;若为真实 member create job 则异常走共享计量(§3.7)。",
|
||
flush=True)
|
||
return key
|
||
|
||
|
||
def _cheap_credential_payload(user_token: str | None = None) -> dict:
|
||
"""组注册 cheap M3 凭据(POST /credential/)的请求体 —— OpenAI 兼容路,base 末尾补 /v1(决策②)。
|
||
|
||
与 tier2(anthropic_credential,host 根)不同:cheap 走 OpenAI 兼容 /v1/chat/completions,故
|
||
type=openai_credential、base_url 带 /v1(与 config.build_model_openai 的 /v1 补全同口径)。
|
||
api_key = 本次生成的 new-api 凭据:优先 job.userToken(per-user 额度),缺失回落全局 env(§3.7 F)。
|
||
"""
|
||
base = _resolve_base().rstrip("/")
|
||
if not base.endswith("/v1"):
|
||
base = base + "/v1"
|
||
return {"data": {"type": "openai_credential", "api_key": _resolve_key(user_token), "base_url": base}}
|
||
|
||
|
||
def _write_session_cfg(session_id: str, *, external_game_id: str,
|
||
write_whitelist=None, scaffold_template=None,
|
||
traceparent=None, tracestate=None) -> None:
|
||
"""写本 session 的会话注册表 sidecar(C2:Service 两工厂读它把 session_id 解析回后端 gameId + 取 write_whitelist)。
|
||
|
||
按 session_id 键写 game-runtime/games/_cheap-sessions/<session_id>.json(worker↔Service 同机共享 FS);
|
||
**external_game_id = 后端 gameId 恒写**(工厂据它绑六工具/评门/collector 目录,与 driver scaffold 一致)。
|
||
restricted = 是否受限写(create 路 False + write_whitelist=None;reskin/modify 路 True + 白名单;I1 fail-closed
|
||
依赖 restricted 标记)。traceparent/tracestate = 面四断点③ 桥:worker 这跳的 W3C context,让工厂据它把
|
||
cheap-service 生成 span 挂到入站 trace 下(reply 在后台任务跑、读不到出站 header 的实时 context,故走 sidecar 桥)。
|
||
best-effort:写失败只告警(Service 侧读缺失 → 回落 session_id、生成响亮失败)。
|
||
"""
|
||
import json # noqa: PLC0415
|
||
|
||
import cheap_run # noqa: PLC0415
|
||
|
||
try:
|
||
# I1 fail-closed:restricted 由「是否给定白名单(is not None)」判,不用 bool()——空集白名单 bool 为 False 会误成
|
||
# create 的不收窄放开全写;is not None 让空集 → restricted True → T2 读侧收窄成空集禁写(受限会话缺白名单宁禁勿放)。
|
||
restricted = write_whitelist is not None
|
||
wl = sorted(write_whitelist) if write_whitelist else None
|
||
p = cheap_run.session_cfg_path(session_id)
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
cfg = {
|
||
"external_game_id": str(external_game_id),
|
||
"write_whitelist": wl,
|
||
"scaffold_template": scaffold_template,
|
||
"restricted": restricted,
|
||
}
|
||
# 面四断点③ 桥:仅当有 traceparent 时才写(默认关/无传播时不写、sidecar 字节不变)。
|
||
if traceparent:
|
||
cfg["traceparent"] = traceparent
|
||
if tracestate:
|
||
cfg["tracestate"] = tracestate
|
||
p.write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8")
|
||
except Exception as e: # noqa: BLE001 —— sidecar best-effort,写失败 Service 侧回落 session_id
|
||
print(f"[cheap-driver] session-cfg 写失败(Service 侧将回落 game_id=session_id、生成响亮失败):"
|
||
f"{type(e).__name__}: {e}", flush=True)
|
||
|
||
|
||
def _read_last_cheap_verdict(game_id: str):
|
||
"""读服务端最后一次九门 play 已落的 verdict(_wg1-gen/<id>/evidence/verdict.json)。缺失/坏 → None(判未过,不伪造)。"""
|
||
import json # noqa: PLC0415
|
||
|
||
import cheap_run # noqa: PLC0415
|
||
|
||
vp = cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json"
|
||
if not vp.exists():
|
||
return None
|
||
try:
|
||
return json.loads(vp.read_text(encoding="utf-8"))
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[cheap-driver] verdict 读失败(按未产出):{type(e).__name__}: {e}", flush=True)
|
||
return None
|
||
|
||
|
||
def _read_service_run_summary(game_id: str, *, wait_s: float = 5.0) -> dict:
|
||
"""读 Service 收口采集 sidecar(game_dir/evidence/service-run-summary.json:costRmb/rmbGate/repairs/…)。
|
||
|
||
C1 竞态兜底:Service 侧 collector 已在 yield REPLY_END 前**同步 flush**,正常 driver 读 REPLY_END 时盘上必有;
|
||
但为防极端时序(SSE 到达早于文件系统可见 / collector 走 finally 兜底路),这里再加**有界轮询**——最多等 wait_s
|
||
(每 100ms 探一次「存在且可解析」),仍读不到才诚实降级 {}(driver 落 costRmb=0/degraded,不伪造)。竞态窗口是
|
||
ms 级,短轮询即闭合;这是 M3b「result_out 只产 trace.cost → D11 三维恒中性」坑的归并版防线。
|
||
"""
|
||
import json # noqa: PLC0415
|
||
import time as _time # noqa: PLC0415
|
||
|
||
import cheap_run # noqa: PLC0415
|
||
|
||
p = cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json"
|
||
deadline = _time.time() + max(0.0, wait_s)
|
||
while True:
|
||
if p.exists():
|
||
try:
|
||
obj = json.loads(p.read_text(encoding="utf-8"))
|
||
if isinstance(obj, dict):
|
||
return obj
|
||
except Exception: # noqa: BLE001 —— 半成品/坏 JSON:轮询期内再等,过期后按空降级
|
||
pass
|
||
if _time.time() >= deadline:
|
||
print(f"[cheap-driver] service-run-summary 有界轮询({wait_s:.1f}s)未读到,诚实降级 costRmb=0/degraded。",
|
||
flush=True)
|
||
return {}
|
||
_time.sleep(0.1)
|
||
|
||
|
||
def _read_driver_type(game_id: str):
|
||
"""读 ensure_play_spec 产的 driver 类型(_wg1-gen/<id>/play-spec.json.driver.type);缺失 → None(trace.gatespec 省略)。"""
|
||
import json # noqa: PLC0415
|
||
|
||
import cheap_run # noqa: PLC0415
|
||
|
||
p = cheap_run.wg1_game_dir(game_id) / "play-spec.json"
|
||
if not p.exists():
|
||
return None
|
||
try:
|
||
return ((json.loads(p.read_text(encoding="utf-8")) or {}).get("driver") or {}).get("type")
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0: float) -> dict:
|
||
"""据九门 verdict + Service 收口采集 sidecar 组一份与 run_studio 同形的 run-summary(供 result_out.build_result_out)。
|
||
|
||
复用 cheap_studio 的 _verdict_brief(verdict 简报)+ build_trace_source(9d-trace 源维度 verdictFull/driverType/models/stage);
|
||
costRmb/rmbGate/repairs 来自 Service 收口采集(worker 进程拿不到 Service 内存态,靠 sidecar 跨进程)。
|
||
attempts = repairs+1(result_out 的 trace.repairs = max(0, attempts-1) 会反推回 repairs)。
|
||
"""
|
||
import cheap_run # noqa: PLC0415
|
||
import cheap_studio # noqa: PLC0415
|
||
from worker.gate_judge import classify_cheap_failure_layer, judge_cheap_verdict # noqa: PLC0415
|
||
|
||
# C6 接线:带 game_id/staged 目录判(驱动器族/日志摘要口径与 Service 续修一致;此处只为 summary 归一,不再落 sidecar 也无妨——staged_dir 传入使口径同源)。
|
||
j = judge_cheap_verdict(verdict or {}, game_id=game_id,
|
||
staged_dir=cheap_run.wg1_game_dir(game_id))
|
||
# F0-归因分层(W-AXIS 波1):把九门失败归到三层(driver_contract/mechanical/gameplay)进 run-summary,
|
||
# 供 result_out/批次账消费与回喂措辞对齐真实层次;放行(passed)/未失败时 layer=none。
|
||
failure_layer = classify_cheap_failure_layer(verdict or {},
|
||
staged_dir=cheap_run.wg1_game_dir(game_id))
|
||
vb = cheap_studio._verdict_brief(verdict) if verdict else None
|
||
svc = svc or {}
|
||
repairs = svc.get("repairs")
|
||
attempts = (repairs + 1) if isinstance(repairs, int) else 1
|
||
driver_type = _read_driver_type(game_id)
|
||
stage = "play" if verdict is not None else "code" # 简化:有 verdict=跑到 play;无=最远到写码(细分阶段列 follow-up)
|
||
model = cheap_studio._bootstrap.SPIKE_MODEL
|
||
summary = {
|
||
"ok": j.passed,
|
||
"gameId": game_id,
|
||
"brief": brief,
|
||
"model": model,
|
||
"finished": j.passed,
|
||
"attempts": attempts,
|
||
"verdict": vb,
|
||
"costRmb": round(float(svc.get("costRmb", 0.0) or 0.0), 4),
|
||
"rmbGate": svc.get("rmbGate", "degraded"),
|
||
"budgetSoftTripped": bool(svc.get("budgetSoftTripped", False)),
|
||
"wallSec": round(time.time() - t0, 1),
|
||
"stoppedReason": (turn.get("reason") if not turn.get("ended") else "turn_ended"),
|
||
}
|
||
# 9d-trace 源维度(verdictFull/driverType/models/stage);对照 None 时各键自然降级(result_out 省略)。
|
||
summary.update(cheap_studio.build_trace_source(verdict, driver_type, attempts, stage, model))
|
||
# F0-归因分层:失败三层归因随 summary 透传(additive;成功/未失败为 layer=none,不误标坏死)。
|
||
summary["failureLayer"] = failure_layer
|
||
# WU2 §3.9:Service 侧在模型调用抛错时(new-api 非 2xx)按 one-api 惯例分辨,把 failureReason 写进收口 sidecar;
|
||
# 这里原样透传进 summary,result_out._map_failure_reason 据它把额度耗尽落成 quota_exhausted(而非含糊 llm_error)。
|
||
# 仅当 Service 明确判额度耗尽(quota_exhausted)才透传,凭据失效/其他仍走 llm_error(可重试),避免误标。
|
||
svc_reason = svc.get("failureReason")
|
||
if svc_reason:
|
||
summary["failureReason"] = svc_reason
|
||
return summary
|
||
|
||
|
||
def _failed_summary(game_id: str, reason: str) -> dict:
|
||
"""早失败(scaffold 失败等)时的最小 failed summary(verdict None → result_out 走 failed 路)。"""
|
||
import cheap_studio # noqa: PLC0415
|
||
|
||
print(f"[cheap-driver] game={game_id} 早失败:{reason}", flush=True)
|
||
return {
|
||
"ok": False, "gameId": game_id, "model": cheap_studio._bootstrap.SPIKE_MODEL,
|
||
"finished": False, "attempts": 1, "verdict": None,
|
||
"costRmb": 0.0, "rmbGate": "degraded", "stage": "scaffold", "stoppedReason": reason,
|
||
}
|
||
|
||
|
||
async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user_id: str = "cheap"):
|
||
"""驱动 cheap Service /chat 跑一局便宜档生成(单 POST + 洋葱内续修),返回 (run-summary, game_dir)。
|
||
|
||
与旧 worker_service._default_run_fn 同契约(process_job 零改动消费)。create 路默认:通用 scaffold + 通用系统提示 +
|
||
write_whitelist=None(对齐现 _default_run_fn 的 run_studio(game_id, brief) 无 scaffold/whitelist)。
|
||
"""
|
||
import asyncio # noqa: PLC0415
|
||
|
||
from worker import client, config, genconfig # noqa: PLC0415 —— client 先导(不牵 httpx),装旁路
|
||
|
||
base_url = base_url or os.environ.get("CHEAP_SERVICE_URL", "http://127.0.0.1:8300")
|
||
# I2 代理旁路必保(**必须先于 import httpx**):resolve_base_url 把 new-api host 并入 NO_PROXY(装凭据/M3 侧);
|
||
# driver→Service 打的是 127.0.0.1:8300、resolve_base_url 不含它,故再 install_proxy_bypass(base_url) 把本地
|
||
# Service host 也并入 NO_PROXY,避免 trust_env=True 的 httpx 被 clash fake-ip 拦本地环回(Opus/Codex I2)。
|
||
client.resolve_base_url()
|
||
client.install_proxy_bypass(base_url)
|
||
|
||
import httpx # noqa: PLC0415 —— 旁路已装,此后 import 才安全
|
||
|
||
import _bootstrap # noqa: PLC0415
|
||
import cheap_otlp_sink # noqa: PLC0415 —— 面四三跳传播:取当前 context 的 W3C carrier(顶层零重依赖)
|
||
import cheap_run # noqa: PLC0415
|
||
import cheap_verify # noqa: PLC0415
|
||
from cheap_roles import build_system_prompt, SCAFFOLD_DESC_BY_TEMPLATE # noqa: PLC0415
|
||
from service.control_plane import _wait_for_turn_end # noqa: PLC0415 —— 复用 tier2 SSE 等回合
|
||
|
||
game_id = job.get("gameId") or job.get("job_id")
|
||
# 真后端 gameId 是 Java Long → JSON 数字 → int;cheap_run 的 subprocess/路径要求 str(同 worker_service 边界归一)。
|
||
game_id = str(game_id) if game_id is not None else None
|
||
brief = job.get("brief") or ""
|
||
t0 = time.time()
|
||
|
||
# WU2 §3.7 F:从 §6.1 job 取 per-user token(后端 dispatchGeneric 对真实 member 装、系统/编排旁路留空);
|
||
# 空串归一为 None(缺失 → _resolve_key 回落全局 env key)。整条 job 由 worker_service 透传至此,故直接从 job 取。
|
||
user_token = (job.get("userToken") or "").strip() or None
|
||
print(f"[cheap-driver] game={game_id} 凭据来源={'per-user token' if user_token else '全局 env key(回落)'} "
|
||
f"userToken={_mask_token(user_token)}", flush=True)
|
||
|
||
# create 路参数:brief→genre 确定性关键词路由(T4 最小品类接线,取代硬编码 scaffold_template=None)。
|
||
# 命中五类(经营/剧情/TRPG/非遗/解谜)→ 选 per-genre 黄金骨架 + genre 透传丰富度评分(生产 create 路
|
||
# 与 S5 bake_off 复验口径同轨);无命中 → (None, None) 走通用 _template(与旧行为一致,路由只加不减)。
|
||
# write_whitelist=None(create 不收窄,对齐现 _default_run_fn)。
|
||
from cheap_genre_route import route_genre # noqa: PLC0415
|
||
|
||
scaffold_template, genre = route_genre(brief)
|
||
scaffold_desc = None
|
||
write_whitelist = None
|
||
if scaffold_template:
|
||
# 2026-07-08 创始人纠偏:规范 = 文件位置(工程规范) + 绝对通用 plumbing(已由 cheap_run._L1_FIXED 锁
|
||
# host-config/game/index/entry/main 的 boot 链与装配),**不把模型限死到只改几个文件**——只改几个文件游戏
|
||
# 做不好玩、也切不中 brief 主题(浏览器验收实测:写锁 core.js 致「榫卯」brief 出「陶艺」游戏,主题漂)。
|
||
# 故 create 路给 per-genre 完整可玩起点(scaffold_template)+ 一句话描述引导(scaffold_desc),但**不加写锁**:
|
||
# game-logic/core/render/assets 全放开,模型自由把它做成好玩且切题的游戏。render.js 整写截断类失败靠 harness
|
||
# 门(check 的 ESM 导出对账 + stripCode 假阴性已修)兜住并回喂续修,不靠锁模型的手。
|
||
scaffold_desc = SCAFFOLD_DESC_BY_TEMPLATE.get(scaffold_template)
|
||
print(f"[cheap-driver] game={game_id} 品类路由命中:genre={genre} template={scaffold_template} "
|
||
f"(per-genre 起点+引导,不写锁)", flush=True)
|
||
# 失控兜底轮数(与三闸 150 对齐;成本上界=max_repairs=6 优雅终止,而非轮数闸;放开防单 POST 多续修被 EXCEED_MAX_ITERS 提前切断)。
|
||
max_iters = genconfig.get("budget", "cheap_max_iters", 150)
|
||
max_tokens = genconfig.get("model", "max_tokens", 16000)
|
||
|
||
_bootstrap.ensure_api_key_env() # 保证 NEWAPI_KEY(装凭据要用)
|
||
|
||
# 面四断点②:把「worker 这跳的当前 context」(worker_span_scope 在 worker 线程 attach、经 asyncio.run 透传进本
|
||
# 协程)注入成 W3C carrier,随所有出站到 :8300 的 header 带上,让 cheap-service 端能接住入站 context。同一份
|
||
# carrier 也写进会话 sidecar(下方 _write_session_cfg)——因 /chat 的 reply 在框架后台任务里跑、生成 span 产在
|
||
# 那条与 HTTP 请求解耦的任务里,读不到出站 header 的实时 context,故靠 driver 恒在 /chat 前写的 sidecar 把
|
||
# traceparent 跨进程桥给工厂(与 external_game_id 同一 C2 桥)。纯增量 best-effort:无 context / otel 不可用 →
|
||
# carrier 为空,header 少带一项、生成 span 退回私有 root,绝不咬生成(设计 §8)。
|
||
otel_carrier = cheap_otlp_sink.current_traceparent_carrier()
|
||
headers = {"X-User-Id": user_id}
|
||
headers.update(otel_carrier) # traceparent(/tracestate,若有);出站 :8300 各 POST 都带
|
||
async with httpx.AsyncClient() as http:
|
||
# ① 注册 OpenAI 兼容凭据(cheap M3 走 base+/v1)。
|
||
cred = (await http.post(f"{base_url}/credential/", json=_cheap_credential_payload(user_token),
|
||
headers=headers, timeout=30.0)).json()
|
||
credential_id = cred.get("credential_id") or cred.get("id")
|
||
# #2 setup fail-fast:Service 未返 id(建凭据/agent/session 任一失败)必须立即诚实失败,不带 id=None 往下走——
|
||
# 否则后续 POST/SSE 拿 None 当 id,Service 侧找不到资源、driver 在 _wait_for_turn_end 空等到 SSE 总超时(~600s)才弃单。
|
||
if not credential_id:
|
||
return (_failed_summary(game_id, f"Service /credential 未返 id(setup 失败):{str(cred)[:200]}"),
|
||
cheap_run.game_dir(game_id))
|
||
# ② 建 agent(system_prompt=cheap build_system_prompt;context_config 历史压缩;react_config 放开轮数)。
|
||
ctx_cfg = config.build_context_config()
|
||
agent_body = {
|
||
"name": "cheap-writer",
|
||
# create 路统一用通用 create prompt + per-genre scaffold_desc 引导(模型自由改全部游戏文件,不锁)。
|
||
"system_prompt": build_system_prompt(game_id, scaffold_desc),
|
||
"context_config": ctx_cfg.model_dump(mode="json"),
|
||
"react_config": {"max_iters": max_iters},
|
||
}
|
||
agent = (await http.post(f"{base_url}/agent/", json=agent_body, headers=headers, timeout=30.0)).json()
|
||
agent_id = agent.get("agent_id") or agent.get("id")
|
||
if not agent_id: # #2 setup fail-fast:建 agent 失败,不带 None 往下走
|
||
return (_failed_summary(game_id, f"Service /agent 未返 id(setup 失败):{str(agent)[:200]}"),
|
||
cheap_run.game_dir(game_id))
|
||
# ③ 建 session(chat_model_config = openai_credential + MiniMax-M3 + max_tokens,无 thinking 分离)。
|
||
# fix400 主防线:parallel_tool_calls=False 源头禁并行 tool call——M3 经 new-api 的并行 call 不区分
|
||
# 流式 chunk index,agentscope 2.0.2 聚合会把第二个 call 的 arguments 拼进首桶(非法 JSON)→ 下一请求
|
||
# 服务端丢弃该 call → tool result 孤儿 → 400 code 2013 → run 崩、SSE 无终结、driver 慢失败 600s。
|
||
# 经 get_model 的 Parameters(**parameters) 透传,_call_api 对 API 带 parallel_tool_calls=false
|
||
# (venv agentscope/model/_openai_chat/_model.py:264-265)。纵深兜底见 m3_stream_patch(Service 进程内按 id 分桶)。
|
||
session_body = {
|
||
"agent_id": agent_id,
|
||
"chat_model_config": {
|
||
"type": "openai_credential",
|
||
"credential_id": credential_id,
|
||
"model": _bootstrap.SPIKE_MODEL,
|
||
"parameters": {"max_tokens": max_tokens, "parallel_tool_calls": False},
|
||
},
|
||
}
|
||
session = (await http.post(f"{base_url}/sessions/", json=session_body, headers=headers, timeout=30.0)).json()
|
||
session_id = session.get("session_id") or session.get("id")
|
||
if not session_id: # #2 setup fail-fast:建 session 失败,不带 None 往下走(否则 PATCH/SSE 拿 None 空等超时)
|
||
return (_failed_summary(game_id, f"Service /sessions 未返 id(setup 失败):{str(session)[:200]}"),
|
||
cheap_run.game_dir(game_id))
|
||
|
||
# ④a per-run 归档(W-AXIS 波1 F0-b):scaffold 会 rmSync 整个 amgen-<id>/、重跑同 gid 直接抹掉上一 run 的
|
||
# trace.jsonl/turns.jsonl/证据。故 scaffold 前先把上一 run 的 amgen-<id>/ 与 _wg1-gen/<id>/ 整体移进
|
||
# 带时间戳归档位(永不同 gid 覆盖),返回归档指针记进 run-summary/批次账。best-effort:关/失败退回旧覆盖行为。
|
||
archived_to = await asyncio.to_thread(cheap_run.archive_prior_run, game_id)
|
||
# ④b scaffold(clone 模板 + SAA 信封)——必须在 /chat 前,**game_id=后端 gameId**(与 system prompt 的 ⟦G⟧ 一致),
|
||
# agent 起点就位在 amgen-<后端 gameId>(subprocess 经 to_thread)。C2:产物目录统一后端 gameId、不用 session_id。
|
||
sc = await asyncio.to_thread(cheap_run.scaffold, game_id, scaffold_template)
|
||
if not sc["ok"]:
|
||
_fs = _failed_summary(game_id, f"scaffold 失败:{sc['output'][:300]}")
|
||
if archived_to:
|
||
_fs["archivedPriorRunTo"] = archived_to
|
||
return _fs, cheap_run.game_dir(game_id)
|
||
# ④c evidence 清理清单化(W-AXIS 波1 F0-c,取代旧红线③只清 verdict.json 一个文件):清 _wg1-gen/<id>/evidence/
|
||
# 残留(verdict/续修反馈/日志/截图全列)+ 兜底清 amgen-<id>/evidence/,封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。
|
||
await asyncio.to_thread(cheap_run.clean_stale_evidence, game_id)
|
||
# ⑤ 写会话注册表 sidecar(C2:Service 两工厂据 session_id 读它解析回后端 gameId、绑六工具/评门/collector;
|
||
# create 路 write_whitelist=None → restricted=False)。external_game_id 恒写、正常路工厂必读到。
|
||
_write_session_cfg(session_id, external_game_id=game_id,
|
||
write_whitelist=write_whitelist, scaffold_template=scaffold_template,
|
||
traceparent=otel_carrier.get("traceparent"),
|
||
tracestate=otel_carrier.get("tracestate"))
|
||
# ⑥ 设 BYPASS 权限(六工具默认 ASK,服务态无人确认,不设首个工具调用即卡死;PATCH 需 query agent_id,同 tier2)。
|
||
await http.patch(f"{base_url}/sessions/{session_id}", json={"permission_mode": "bypass"},
|
||
headers=headers, params={"agent_id": agent_id}, timeout=30.0)
|
||
# ⑦ 发 kick(引导 read skill → 写 game-logic.js → check/build → finish;文本对齐旧 run_studio 的 create kick)。
|
||
kick = (f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md)"
|
||
"和你的起点 game-logic.js 再动手;核心玩法实现完、check 与 build 都绿了就立即 finish。")
|
||
await http.post(f"{base_url}/chat/", json={
|
||
"agent_id": agent_id, "session_id": session_id,
|
||
"input": {"name": "user", "role": "user", "content": [{"type": "text", "text": kick}]},
|
||
}, headers=headers, timeout=30.0)
|
||
|
||
# ⑧ SSE 等这一次回合真结束(内部续修多轮)。C3:idle 从 genconfig 派生(不硬编码 300;慢门 ~390s 静默不误弃单),
|
||
# 总超时按 (max_repairs+1)×(门+推理)放宽、且 > breaker 墙钟(让 breaker 先优雅收、driver 别抢先弃单)。
|
||
sse_idle_s = genconfig.get("budget", "cheap_repair_step_timeout_s", 600) # = breaker step_timeout,> 单次门跑最坏
|
||
sse_timeout = genconfig.get("budget", "cheap_sse_turn_timeout_s",
|
||
(genconfig.get("iteration", "max_resumes", 6) + 1)
|
||
* (genconfig.get("budget", "cheap_gate_timeout_s", 420) + 180) + 600)
|
||
turn = await _wait_for_turn_end(base_url, agent_id, session_id, user_id=user_id,
|
||
timeout_s=sse_timeout, idle_timeout_s=sse_idle_s)
|
||
if not turn.get("ended"):
|
||
# 回合未真结束(SSE 总超时/断流):Service 可能仍在续修写盘,读中间态会误判 → 据现有产物 best-effort 组 summary、诚实标 reason。
|
||
print(f"[cheap-driver] game={game_id} 回合未真结束(reason={turn.get('reason')}),据现有产物组 summary。", flush=True)
|
||
|
||
# ⑨ 读九门 verdict + Service 收口采集 sidecar → 组 run-summary(供 result_out;C2:全用后端 gameId)。
|
||
# _read_service_run_summary 带有界轮询(C1:关闭 collector flush 与 driver 读 REPLY_END 的竞态)。
|
||
verdict = _read_last_cheap_verdict(game_id)
|
||
svc = _read_service_run_summary(game_id)
|
||
summary = _build_summary(game_id, brief, verdict, svc, turn, t0)
|
||
# F0-b:上一 run 归档指针随 run-summary 透传(批次账每 run 记一笔;首跑/关/失败为 None,不带该键)。
|
||
if archived_to:
|
||
summary["archivedPriorRunTo"] = archived_to
|
||
|
||
# ⑩ reply 外收口:非阻塞丰富度 LLM 评分(additive;不进 verdict、不改 ok;token 不污染生成成本台账)。
|
||
# genre 透传(T4):品类路由命中时同一次评分 additive 追加品类扩展条目(与 run_studio 的 genre 参数同义)。
|
||
try:
|
||
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=genre)
|
||
rv = summary["richness"]
|
||
print(f"[cheap-driver] game={game_id} 丰富度 score={rv.get('score')}/{rv.get('max')} "
|
||
f"genre={genre or '-'} groups={rv.get('groups')} degraded={rv.get('degraded')}", flush=True)
|
||
except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调
|
||
summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"}
|
||
|
||
# ⑪ 玩法地板判定 + ok 语义切换(W-AXIS 波2 · 质量模型 SoT 裁定三):机械九门(_build_summary 的 j.passed)
|
||
# 只作「未见明显死」预筛,过筛者交独立多模态档看真玩证据(首帧/局中连拍/局末截图 + game-log + 取证时间线)
|
||
# 裁 broken/hollow/off-brief,阻断放行(ok = 预筛 ∧ 判定)。与丰富度(非阻塞观测)是两物:fail-closed,
|
||
# 评不出/无证据 → 不放行、标 degraded。apply_gameplay_judge 内建 fail-closed 与顶层兜底、绝不抛,
|
||
# additive 写 summary['accepted']/['judge'] 并更新 ['ok']——result_out 据 accepted 落 status。
|
||
summary = await cheap_verify.apply_gameplay_judge(summary, game_id=game_id, brief=brief)
|
||
_js = summary.get("judge") or {}
|
||
print(f"[cheap-driver] game={game_id} 玩法地板判定 ran={_js.get('ran')} blocking={_js.get('blocking')} "
|
||
f"verdict={_js.get('verdict')} rejectClasses={_js.get('rejectClasses')} degraded={_js.get('degraded')} "
|
||
f"costRmb={_js.get('costRmb')} → accepted={summary.get('accepted')}", flush=True)
|
||
|
||
print(f"[cheap-driver] game={game_id} 单 POST 结束: ok={summary['ok']} attempts={summary['attempts']} "
|
||
f"costRmb={summary['costRmb']} wallSec={summary['wallSec']}", flush=True)
|
||
return summary, cheap_run.game_dir(game_id)
|