新增 mcfold/api.py:把 M3/M4/M5/M6 包成 agent 能自己摸清的工具箱
- 观察 14:where_am_i/block_at/column/see/raycast/walkable/slice/
nearby_blocks/nearby_entities/inventory/surroundings/look_at
- 事件 1:events(含细分层按前缀查)
- 操作 7:chat/command/look/look_at/move/dig/place/use_item/select_slot
- 会话 2:status/describe
- call(name, **kwargs) 派发入口
照 plan §1d 五个维度落地:
- 参数:全部 keyword-only(坐标必须按名字传,LLM 不会搞混 x/y/z)
- 返回:每条带 describe(人类可读摘要)
- 失败:永不抛异常 → ok:false + why + how_to
- 规模:限量 + total/remaining/truncated,明说被截断
- 发现:describe() 一次列全工具 + 约定字典 + 4 条提示;目录与实现双向一致
判据抓到的真问题:坐标工具原允许位置传参 ⇒ 已全改 keyword-only。
★ 判据自身也修正过一次:我把「不能有必填参数」误当成规则,
实际要判的是参数**种类**(关键字专用)——「坐标必需」与「可位置传」是两件事。
★ 操作失误:变异测试用过期 /tmp 备份覆盖了 state.py(丢 113 行),
导致一个变异「被抓」其实是坏代码造成 ⇒ 恢复必须用 git,
且每个变异单独确认基线绿。重做该变异后才确认判据有效。
163 项全通过;5 项变异全被抓
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""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:全部通过")
|