Files
agentmc/tests/test_api.py
JianFeeeee 94bcd37e66 M7:面向 LLM 的 API 形状(23 个工具)+ 17 项判据
新增 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 项变异全被抓
2026-09-26 13:17:17 +08:00

334 lines
13 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.

"""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:全部通过")