zizi 0fd0d4fb10 fix(tier2): 抽平台命脉进锁定 play-runtime.js + play-scene 契约门(根因3 另一半)
spike feie-003 实证(lock+schema 都修对后):F_wiring 仍 calls=0、E_live distinct=1/N、
latch=false。diff 出真因——M3 重写 play-scene 时把两件平台命脉一起改没了:
① 输入路由(doHitTest/doHandleInput/this.input.on pointerdown):没了或坐标错位
   (用 (col,row) 调 core.tapCell 而非 (i,j))→ driver 注入的 touch 落空/落错格 → 没合成 → F/G 门挂。
② 逐帧 core.update(dt):没了 → 订单耐心不倒计时、赢/输 latch 永不触发 → E_live/H/经济/latch 全挂。

这俩原先混在 agent 可写的 play-scene 里、只靠注释「别动」——无机器门=必被 M3 改掉。
现抽进平台锁定文件 src/scenes/play-runtime.js(bindInput 输入路由 + tick 逐帧推进),
加进 LOCKED_PLATFORM_FILES;fixture play-scene 重构成 create 调 bindInput(this,core)、
update 调 tick(core,delta),薄委托命中给锁定实现(坐标 layout 同源、driver-tap 焊死)。
加 validate_play_scene 契约门(build 段跑):play-scene 必须 import play-runtime 且调 bindInput+tick,
缺了响亮回喂精确指引。prompt/hint 同步:你只管画 render,别碰输入路由与 tick。

门未放松(judge 纯代码判);fixture 重构后须重跑金标 smoke 验仍 ACCEPT(下一步)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:53:53 +00:00

366 lines
20 KiB
Python

