feat(tools): 落成工具规格与注册表,四者同源由同一个实例驱动
ToolSpec 五个字段加构造期校验(空名字、非映射 parameters),parameters 深拷贝一份存下来 ——不拷贝的话调用方在别处改那个字典会连带改掉模型看见的 schema,而那次修改没有任何地方 记录得到。 ToolRegistry 是不可变值对象:注册顺序保留(工具清单要贴进提示词,而 restrict_to 收到的 常常是 set,照集合顺序输出会让同一份配置在不同进程里渲染出不同提示词);相等按内容判 不按身份判,供 RunRequest 那条一致性校验用;restrict_to 遇到不认识的名字直接报错, 不静默丢弃。 validate 校验的是 JSON Schema 的一个子集(必填键、additionalProperties: false 时的多余键、 顶层 type),子集边界写在 docstring 里。完整校验只能靠第三方库,而公共签名上不许出现 第三方类型。剩下那部分由工具自己报错,那是一条正常观察。 executor() 没写,缺口见 design/0008(待确认,要过人类门)。
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""工具规格与注册表的行为。
|
||||
|
||||
这里断言的不是「注册表有哪些方法」,是它对外的行为——名字与签名本身由
|
||||
`research-wiki/design/0006-public-names-and-signatures.md` 决策六承诺,改了是破坏性变更。
|
||||
|
||||
最要紧的一条是**四者同源**:模型看见的 schema、存在性校验、参数校验、分发都由同一个实例
|
||||
驱动。收窄之后模型看不见的工具,校验也必须拒绝它——这一条单独写在下面,因为它正是这个
|
||||
模块存在的理由。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from polyloop.ports import ToolCall
|
||||
from polyloop.tools import ToolRegistry, ToolSpec, ToolValidationError
|
||||
from polyloop.types import ReplayPolicy
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _spec(name: str, **kwargs: object) -> ToolSpec:
|
||||
"""造一个规格,只有 `name` 是必须自己填的。"""
|
||||
parameters = kwargs.pop("parameters", {})
|
||||
return ToolSpec(name=name, description=f"{name} 的说明", parameters=parameters, **kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolSpec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spec_rejects_an_empty_name() -> None:
|
||||
"""空名字在提示词里不可见,模型永远调不到它。"""
|
||||
with pytest.raises(ValueError, match="工具名"):
|
||||
ToolSpec(name="", description="", parameters={})
|
||||
|
||||
|
||||
def test_spec_rejects_non_mapping_parameters() -> None:
|
||||
with pytest.raises(TypeError, match="parameters"):
|
||||
ToolSpec(name="search", description="", parameters=["query"]) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_spec_snapshots_the_parameters_it_was_given() -> None:
|
||||
"""注册之后改调用方那份字典,注册表看见的仍是注册那一刻的形状。
|
||||
|
||||
不拷贝的话,「模型看见的 schema」会随调用方在别处的一次修改一起变,而那次修改没有任何
|
||||
地方记录得到——事后翻轨迹,模型当时到底看见的是哪一份,查不出来。
|
||||
"""
|
||||
schema: dict[str, object] = {"properties": {"query": {"type": "string"}}}
|
||||
spec = _spec("search", parameters=schema)
|
||||
|
||||
schema["properties"] = {"query": {"type": "integer"}} # type: ignore[index]
|
||||
|
||||
assert spec.parameters["properties"] == {"query": {"type": "string"}}
|
||||
|
||||
|
||||
def test_spec_defaults_are_the_conservative_ones() -> None:
|
||||
"""重放策略默认「绝不重放」、完成标记默认「不完成」。
|
||||
|
||||
两个默认值的方向都是「漏了声明只会多停一次,有人会看见」,反过来则是静默重复副作用、
|
||||
或者一次运行永远停不下来。
|
||||
"""
|
||||
spec = _spec("write_file")
|
||||
|
||||
assert spec.replay_policy is ReplayPolicy.NEVER
|
||||
assert spec.completes_run is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 注册与查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_names() -> None:
|
||||
"""重名会让「模型看见的那一份」和「分发时取到的那一份」取决于注册顺序。"""
|
||||
with pytest.raises(ValueError, match="重复"):
|
||||
ToolRegistry([_spec("search"), _spec("search")])
|
||||
|
||||
|
||||
def test_registry_rejects_things_that_are_not_specs() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
ToolRegistry([{"name": "search"}]) # type: ignore[list-item]
|
||||
|
||||
|
||||
def test_an_empty_registry_is_constructible() -> None:
|
||||
"""不注册任何工具是一种正常装配:模型输出的是一整段代码,不是工具调用。"""
|
||||
registry = ToolRegistry()
|
||||
|
||||
assert registry.names() == ()
|
||||
assert list(registry.schema_for_model()) == []
|
||||
|
||||
|
||||
def test_registration_order_is_preserved() -> None:
|
||||
"""工具清单要贴进提示词,顺序必须是确定的。"""
|
||||
registry = ToolRegistry([_spec("read"), _spec("write"), _spec("search")])
|
||||
|
||||
assert registry.names() == ("read", "write", "search")
|
||||
assert [entry["name"] for entry in registry.schema_for_model()] == ["read", "write", "search"]
|
||||
|
||||
|
||||
def test_spec_for_returns_none_for_an_unregistered_name() -> None:
|
||||
"""问一个不在注册表里的名字是正常情形,不是错误。
|
||||
|
||||
没有工具的动作(一整段代码)就问不出规格,那时调用方取「绝不重放」。抛异常的话这条
|
||||
正常路径要用捕获异常来走。
|
||||
"""
|
||||
registry = ToolRegistry([_spec("read")])
|
||||
|
||||
assert registry.spec_for("read") is not None
|
||||
assert registry.spec_for("write") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 收窄
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restrict_to_keeps_registration_order_not_the_caller_order() -> None:
|
||||
"""收窄结果按注册顺序排,不按传进来的那个集合的迭代顺序。
|
||||
|
||||
收窄清单常常是一个 `set`,而 Python 的字符串哈希每进程随机——照集合顺序输出会让同一份
|
||||
配置在不同进程里渲染出不同的提示词。
|
||||
"""
|
||||
registry = ToolRegistry([_spec("read"), _spec("write"), _spec("search")])
|
||||
|
||||
narrowed = registry.restrict_to({"search", "read"})
|
||||
|
||||
assert narrowed.names() == ("read", "search")
|
||||
|
||||
|
||||
def test_restrict_to_leaves_the_original_alone() -> None:
|
||||
"""注册表是不可变值对象,取子集返回新实例。"""
|
||||
registry = ToolRegistry([_spec("read"), _spec("write")])
|
||||
|
||||
narrowed = registry.restrict_to(["read"])
|
||||
|
||||
assert narrowed.names() == ("read",)
|
||||
assert registry.names() == ("read", "write")
|
||||
|
||||
|
||||
def test_restrict_to_rejects_a_name_it_does_not_have() -> None:
|
||||
"""静默丢弃的话,模型看不见调用方以为已经开放的那个工具,而表现是模型在绕圈。"""
|
||||
registry = ToolRegistry([_spec("read")])
|
||||
|
||||
with pytest.raises(ValueError, match="不在注册表里"):
|
||||
registry.restrict_to(["read", "reed"])
|
||||
|
||||
|
||||
def test_two_registries_with_the_same_specs_are_equal() -> None:
|
||||
"""相等按「注册了哪些规格、什么顺序」判,不按对象身份判。
|
||||
|
||||
构造运行请求时要比对「执行器持有的注册表」和「本次可见的注册表」,两份内容相同的注册表
|
||||
在模型可见 schema 与实际分发上完全一致,没有可失败的地方。
|
||||
"""
|
||||
specs = [_spec("read"), _spec("write")]
|
||||
|
||||
assert ToolRegistry(specs) == ToolRegistry(specs)
|
||||
assert ToolRegistry(specs) != ToolRegistry(list(reversed(specs)))
|
||||
assert ToolRegistry(specs) != ToolRegistry([_spec("read")])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模型可见的 schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schema_for_model_is_json_serialisable() -> None:
|
||||
"""这份清单要贴进提示词或者发给模型 API,必须能直接序列化。"""
|
||||
registry = ToolRegistry(
|
||||
[_spec("read", parameters={"properties": {"path": {"type": "string"}}})]
|
||||
)
|
||||
|
||||
json.dumps(list(registry.schema_for_model()))
|
||||
|
||||
|
||||
def test_mutating_the_returned_schema_does_not_touch_the_registry() -> None:
|
||||
"""每次调用现造一份新的普通字典,调用方改它不会影响后面几步看见的东西。"""
|
||||
registry = ToolRegistry(
|
||||
[_spec("read", parameters={"properties": {"path": {"type": "string"}}})]
|
||||
)
|
||||
|
||||
entry = registry.schema_for_model()[0]
|
||||
entry["parameters"]["properties"]["path"]["type"] = "integer" # type: ignore[index]
|
||||
|
||||
assert registry.schema_for_model()[0]["parameters"] == {
|
||||
"properties": {"path": {"type": "string"}}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_accepts_a_well_formed_call() -> None:
|
||||
registry = ToolRegistry(
|
||||
[
|
||||
_spec(
|
||||
"read",
|
||||
parameters={
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
registry.validate(ToolCall(name="read", arguments={"path": "a.txt"}))
|
||||
|
||||
|
||||
def test_validate_rejects_a_tool_that_is_not_registered() -> None:
|
||||
registry = ToolRegistry([_spec("read")])
|
||||
|
||||
with pytest.raises(ToolValidationError, match="不存在"):
|
||||
registry.validate(ToolCall(name="write", arguments={}))
|
||||
|
||||
|
||||
def test_validate_rejects_a_missing_required_parameter() -> None:
|
||||
registry = ToolRegistry(
|
||||
[
|
||||
_spec(
|
||||
"read",
|
||||
parameters={"properties": {"path": {"type": "string"}}, "required": ["path"]},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ToolValidationError, match="必填"):
|
||||
registry.validate(ToolCall(name="read", arguments={}))
|
||||
|
||||
|
||||
def test_validate_rejects_an_undeclared_parameter_only_when_the_schema_says_so() -> None:
|
||||
"""多出来的键只在 schema 显式写了 `additionalProperties: false` 时才拒绝。
|
||||
|
||||
JSON Schema 里这一项默认为真,跟着它走;自作主张收紧的话,一份合法 schema 在库里和在别处
|
||||
的含义就不一样了,而不一样的地方没有任何标记。
|
||||
"""
|
||||
lenient = ToolRegistry([_spec("read", parameters={"properties": {"path": {"type": "string"}}})])
|
||||
strict = ToolRegistry(
|
||||
[
|
||||
_spec(
|
||||
"read",
|
||||
parameters={
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
call = ToolCall(name="read", arguments={"path": "a.txt", "encoding": "utf-8"})
|
||||
|
||||
lenient.validate(call)
|
||||
with pytest.raises(ToolValidationError, match="未声明"):
|
||||
strict.validate(call)
|
||||
|
||||
|
||||
def test_validate_rejects_a_wrong_top_level_type() -> None:
|
||||
registry = ToolRegistry(
|
||||
[_spec("read", parameters={"properties": {"path": {"type": "string"}}})]
|
||||
)
|
||||
|
||||
with pytest.raises(ToolValidationError, match="类型"):
|
||||
registry.validate(ToolCall(name="read", arguments={"path": 7}))
|
||||
|
||||
|
||||
def test_a_boolean_is_not_an_integer() -> None:
|
||||
"""布尔在 Python 里是整数的子类,在 JSON 里不是。
|
||||
|
||||
不排掉的话,一个声明收整数的工具会收下 `True`,然后在工具内部被当成 1 用。
|
||||
"""
|
||||
registry = ToolRegistry([_spec("head", parameters={"properties": {"n": {"type": "integer"}}})])
|
||||
|
||||
with pytest.raises(ToolValidationError, match="类型"):
|
||||
registry.validate(ToolCall(name="head", arguments={"n": True}))
|
||||
|
||||
|
||||
def test_a_type_may_be_declared_as_a_list_of_alternatives() -> None:
|
||||
registry = ToolRegistry(
|
||||
[_spec("head", parameters={"properties": {"n": {"type": ["integer", "null"]}}})]
|
||||
)
|
||||
|
||||
registry.validate(ToolCall(name="head", arguments={"n": 3}))
|
||||
registry.validate(ToolCall(name="head", arguments={"n": None}))
|
||||
with pytest.raises(ToolValidationError, match="类型"):
|
||||
registry.validate(ToolCall(name="head", arguments={"n": "3"}))
|
||||
|
||||
|
||||
def test_an_empty_schema_accepts_anything() -> None:
|
||||
"""没声明参数就等于不约束参数,不等于「不许带参数」。"""
|
||||
registry = ToolRegistry([_spec("ping")])
|
||||
|
||||
registry.validate(ToolCall(name="ping", arguments={"anything": [1, 2, 3]}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 四者同源
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_narrowing_hides_a_tool_from_the_model_and_from_validation_together() -> None:
|
||||
"""收窄之后模型看不见的工具,校验也拒绝它。
|
||||
|
||||
这是这个模块存在的理由:模型看见的 schema、存在性校验、参数校验、分发四者同源。不同源
|
||||
的表现是「模型调了一个它看得见的工具却说不存在」,或者反过来——校验放行了一个分发时
|
||||
找不到的名字。
|
||||
"""
|
||||
registry = ToolRegistry([_spec("read"), _spec("write")])
|
||||
|
||||
narrowed = registry.restrict_to(["read"])
|
||||
|
||||
assert [entry["name"] for entry in narrowed.schema_for_model()] == ["read"]
|
||||
assert narrowed.spec_for("write") is None
|
||||
with pytest.raises(ToolValidationError):
|
||||
narrowed.validate(ToolCall(name="write", arguments={}))
|
||||
Reference in New Issue
Block a user