"""M7 面向 LLM 的 API 判据:api.py。 ## 判据照着 plan.md §1d 的五个维度 | 维度 | 判据 | |---|---| | 参数 | 全命名;调用签名无位置参数;工具目录声明了每个参数 | | 返回 | 每条结果都带 `describe`(可读摘要) | | 失败 | **不抛异常**:`ok:false` + `why` + `how_to` | | 规模 | 限量 + `total`/`truncated`/`remaining`,且**明确说被截断** | | 发现 | `describe()` 一次列全工具 + 约定 + 提示 | ## 特别要守住的 1. **LLM 绝不该看到 traceback** —— 任何输入都要得到结构化答复。 判据用「故意传错参数 / 传垃圾值」来验证这一点。 2. **失败答复必须能指导下一步**(`how_to`),而不只是说「错了」。 3. **不许编造**:`inventory` 里认不出的物品不能给个像样的名字。 """ import sys, os, json, inspect sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from mcfold.api import Tools, TOOLS, _direction_vector from mcfold.state import WorldState, load_vanilla_registry from mcfold.events import EventLog from mcfold.chunk import BlockRegistry from mcfold import rough DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/' 'minecraft-data/minecraft-data/data') _REG = load_vanilla_registry('1.21.1', DATA_ROOT) STONE, DIRT, GRASS, AIR = 1, 10, 9, 0 class _Chunk: def __init__(self, fn): self.fn = fn def state_id_at(self, x, y, z): return self.fn(x, y, z) def _scene(fn, chunks=((0, 0),), registry=None): ws = WorldState(registry=registry if registry is not None else _REG, num_sections=24) for cx, cz in chunks: ws.world.chunks[(cx, cz)] = _Chunk(fn) ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5 return ws def _tools(fn=None, chunks=((0, 0),), registry=None): """一个不联网的 Tools(只测 API 形状,不测网络)。""" if fn is None: fn = lambda x, y, z: STONE if y < 0 else AIR ws = _scene(fn, chunks=chunks, registry=registry) t = Tools.__new__(Tools) t.s = type("S", (), {})() t.s.state = ws t.s.events = EventLog() t.s.actions = None # 操作类单独测 t.s.client = type("C", (), {"phase": "play", "proto_num": 767, "decode_failures": []})() t.s.teleports_confirmed = 1 return t # ---------- ① 自省:一次列全 ---------- def test_describe_lists_all_tools(): """★ describe() 必须**一次给全**工具 + 参数,让 LLM 不用逐个试错。""" t = _tools() d = t.describe() assert d["tool_count"] == len(TOOLS) >= 20, d["tool_count"] for name in ("where_am_i", "block_at", "see", "dig", "place", "events", "inventory", "status"): assert name in d["tools"], f"缺少 {name}" # 参数说明必须存在且是字符串说明(LLM 要读) assert d["tools"]["block_at"]["params"], d["tools"]["block_at"] assert d["tools"]["block_at"]["summary"] # 约定与提示是「怎么理解返回值」的关键 assert "conventions" in d and "方块 source" in d["conventions"], list(d["conventions"]) assert d["tips"], "必须给 agent 提示" print(f" ★ describe() 一次列全 {d['tool_count']} 个工具 + 约定 + 提示") def test_tool_catalog_matches_actual_methods(): """★ 工具目录与真实方法必须**一致**(否则自省在说谎)。""" t = _tools() for name in TOOLS: fn = getattr(t, name, None) assert callable(fn), f"目录里声明了 {name} 但没有对应方法" # 反向:公开方法(除 call/内部)都应在目录里 pub = {n for n, _ in inspect.getmembers(t, predicate=callable) if not n.startswith("_") and n not in ("call",)} missing = pub - set(TOOLS) assert not missing, f"这些公开方法不在目录里(LLM 看不到):{sorted(missing)}" print(f" ★ 目录与实现双向一致({len(TOOLS)} 个)") def test_no_positional_params_on_public_tools(): """★ 参数必须**按名字传**(KEYWORD_ONLY)。 plan §1d:LLM 靠参数**名**猜,位置参数对它就是陷阱 (`block_at(1, 2, 3)` 里的 1/2/3 是什么?LLM 会猜错)。 ★ 注意:这里判的是**参数种类**(关键字专用),不是「有没有默认值」。 坐标是必需的,但必须以关键字形式给 —— 这两件事不同, 我第一版把它们混为一谈,结果把「必需」误判成「违规」。 """ t = _tools() bad: list[str] = [] for name in TOOLS: sig = inspect.signature(getattr(t, name)) for p in list(sig.parameters.values())[1:]: if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): bad.append(f"{name}.{p.name}") assert not bad, f"这些参数可按位置传(LLM 会猜错):{bad}" print(" ★ 所有工具参数均为关键字专用(必须按名字传)") # ---------- ② 失败:永不抛异常,且可读 ---------- def test_call_unknown_tool_is_readable(): """★ 调不存在的工具 → 结构化答复 + 可用清单(不是 KeyError)。""" t = _tools() r = t.call("no_such_tool") assert r["ok"] is False, r assert "no_such_tool" in r["why"], r assert r["how_to"], r assert "describe" in r["how_to"], r assert r["available"], "应给出可用工具清单" print(" ★ 未知工具:结构化答复 + 工具清单") def test_call_wrong_params_is_readable(): """★ 参数给错 → 报错说清缺什么,并回该工具的参数说明。""" t = _tools() r = t.call("block_at") # 缺 x/y/z assert r["ok"] is False, r assert r["why"] and r["how_to"], r assert "x" in str(r["how_to"]), r print(" ★ 参数缺失:可读说明 + 该工具的参数表") def test_call_never_raises_on_garbage(): """★★ 任何垃圾输入都必须得到结构化答复(LLM 绝不该见 traceback)。""" t = _tools() for name, kwargs in [ ("block_at", {"x": "abc", "y": None, "z": [1]}), ("see", {}), ("walkable", {"radius": "很大"}), ("slice", {"axis": 123}), ("column", {"x": None, "z": object()}), ("nearby_blocks", {"radius": -5}), ("raycast", {"direction": "斜着"}), ("select_slot", {"slot": "第一格"}), ("look", {}), ("dig", {"x": 1, "y": 2, "z": 3, "face": "东南"}), ]: r = t.call(name, **kwargs) assert isinstance(r, dict), f"{name} 返回了非 dict:{type(r)}" assert "ok" in r, f"{name} 缺 ok 字段" if r["ok"] is False: assert r.get("why"), f"{name} 失败时缺 why" assert r.get("how_to"), f"{name} 失败时缺 how_to" print(" ★ 垃圾输入全部得到结构化答复(无异常泄漏)") def test_private_methods_not_callable(): """★ 下划线开头的方法不能被 call(防止 agent 摸到内部)。""" t = _tools() for name in ("_solid_of", "_direction_vector", "_scene"): r = t.call(name) assert r["ok"] is False, f"{name} 不该能被调用" print(" ✓ 私有方法不可经 call 调用") # ---------- ③ 返回:每条都带可读摘要 ---------- def test_every_result_has_describe(): """★ 所有观察类工具的返回都要有 `describe`(LLM 的主阅读入口)。""" def fn(x, y, z): return STONE if y < 0 else AIR t = _tools(fn) for name, kwargs in [ ("where_am_i", {}), ("block_at", {"x": 0, "y": -1, "z": 0}), ("column", {}), ("surroundings", {}), ("walkable", {"radius": 2}), ("slice", {"axis": "z", "width": 6, "height": 4}), ("nearby_blocks", {"radius": 2}), ("nearby_entities", {}), ("inventory", {}), ("status", {}), ("raycast", {"direction": "down"}), ("see", {"x": 0.5, "y": 1.0, "z": 0.5}), ]: r = t.call(name, **kwargs) assert r["ok"] is True, f"{name} 应成功:{r}" assert r.get("describe"), f"{name} 缺 describe" assert isinstance(r["describe"], str), f"{name} 的 describe 不是文本" print(" ★ 12 个观察工具都带可读 describe") # ---------- ④ 规模:有界 + 明说截断 ---------- def test_bounded_results_say_truncated(): """★ 超限要**明确说被截断**(LLM 才知道该缩小范围)。""" # 只有 4 种方块,放不进 12 格的呈现上限?不 —— 改为验证切面的上限拒绝 t = _tools() r = t.slice(axis="z", width=200, height=200) assert r["ok"] is False, r assert r["how_to"], r print(" ★ 超限切面:拒绝 + 给出怎么办") def test_walkable_grid_stats_consistent(): t = _tools() r = t.walkable(radius=3) flat = "".join(r["grid"]) st = r["stats"] assert st["open"] == flat.count(".") assert st["blocked"] == flat.count("#") assert st["unknown"] == flat.count("?") assert st["open"] + st["blocked"] + st["unknown"] == 49 print(" ★ 可行走网格统计与字符一致(49 格)") def test_events_reports_remaining(): """★ 事件取走一批后要说明**还剩多少**(否则 LLM 以为看全了)。""" t = _tools() for i in range(10): t.s.events.emit("chat", {"text": f"m{i}"}) r = t.events(limit=4) assert r["count"] == 4, r assert r["remaining"] == 6, r assert "剩余 6" in r["describe"], r["describe"] print(" ★ 事件限量取用并报告剩余数量") # ---------- ⑤ 诚实:不编造 ---------- def test_inventory_does_not_invent_item_names(): """★ 认不出的物品只给 id 或留空,**不**编一个像样的名字。""" # 一张缺残注册表:只认识 stone tiny = BlockRegistry([{"name": "stone", "minStateId": 1, "maxStateId": 1}]) t = _tools(registry=tiny) t.s.state.slots[36] = {"itemCount": 64, "itemId": 1} t.s.state.hotbar[0] = {"itemCount": 64, "itemId": 1} t.s.state.player.held_slot = 0 r = t.inventory() assert r["held"], r assert "item#" in str(r["held"]), f"应给可辨识 id 而非编名字:{r['held']}" # 未收到的槽位不该被填 assert set(r["hotbar"].keys()) == {0}, r["hotbar"] print(f" ★ 物品只给可辨识 id({r['held']}),不编名字") def test_inventory_says_unknown_when_empty(): t = _tools() r = t.inventory() assert r["held"] is None, r assert r["hotbar"] == {}, r assert "未知" in r["describe"] or "还没收到" in r["note"], r print(" ★ 空背包如实说「未知」(不假装确定)") def test_status_warnings_are_specific(): """★ status() 的告警要**具体**(能据此行动),不是泛泛一句。""" t = _tools() t.s.client.decode_failures = [{"name": "declare_recipes"}] t.s.state.world.unknown_state_ids[9999] = 3 r = t.status() assert r["warnings"], r joined = " ".join(r["warnings"]) assert "declare_recipes" in joined or "解不开" in joined, joined assert "mod" in joined or "认不出" in joined, joined assert r["decode_failures"] == 1 print(f" ★ 告警具体:{r['warnings']}") def test_block_at_reports_unknown_state_honestly(): """★ 认不出的方块:ok=False + 如实说明(不假装成功)。""" tiny = BlockRegistry([{"name": "stone", "minStateId": 1, "maxStateId": 1}]) t = _tools(lambda x, y, z: STONE if y < 0 else 9999, registry=tiny) r = t.block_at(x=0, y=0, z=0) assert r["ok"] is False, f"认不出时不该报 ok:{r}" assert r["source"] == "unknown-state", r assert "unknown" in r["describe"], r print(f" ★ 未知方块 ok=False({r['describe']})") # ---------- ⑥ 方向解析 ---------- def test_direction_table(): """★ 方向名 → 向量;不认识就 None(由 call 转成可读失败)。""" assert _direction_vector(None, "x") == (1, 0, 0) assert _direction_vector(None, "-y") == (0, -1, 0) assert _direction_vector(None, "斜着") is None print(" ✓ 方向表正确,未知为 None") def test_raycast_bad_direction_readable(): t = _tools() r = t.raycast(direction="斜着") assert r["ok"] is False, r assert "方向" in r["why"], r assert "-x" in r["how_to"], r print(" ★ 方向不认识:可读失败 + 列出合法值") if __name__ == "__main__": for fn in [test_describe_lists_all_tools, test_tool_catalog_matches_actual_methods, test_no_positional_params_on_public_tools, test_call_unknown_tool_is_readable, test_call_wrong_params_is_readable, test_call_never_raises_on_garbage, test_private_methods_not_callable, test_every_result_has_describe, test_bounded_results_say_truncated, test_walkable_grid_stats_consistent, test_events_reports_remaining, test_inventory_does_not_invent_item_names, test_inventory_says_unknown_when_empty, test_status_warnings_are_specific, test_block_at_reports_unknown_state_honestly, test_direction_table, test_raycast_bad_direction_readable]: fn() print("\nLLM API:全部通过")