"""toolkit.py —— tier2 单写 ReAct agent 的九工具面(A7 · toolkit-signatures 契约落地)。
接法(AgentScope v2.0.2 源码核验):2.0.2 **没有 register_tool_function**;把 Python 函数变 agent 可调工具
靠 `FunctionTool(func)` 薄壳(tool/_adapters.py),再经构造器 `Toolkit(tools=[FunctionTool(...), ...])`
(tool/_toolkit.py)装进 toolkit,最后 `Agent(toolkit=...)`。工具入参 schema 由 AgentScope 从函数签名 +
docstring 自动抽(tool/_utils.py:_extract_input_schema),故每个工具用**带类型注解的参数 + Google 风格 docstring**。
九工具三类(toolkit-signatures A7):
核心环:write_source / build / headless_check / run_gates / read_verdict / finish
取证: screenshot / query_asset
起手收尾:scaffold_init / finish
三条边界铁律(焊死,见契约):
① 单写独占:整工程只一个 agent 写(本 toolkit 绑单 session,不并行);
② finish 入参 schema = A3 源项目契约(tier2-source-project.schema.json),不另写;
③ 验收零自评:run_gates/read_verdict 的 verdict 由 judge 纯代码产出,screenshot 只取证不判定。
工具返回:统一返回 JSON 字符串(FunctionTool 会包成 agent 可读结果;见 _adapters.py:_convert_func_result_to_chunk)。
"""
import hashlib
import json
from pathlib import Path
from agentscope.tool import Toolkit, FunctionTool
from . import run
# ── 平台锁定文件(写死、agent 不许改;spike feie-002 实证根因 3)──
# fixture-spec 预建分工:平台预建(装载胶水 + 三系统纯逻辑 + 状态机 + 结算 + latch + readState 导出 +
# 输入路由/命中映射约定)写死不动;agent 只写表现层 play-scene.js + 数据表 + 新增表现文件。
# 实证:M3 重写了 main.js(装载胶水)→ 丢了 recHook 透传 → F 门 calls=0、合成不记引擎调用、G/H 门连环挂。
# 故把 fixture 平台预建文件焊成只读:write_source 改它直接拒,并提示「只改 play-scene.js + 数据表」。
LOCKED_PLATFORM_FILES = frozenset({
"src/main.js", # entry · PhaserGameFactory 装载胶水(recHook/caps/scene 接线在此)
"src/game-core.js", # 三系统编排 + 状态机 + 结算 + latch + _forensicsView/readState 导出
"src/layout.js", # 棋盘/订单面板布局常量单一源(命中映射 + driver tap 同源坐标)
"src/data/tables.js", # 数据表适配层(按固定 key 读 datatable.gold.json)
"src/util/seeded-random.js", # 受控随机源(确定性铁律)
"src/systems/resource-system.js", # 资源系统纯逻辑(addCoins/consumeIngredient 经 recHook 记调用)
"src/systems/merge-system.js", # 合成系统纯逻辑(tapCell 两次点同物品合成)
"src/systems/order-system.js", # 订单系统纯逻辑
"src/systems/reachability.js", # 可达性纯逻辑
"src/scenes/play-runtime.js", # 表现层平台锁定运行时插座(输入路由 + 逐帧 core.update tick)
})
def is_locked_platform_file(rel: str) -> bool:
"""该工程内相对路径是否平台锁定(写死、agent 不许改)。"""
return rel in LOCKED_PLATFORM_FILES
def _content_hash(files: list[dict]) -> str:
"""整工程内容指纹 = sha256(各文件 path+content 规范化拼接)。对齐 A3 contentHash 语义。"""
h = hashlib.sha256()
# 按 path 排序后拼接,保证同内容同指纹(规范化)。
for f in sorted(files or [], key=lambda x: (x or {}).get("path", "")):
h.update(((f or {}).get("path", "") + "\0").encode("utf-8"))
h.update(((f or {}).get("content", "") + "\0").encode("utf-8"))
return h.hexdigest()
class Tier2Session:
"""单写 session 的可变状态(单 agent 独占;绑 game_id)。
工具闭包共享它:write_source 累积 file 树、build/run_gates 记最近结果、finish 据它组装源工程。
"""
def __init__(self, game_id: str, *, entry: str = "src/main.js",
phaser_version: str = "3.80.1", play_spec: dict | None = None) -> None:
self.game_id = game_id
self.entry = entry
self.phaser_version = phaser_version
self.play_spec = play_spec or {}
# path → content(write_source / scaffold_init 累积的工程文件树)。
self.files: dict[str, str] = {}
# path → role(对齐 A3 fileTree[].role;scaffold_init 填初始角色,write_source 默认 other)。
self.roles: dict[str, str] = {}
self.last_verdict: dict | None = None
self.last_build: dict | None = None
self.finished: dict | None = None
def file_list(self) -> list[dict]:
"""当前文件树(供落盘 / contentHash;[{path, content}])。"""
return [{"path": p, "content": c} for p, c in self.files.items()]
def file_tree(self) -> list[dict]:
"""A3 fileTree 形状([{path, role}])。"""
return [{"path": p, "role": self.roles.get(p, "other")} for p in self.files]
def build_toolkit(session: Tier2Session) -> Toolkit:
"""据单 session 造九工具面 Toolkit(FunctionTool + 构造器 tools=,2.0.2 正确姿势)。"""
# ── 起手:scaffold_init ──
async def scaffold_init(template_id: str, brief: str = "", design_docs: str = "") -> str:
"""开局铺一套先天过 boot 的 Phaser 富游戏骨架(平台预建 ~25%:三系统纯逻辑 + 装载胶水 + 留空数据表)。
一次 session 开头调一次。铺的是 fixture 平台预建骨架(先天过 boot),你只需:
① 把 data/datatable.gold.json 的留空 sentinel 填成本游戏主题的真值(物品/合成链/订单数值);
② 在 src/scenes/play-scene.js 的 TODO 处填那约 56% 表现层(棋盘渲染/订单面板/HUD/反馈)。
逻辑命令(core.tapCell/fulfillOrder)、命中映射坐标系、readState 语义约定不要动。
Args:
template_id: 品类模板 id(如 'business-sim' = 经营/合成富游戏 = mini-肥鹅 三系统)。
brief: 题面(可选)。
design_docs: 阶段 1 设计结论(可选,作为工程内文档写入 DESIGN.md)。
"""
# ① 读品类模板对应的 fixture 平台预建骨架(M4/M1 真实预建,先天过 boot)。
scaffold = run.load_fixture_scaffold(template_id)
if scaffold["ok"]:
# 铺 fixture 骨架进 session(已存在的不覆盖,允许 agent 已先写过个别文件)。
for f in scaffold["files"]:
session.files.setdefault(f["path"], f["content"])
session.roles.setdefault(f["path"], f.get("role", "other"))
else:
# 兜底:未知品类 → 最小可被 write_source 覆盖的空骨架(诚实标注,不假装铺了真骨架)。
fallback = {
session.entry: (
"// src/main.js —— Phaser 工程入口(scaffold_init 兜底空骨架;write_source 覆盖)\n"
"// TODO(agent): 配置 Phaser.Game,挂 scene 树,暴露 readState 语义 state 视图。\n"
),
"src/scenes/PlayScene.js": "// Play scene 脚手架;write_source 填多系统表现层。\n",
"src/data/datatable.json": "{}\n",
}
for path, content in fallback.items():
session.files.setdefault(path, content)
session.roles.setdefault(
path, "entry" if path == session.entry
else "scene" if "/scenes/" in path
else "config" if "/data/" in path else "other")
if design_docs:
session.files["DESIGN.md"] = design_docs
session.roles["DESIGN.md"] = "other"
# ② 落盘整骨架(供后续 build/run_gates 子进程读;scaffold 同时拷宿主 index.html 模板)。
res = run.scaffold(session.game_id, session.file_list())
msg = (f"fixture 骨架已铺(template={template_id},{len(scaffold['files'])} 文件):"
"填 data/datatable.gold.json 真值 + play-scene.js 的 TODO 表现层。"
if scaffold["ok"]
else f"⚠未知品类 template={template_id}({scaffold['reason']}):已铺最小空骨架,需你 write_source 写全。")
return json.dumps(
{"ok": res["ok"], "workdir": res["workdir"], "fileTree": session.file_tree(),
"scaffoldKind": "fixture" if scaffold["ok"] else "minimal", "message": msg},
ensure_ascii=False)
# ── 核心环:write_source ──
async def write_source(files: list) -> str:
"""把一批源文件写进工程(多文件、允许 import;写那约 56% 表现层的主手段)。
直接落文件,**不从 markdown 抽代码**(与 Tier0/1 反转)。越界路径(含 '..'/绝对路径)会被拒。
Args:
files: 文件数组,每项 {path: 工程内相对路径(如 'src/scenes/PlayScene.js',禁 '..'),
content: 文件全文 UTF-8, role?: 文件角色(entry/scene/system/ui/config/lib/other)}。
"""
written, errors, locked = [], [], []
for f in files or []:
rel = (f or {}).get("path") or ""
content = (f or {}).get("content")
if not rel or ".." in rel.split("/") or rel.startswith("/"):
errors.append({"path": rel, "reason": "非法路径(空/含 '..'/绝对路径)"})
continue
if not isinstance(content, str):
errors.append({"path": rel, "reason": "content 必须是字符串"})
continue
# 平台锁定文件保护(根因 3):若改的是写死的平台文件且内容真变了,拒绝并提示。
# 允许「内容完全相同」的无操作重写(幂等;不阻断 agent 把整树回写)。
if is_locked_platform_file(rel) and session.files.get(rel) != content:
locked.append({
"path": rel,
"reason": "这是平台锁定文件(装载胶水/三系统纯逻辑/状态机/结算/latch/readState/布局),"
"写死不许改——改它会破坏引擎接线(如丢 recHook → F 门 calls=0、合成不记调用)。"
"你只该改 src/scenes/play-scene.js(表现层)+ data/datatable.gold.json(数据表),"
"或在 src/scenes/ 下新增表现文件。请把这个文件的改动撤回。",
})
continue
session.files[rel] = content
if (f or {}).get("role"):
session.roles[rel] = f["role"]
elif rel not in session.roles:
session.roles[rel] = "other"
written.append(rel)
# 同步落盘(供后续 build/headless_check 子进程读)。
run.scaffold(session.game_id, session.file_list())
return json.dumps(
{"ok": len(errors) == 0 and len(locked) == 0,
"written": written, "errors": errors, "lockedRejected": locked},
ensure_ascii=False)
# ── 核心环:validate_datatable(数据表 schema/可达性预检)──
async def validate_datatable() -> str:
"""校验 data/datatable.gold.json 是否符合平台锁定 schema + 富游戏可达性不变量(合成链非空/DAG/订单可达)。
强烈建议填完数据表后、build 前先调它。失败会精确告诉你平台读哪些 key、你缺/错了哪个
(平台锁定文件 src/data/tables.js 按固定 key 消费数据表;自创 schema 会让合成系统空转、三联动门全挂)。
注:build 也会自动跑这道预检,不过提前调能省一整轮。
"""
dv = run.validate_datatable(session.game_id)
return json.dumps(
{"ok": dv["ok"], "errors": dv["errors"],
"expectedSchemaHint": dv.get("expectedSchemaHint", "")},
ensure_ascii=False)
# ── 核心环:build ──
async def build() -> str:
"""esbuild 多文件打包当前 src/ 工程(入口 src/main.js,产 iife bundle)。
构建失败返回 ok=false + 完整 esbuild 日志,据 log 改源重试。
"""
res = run.build(session.game_id, entry=session.entry)
session.last_build = res
# 失败 log 可能很长,截断喂回(避免撑爆上下文;完整在落盘日志)。
return json.dumps(
{"ok": res["ok"], "bundlePath": res["bundlePath"],
"durationMs": res["durationMs"], "log": (res["log"] or "")[:1500]},
ensure_ascii=False)
# ── 核心环:headless_check ──
async def headless_check() -> str:
"""全套真玩门前的便宜快筛(无头:语法 / bundle 非空)。
这是快筛、不是验收门 —— ok 不代表过 L1,只是值不值得上真玩门。结果不入硬判。
"""
res = run.headless_check(session.game_id)
return json.dumps({"ok": res["ok"], "signals": res["signals"], "log": res["log"]},
ensure_ascii=False)
# ── 核心环:run_gates ──
async def run_gates() -> str:
"""真浏览器真玩,跑 L1 九门 + 富游戏三门,返回机器判的 verdict(零自评)。
verdict 由 judge 纯代码产出,你不给自己打分;据返回的失败门改源。
"""
res = run.run_gates(session.game_id, session.play_spec)
session.last_verdict = res["verdict"]
# 给 agent 的回喂:verdict 摘要 + 失败门反馈(人读)。
feedback = run.verdict_feedback(res["verdict"], res.get("log", ""))
v = res["verdict"] or {}
l1 = ((v.get("layerResults") or {}).get("L1") or {})
return json.dumps(
{"rc": res["rc"], "decision": v.get("decision"),
"structureOk": v.get("structureOk"), "runnableOk": v.get("runnableOk"),
"l1Passed": l1.get("passed"), "feedbackText": feedback},
ensure_ascii=False)
# ── 核心环:read_verdict ──
async def read_verdict() -> str:
"""读回上次 run_gates 的裁决与失败门摘要(只读,不重跑门)。"""
v = session.last_verdict
if not v:
return json.dumps({"available": False,
"feedbackText": "尚无 verdict;先调 run_gates。"}, ensure_ascii=False)
l1 = ((v.get("layerResults") or {}).get("L1") or {})
return json.dumps(
{"available": True, "decision": v.get("decision"),
"structureOk": v.get("structureOk"), "runnableOk": v.get("runnableOk"),
"l1Passed": l1.get("passed"),
"feedbackText": run.verdict_feedback(v)},
ensure_ascii=False)
# ── 取证:screenshot ──
async def screenshot(phase: str = "play", label: str = "") -> str:
"""截当前游戏画面,喂给 L3 软检与人工终审/debug。
边界铁律:截图只取证、**绝不进硬门判定**。L1/L2 判定零截图参与。
Args:
phase: 截哪个时相(如 'title'/'play'/'gameover')。
label: 截图标签(可选)。
"""
# 真截图由 CDP harness 在真玩时落盘(U3/U6,mini-desktop);这里返回约定落点,标注取证用途。
ev_dir = run._workdir(session.game_id) / "evidence"
shot = f"{phase}-{label or 'shot'}.png"
return json.dumps(
{"path": str(ev_dir / shot), "phase": phase,
"note": "截图只取证,绝不进 L1/L2 硬门判定(防 Goodhart)。真截图由 run_gates 时 CDP 落盘。"},
ensure_ascii=False)
# ── 取证:query_asset ──
async def query_asset(category: str, count: int = 4) -> str:
"""按类目查可用资产(六类),不感知底下是哪个资产源(provider 可插拔)。
只查/取引用,不生成(生成走 B-ASSET-mmx provider)。spike 期走共用资产池占位图。
Args:
category: 资产类目('sprite'|'character'|'effect'|'scene'|'ui'|'music')。
count: 要几个(默认 4)。
"""
# spike 期:返回占位资产引用(真 provider 接入走 A9,本期不感知实现)。
refs = [{"id": f"placeholder-{category}-{i}", "category": category,
"ref": f"shared-pool://{category}/{i}", "provider": "placeholder"}
for i in range(max(1, min(count, 12)))]
return json.dumps({"assets": refs}, ensure_ascii=False)
# ── 核心环 + 收尾:finish ──
async def finish(entry: str = "src/main.js", phaser_version: str = "") -> str:
"""ReAct 收敛那一刻的收尾:吐出最终源工程(tier2 源项目契约形状),交付落库。
入参 schema 与 A3 源项目契约共用同一份定义(tier2-source-project.schema.json):
projectType/fileTree/entry/buildProfile/depLock/contentHash/addressing。这里据当前 session
文件树自动组装这七要素(agent 只需确认 entry / phaser 版本),从源头杜绝交付↔落库漂移(F3)。
Args:
entry: 构建入口(默认 src/main.js;必须命中已写入的某个文件)。
phaser_version: Phaser 引擎精确版本(默认沿用 session 配置)。
"""
if entry not in session.files:
return json.dumps(
{"ok": False, "message": f"entry '{entry}' 不在已写入文件树中;先 write_source 写入入口文件。"},
ensure_ascii=False)
# finish 门(spike feie-001 根因 1):验收零自评——不许在没真跑过门、或门没绿时就 finish 收尾。
# 这道门防 agent 看一次 fix 就放弃/或自我宣布做好。门未绿就把失败反馈塞回去,逼它继续修。
v = session.last_verdict
if not v:
return json.dumps(
{"ok": False, "blockedByGate": True,
"message": "还没跑过验收门,不能 finish。先 run_gates 真玩,据 verdict 修到门绿再 finish。"},
ensure_ascii=False)
l1 = ((v.get("layerResults") or {}).get("L1") or {})
if v.get("decision") != "accept" or not l1.get("passed"):
return json.dumps(
{"ok": False, "blockedByGate": True,
"decision": v.get("decision"), "l1Passed": l1.get("passed"),
"feedbackText": run.verdict_feedback(v),
"message": "验收门未全绿(decision≠accept 或 L1 未过),不能 finish。"
"请据上面失败门继续 write_source 针对性修,再 run_gates,直到门绿。"},
ensure_ascii=False)
files = session.file_list()
chash = _content_hash(files)
# 组装 A3 源项目契约形状(七要素):身份/骨架/入口/构建/依赖锁/内容指纹/落库寻址。
source_project = {
"schemaVersion": "tier2-1.0",
"projectType": "tier2-phaser",
"fileTree": session.file_tree(),
"entry": entry,
"buildProfile": {
"bundler": "esbuild", "format": "iife",
"globalName": run.DEFAULT_GLOBAL_NAME, "minify": True, "target": "es2019",
},
"depLock": {"phaser": phaser_version or session.phaser_version},
"contentHash": chash,
"addressing": {"store": "mysql+oss", "fetchById": "contentHash"},
}
session.finished = source_project
# 注:落库 versionId 由后端回填,本工具只交付形状 + contentHash。
return json.dumps(
{"ok": True, "contentHash": chash,
"sourceProject": source_project,
"message": "源工程已交付(tier2 源项目契约形状);落库 versionId 由后端回填。"},
ensure_ascii=False)
# 九工具经 FunctionTool 包壳,构造器 tools= 装进 Toolkit(2.0.2 唯一姿势,无 register_tool_function)。
tools = [
FunctionTool(scaffold_init),
FunctionTool(write_source),
FunctionTool(validate_datatable),
FunctionTool(build),
FunctionTool(headless_check),
FunctionTool(run_gates),
FunctionTool(read_verdict),
FunctionTool(screenshot),
FunctionTool(query_asset),
FunctionTool(finish),
]
return Toolkit(tools=tools)