lili 686aaaa421
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(reference-assets): 签认《山海行纪》并闭合可信消费门
冻结地图1二十分钟纵切版的平衡、证据与金标登记。

新增 ReferenceAsset/2 清单、策略、release、受信快照及 CLI/Service/acceptance provenance /4 消费链;保持 survivor live、R1 签名与部署关闭。
2026-07-28 09:11:54 -07:00

334 lines
16 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_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 的 Tier2SessionLittleJS 简化版)。
单 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 边界,禁 L1A11 M4 模块重生成传 {"game-logic.js"},把写边界收窄到只许写玩法文件——
其余 L3core/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)