冻结地图1二十分钟纵切版的平衡、证据与金标登记。 新增 ReferenceAsset/2 清单、策略、release、受信快照及 CLI/Service/acceptance provenance /4 消费链;保持 survivor live、R1 签名与部署关闭。
334 lines
16 KiB
Python
334 lines
16 KiB
Python
"""
|
||
cheap_toolkit.py — 便宜档 LittleJS 六工具(对照源:Node prompt.mjs 的 TOOLS + gen.mjs done 门 + tier2 toolkit.py 组装范式)。
|
||
|
||
工具用 AgentScope FunctionTool 薄壳包 async 闭包函数(schema 据类型注解 + Google docstring 自动抽,prompt 里不手写),
|
||
闭包共享一个 CheapSession 可变状态(对 tier2 toolkit.py 的 Tier2Session)。底层:read/list/write 走 cheap_run
|
||
Python 实现、check/build shell-out node。done 门 = finish 工具(工具内复核 check+build 绿才接受,对 gen.mjs done 门)。
|
||
"""
|
||
|
||
import json
|
||
import unicodedata
|
||
from collections.abc import Mapping
|
||
from dataclasses import dataclass, field
|
||
from types import MappingProxyType
|
||
from typing import Optional
|
||
|
||
import cheap_run
|
||
|
||
# 单次工具结果注入上限(对 gen.mjs TOOL_RESULT_CAP:够一份 skill/api.d.ts,控 context)。
|
||
_TOOL_RESULT_CAP = 30000
|
||
_REFERENCE_READ_BYTES = getattr(cheap_run, "_MAX_READ_BYTES", 200 * 1024)
|
||
_REFERENCE_DENY_MESSAGE = "ERROR: 受保护参照路径未在只读快照中"
|
||
|
||
|
||
def _normalize_repo_path(path: str) -> Optional[str]:
|
||
"""把仓内逻辑路径规范化为 NFC POSIX 段,拒绝绝对路径和越过仓根的路径。"""
|
||
if not isinstance(path, str) or not path or "\x00" in path or path.startswith(("/", "\\")):
|
||
return None
|
||
path = unicodedata.normalize("NFC", path)
|
||
parts = []
|
||
for part in path.split("/"):
|
||
if part in ("", "."):
|
||
continue
|
||
if part == "..":
|
||
if not parts:
|
||
return None
|
||
parts.pop()
|
||
continue
|
||
parts.append(part)
|
||
return "/".join(parts) or None
|
||
|
||
|
||
def _path_requires_rejection(path: str) -> bool:
|
||
"""判断绝对路径、非法类型或越过仓根的别名,避免它们回落到活目录。"""
|
||
if not isinstance(path, str) or "\x00" in path or path.startswith(("/", "\\")):
|
||
return True
|
||
parts = []
|
||
for part in path.split("/"):
|
||
if part in ("", "."):
|
||
continue
|
||
if part == "..":
|
||
if not parts:
|
||
return True
|
||
parts.pop()
|
||
continue
|
||
parts.append(part)
|
||
return False
|
||
|
||
|
||
def _path_is_within(path: str, root: str) -> bool:
|
||
"""按路径段判断包含关系,避免 assets/gold 误匹配 assets/golden。"""
|
||
return path == root or path.startswith(root + "/")
|
||
|
||
|
||
def _freeze_reference_files(files: Optional[Mapping[str, bytes]]) -> Mapping[str, bytes]:
|
||
"""复制并冻结验证器给出的 bytes 快照,避免调用方改写 Toolkit 输入。"""
|
||
if files is None:
|
||
return MappingProxyType({})
|
||
if not isinstance(files, Mapping):
|
||
raise TypeError("reference_files 必须是路径到 bytes 的映射")
|
||
frozen = {}
|
||
for raw_path, content in files.items():
|
||
path = _normalize_repo_path(raw_path)
|
||
if path is None or not isinstance(content, bytes):
|
||
raise TypeError("reference_files 必须包含规范化路径和 bytes 内容")
|
||
frozen[path] = content
|
||
return MappingProxyType(frozen)
|
||
|
||
|
||
def _freeze_reference_roots(roots: Optional[Mapping[str, object]]) -> Mapping[str, tuple[str, ...]]:
|
||
"""复制并冻结受保护根;任何非法根都在构造边界 fail-closed。"""
|
||
if roots is None:
|
||
return MappingProxyType({})
|
||
if not isinstance(roots, Mapping):
|
||
raise TypeError("reference_roots 必须是 recordId 到路径元组的映射")
|
||
frozen = {}
|
||
for record_id, raw_roots in roots.items():
|
||
if isinstance(raw_roots, str):
|
||
values = (raw_roots,)
|
||
else:
|
||
try:
|
||
values = tuple(raw_roots)
|
||
except TypeError as exc:
|
||
raise TypeError("reference_roots 必须包含路径序列") from exc
|
||
if not values:
|
||
raise ValueError("reference_roots 不得包含空根")
|
||
normalized = []
|
||
for value in values:
|
||
path = _normalize_repo_path(value)
|
||
if path is None:
|
||
raise ValueError("reference_roots 含非法路径")
|
||
normalized.append(path)
|
||
frozen[record_id] = tuple(normalized)
|
||
return MappingProxyType(frozen)
|
||
|
||
|
||
def _validate_reference_snapshot(
|
||
files: Mapping[str, bytes], roots: Mapping[str, tuple[str, ...]]
|
||
) -> None:
|
||
"""校验快照与根索引的一致性,避免未覆盖文件回落到活目录。"""
|
||
protected_roots = tuple(root for values in roots.values() for root in values)
|
||
if files and not protected_roots:
|
||
raise ValueError("reference_files 非空时 reference_roots 不能为空")
|
||
for path in files:
|
||
if not any(_path_is_within(path, root) for root in protected_roots):
|
||
raise ValueError("reference_files 必须全部位于 reference_roots 内")
|
||
|
||
|
||
def _protected_roots(session: "CheapSession") -> tuple[str, ...]:
|
||
"""展平 session 的受保护根索引,供 read/list 共用同一边界判断。"""
|
||
return tuple(root for roots in session.reference_roots.values() for root in roots)
|
||
|
||
|
||
def _is_protected_path(session: "CheapSession", path: str) -> bool:
|
||
"""判断规范化路径是否位于任一受保护根内。"""
|
||
return any(_path_is_within(path, root) for root in _protected_roots(session))
|
||
|
||
|
||
def _read_snapshot_bytes(content: bytes) -> str:
|
||
"""按 cheap_run.read_file 的 200KB、UTF-8 忽略错误和 Toolkit 总上限返回文本。"""
|
||
truncated = len(content) > _REFERENCE_READ_BYTES
|
||
raw = content[:_REFERENCE_READ_BYTES] if truncated else content
|
||
prefix = "[内容已截断]\n" if truncated else ""
|
||
return (prefix + raw.decode("utf-8", "ignore"))[:_TOOL_RESULT_CAP]
|
||
|
||
|
||
def _list_snapshot_directory(session: "CheapSession", path: str) -> str:
|
||
"""从快照文件映射投影指定目录的直接子项,不观察活目录。"""
|
||
entries = set()
|
||
for file_path in session.reference_files:
|
||
if not _is_protected_path(session, file_path) or not _path_is_within(file_path, path):
|
||
continue
|
||
suffix = file_path[len(path):].lstrip("/")
|
||
if not suffix:
|
||
continue
|
||
first, separator, _ = suffix.partition("/")
|
||
entries.add(first + "/" if separator else first)
|
||
return "\n".join(sorted(entries))[:_TOOL_RESULT_CAP]
|
||
|
||
|
||
@dataclass(init=False)
|
||
class CheapSession:
|
||
"""便宜档单 run 可变状态闭包(对 tier2 toolkit.py 的 Tier2Session,LittleJS 简化版)。
|
||
|
||
单 session 绑单 agent、单 run,不跨 run 复用(同 tier2 toolkit 的三条边界铁律)。
|
||
"""
|
||
|
||
game_id: str
|
||
last_check: Optional[dict] = None
|
||
last_build: Optional[dict] = None
|
||
finished: Optional[dict] = None # finish 组装的产物摘要(None=未收敛,编排层据此判收敛)
|
||
_reference_files: Mapping[str, bytes] = field(init=False, repr=False, compare=False)
|
||
_reference_roots: Mapping[str, tuple[str, ...]] = field(init=False, repr=False, compare=False)
|
||
|
||
def __init__(
|
||
self,
|
||
game_id: str,
|
||
last_check: Optional[dict] = None,
|
||
last_build: Optional[dict] = None,
|
||
finished: Optional[dict] = None,
|
||
*,
|
||
reference_files: Optional[Mapping[str, bytes]] = None,
|
||
reference_roots: Optional[Mapping[str, object]] = None,
|
||
) -> None:
|
||
"""保留旧 session 参数顺序,并在边界复制冻结可信消费快照。"""
|
||
self.game_id = game_id
|
||
self.last_check = last_check
|
||
self.last_build = last_build
|
||
self.finished = finished
|
||
frozen_files = _freeze_reference_files(reference_files)
|
||
frozen_roots = _freeze_reference_roots(reference_roots)
|
||
_validate_reference_snapshot(frozen_files, frozen_roots)
|
||
object.__setattr__(self, "_reference_files", frozen_files)
|
||
object.__setattr__(self, "_reference_roots", frozen_roots)
|
||
|
||
@property
|
||
def reference_files(self) -> Mapping[str, bytes]:
|
||
"""返回只读的仓内逻辑路径到 bytes 快照映射。"""
|
||
return self._reference_files
|
||
|
||
@property
|
||
def reference_roots(self) -> Mapping[str, tuple[str, ...]]:
|
||
"""返回只读的 recordId 到受保护根路径元组映射。"""
|
||
return self._reference_roots
|
||
|
||
|
||
def build_toolkit(session: CheapSession, *, write_whitelist=None):
|
||
"""组装便宜档 Toolkit(对 tier2 toolkit.py build_toolkit)。返回 agentscope.tool.Toolkit。
|
||
|
||
Args:
|
||
session: 本 run 可变状态闭包。
|
||
write_whitelist: 可选写边界白名单(basename 集合)。None=create 路不收窄(沿用 cheap_run.write_file
|
||
的 L3 边界,禁 L1);A11 M4 模块重生成传 {"game-logic.js"},把写边界收窄到只许写玩法文件——
|
||
其余 L3(core/render/assets)连同 L1 一并禁写,确保「只改玩法、不动别处」。
|
||
"""
|
||
from agentscope.tool import Toolkit, FunctionTool
|
||
|
||
async def read_file(path: str) -> str:
|
||
"""读 repo 内任意文件(只读)。用它读 skill、插件 api.d.ts、_template 范例。先读手册再写码。
|
||
|
||
Args:
|
||
path: repo 相对路径,如 .agents/skills/littlejs-game-dev.md
|
||
"""
|
||
normalized = _normalize_repo_path(path)
|
||
if normalized is not None and _is_protected_path(session, normalized):
|
||
content = session.reference_files.get(normalized)
|
||
if content is None:
|
||
return _REFERENCE_DENY_MESSAGE
|
||
return _read_snapshot_bytes(content)
|
||
if _path_requires_rejection(path):
|
||
return "ERROR: 路径必须是仓内相对路径"
|
||
r = cheap_run.read_file(path)
|
||
if not r["ok"]:
|
||
return "ERROR: " + r["error"]
|
||
prefix = "[内容已截断]\n" if r.get("truncated") else ""
|
||
return (prefix + r["content"])[:_TOOL_RESULT_CAP]
|
||
|
||
async def list_dir(path: str) -> str:
|
||
"""列目录(只读),用于发现文件。
|
||
|
||
Args:
|
||
path: repo 相对路径。
|
||
"""
|
||
normalized = _normalize_repo_path(path)
|
||
if normalized is not None and _is_protected_path(session, normalized):
|
||
return _list_snapshot_directory(session, normalized)
|
||
if _path_requires_rejection(path):
|
||
return "ERROR: 路径必须是仓内相对路径"
|
||
r = cheap_run.list_dir(path)
|
||
if not r["ok"]:
|
||
return "ERROR: " + r["error"]
|
||
return "\n".join(r["entries"])[:_TOOL_RESULT_CAP]
|
||
|
||
async def write_file(path: str, content: str) -> str:
|
||
"""写文件到本 run 的 game 目录(仅许写 games/amgen-<id>/ 内的 L3 游戏文件)。整文件覆盖。
|
||
|
||
Args:
|
||
path: 如 game-runtime/games/amgen-<id>/src/game-logic.js
|
||
content: 完整文件内容。
|
||
"""
|
||
# M4 模块重生成:写边界白名单生效时,basename 不在白名单(即非 game-logic.js)的写一律拒——
|
||
# 本次只重写玩法文件,其余文件保持不变(read_file 读它取上下文即可)。create 路 white=None 不收窄。
|
||
if write_whitelist is not None:
|
||
base = path.rsplit("/", 1)[-1]
|
||
if base not in write_whitelist:
|
||
allowed = "/".join(sorted(write_whitelist))
|
||
return (f"ERROR: 本次是「只改玩法」的模块重生成,只许写 {allowed};"
|
||
f"{base} 属其余文件,要保持不变、不要写它(需要上下文就 read_file 读它)。")
|
||
r = cheap_run.write_file(session.game_id, path, content)
|
||
return f"OK {r['bytes']}B 已写" if r["ok"] else "ERROR: " + r["error"]
|
||
|
||
async def edit_file(path: str, old: str, new: str) -> str:
|
||
"""增量改文件:把文件里出现一次的 old 串换成 new。改小处(如一个函数几行)用它,别整文件重写。
|
||
|
||
old 必须逐字节精确匹配文件原文(含缩进/换行)且唯一命中。找不到/不唯一会明确报错——按提示加长 old 再试。
|
||
边界同 write_file:只许改 games/amgen-<id>/ 内 L3 游戏文件,L1 plumbing 拒改。
|
||
|
||
Args:
|
||
path: 如 game-runtime/games/amgen-<id>/src/game-logic.js
|
||
old: 要被替换的原文片段(复制文件里的原样,含缩进)。
|
||
new: 替换成的新片段。
|
||
"""
|
||
# 写边界白名单:与 write_file 同口径(M4"只改玩法"时非 game-logic.js 一律拒)。
|
||
if write_whitelist is not None:
|
||
base = path.rsplit("/", 1)[-1]
|
||
if base not in write_whitelist:
|
||
allowed = "/".join(sorted(write_whitelist))
|
||
return (f"ERROR: 本次是「只改玩法」的模块重生成,只许改 {allowed};"
|
||
f"{base} 属其余文件,要保持不变、不要改它。")
|
||
r = cheap_run.edit_file(session.game_id, path, old, new)
|
||
return r.get("message", "OK edit 已应用") if r["ok"] else "ERROR: " + r["error"]
|
||
|
||
async def check() -> str:
|
||
"""循环内快校验:node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。
|
||
|
||
tsc 类型门(W-AXIS 波3·advisory):`node tools.mjs check` 会在主判定后附一节 [check:tsc-advisory]。
|
||
主判定 FAIL 时它随 output 一并回给模型;主判定 PASS 时——若 advisory 有【真发现】(红线/结构门放行、
|
||
但 tsc 抓到的类型 bug,如把数字当函数调/拼错本地属性名)——把那一节附在 PASS 后回给模型自纠。
|
||
**advisory 绝不改 r["ok"]/不阻断**:只是把「红线门漏掉的类型问题」原文喂给模型(0 发现/tsc 不可用不附,免噪音)。
|
||
"""
|
||
session.last_check = cheap_run.check(session.game_id)
|
||
r = session.last_check
|
||
if r["ok"]:
|
||
out = r["output"] or ""
|
||
idx = out.find("[check:tsc-advisory]")
|
||
adv = out[idx:].strip() if idx >= 0 else ""
|
||
# 仅当 advisory 有真发现才附("0 发现"/"不可用" 对模型是噪音,不附);不改 PASS 判定。
|
||
surface = bool(adv) and ("发现" in adv) and ("0 发现" not in adv)
|
||
return ("PASS" + ("\n" + adv if surface else ""))[:_TOOL_RESULT_CAP]
|
||
return ("FAIL:\n" + r["output"])[:_TOOL_RESULT_CAP]
|
||
|
||
async def build() -> str:
|
||
"""循环内 esbuild 打包(秒级,SAA 信封 __GameBundle)。返回 PASS 或编译错误。finish 前必须 PASS。"""
|
||
session.last_build = cheap_run.build(session.game_id)
|
||
r = session.last_build
|
||
return ("PASS" if r["ok"] else "FAIL:\n" + r["output"])[:_TOOL_RESULT_CAP]
|
||
|
||
async def finish(summary: str) -> str:
|
||
"""声明完成。done 门:工具内复核 check 与 build 都 PASS 才接受(不信自评;对 gen.mjs done 门)。
|
||
|
||
Args:
|
||
summary: 一句话:你做了一款什么游戏、核心玩法。
|
||
"""
|
||
c = cheap_run.check(session.game_id)
|
||
b = cheap_run.build(session.game_id) if c["ok"] else {"ok": False, "output": "check 未过,先修 check"}
|
||
session.last_check = c
|
||
if c["ok"]:
|
||
session.last_build = b
|
||
if c["ok"] and b["ok"]:
|
||
session.finished = {"summary": summary, "gameId": session.game_id}
|
||
return json.dumps({"ok": True, "message": "DONE 已接受(check+build 绿)"}, ensure_ascii=False)
|
||
return json.dumps({
|
||
"ok": False, "blockedByGate": True,
|
||
"message": ("finish 被拒:done 前 check+build 必须都绿。"
|
||
f"check={'PASS' if c['ok'] else 'FAIL'} build={'PASS' if b.get('ok') else 'FAIL'}"),
|
||
"checkOutput": (c.get("output") or "")[:2000],
|
||
"buildOutput": (b.get("output") or "")[:2000],
|
||
}, ensure_ascii=False)
|
||
|
||
tools = [
|
||
FunctionTool(read_file), FunctionTool(list_dir), FunctionTool(write_file),
|
||
FunctionTool(edit_file), FunctionTool(check), FunctionTool(build), FunctionTool(finish),
|
||
]
|
||
return Toolkit(tools=tools)
|