"""工具规格与注册表的行为。 这里断言的不是「注册表有哪些方法」,是它对外的行为——名字与签名本身由 `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_a_blank_name() -> None: """纯空白的名字和空串一样,在提示词里不可见。""" with pytest.raises(ValueError, match="工具名"): ToolSpec(name=" ", description="", parameters={}) def test_spec_rejects_a_name_that_is_not_a_string() -> None: """名字要落进发给模型的那份 schema,不是字符串的话整个请求会被网关拒掉。 那时报错指向请求体、不指向注册表,而两者隔着好几层。 """ with pytest.raises(TypeError, match="工具名"): ToolSpec(name=123, description="", parameters={}) # type: ignore[arg-type] def test_spec_rejects_a_description_that_is_not_a_string() -> None: with pytest.raises(TypeError, match="工具说明"): ToolSpec(name="search", description=object(), parameters={}) # type: ignore[arg-type] 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_a_spec_taken_out_of_the_registry_cannot_be_edited_in_place() -> None: """从注册表里取出规格、往里伸一层去改,改不动。 只冻最外面一层挡不住这种改法,而它一下同时改掉模型看见的 schema 和校验用的 schema—— 第 1 步模型看到的和第 5 步校验用的就分了岔,而这次修改没有任何地方记录得到。 """ registry = ToolRegistry( [ _spec( "read", parameters={"properties": {"path": {"type": "string"}}, "required": ["path"]}, ) ] ) taken = registry.spec_for("read") assert taken is not None with pytest.raises(TypeError): taken.parameters["required"] = ["path", "mode"] # type: ignore[index] with pytest.raises(TypeError): taken.parameters["properties"]["path"]["type"] = "integer" # type: ignore[index] registry.validate(ToolCall(name="read", arguments={"path": "a.txt"})) 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_float_with_no_fractional_part_counts_as_an_integer() -> None: """JSON Schema draft-06 起,小数部分为零的浮点数是合法的整数。 模型写出 `1e2` 或者 `3.0`,`json.loads` 给的就是 float。照「必须是 int」判会拒掉一次 合法调用,而模型怎么改都过不去——它写的东西按标准就是对的。 """ registry = ToolRegistry([_spec("head", parameters={"properties": {"n": {"type": "integer"}}})]) registry.validate(ToolCall(name="head", arguments={"n": 3.0})) with pytest.raises(ToolValidationError, match="类型"): registry.validate(ToolCall(name="head", arguments={"n": 3.5})) def test_pattern_properties_switches_off_the_unknown_key_check() -> None: """`patternProperties` 在场时,「哪些键被声明过」要靠正则才答得出,这里不答。 照 `properties` 的键去判的话,每一个匹配到 pattern 的键都会被拒——而那些正是这份 schema 专门要收的键,模型改名字也绕不过去。 """ registry = ToolRegistry( [ _spec( "put", parameters={ "patternProperties": {"^x_": {"type": "string"}}, "additionalProperties": False, }, ) ] ) registry.validate(ToolCall(name="put", arguments={"x_1": "a"})) def test_validate_rejects_arguments_that_are_not_a_mapping() -> None: """`ToolCall` 自己不校验字段,一个列表参数能从下游的解释器直接产出。 不在这里拦住的话,后面那句 `.items()` 会抛 `AttributeError` 打断整次运行;拦住了它就 只是一条正常观察,模型收到说明可以自己纠正。 """ registry = ToolRegistry([_spec("read")]) with pytest.raises(ToolValidationError, match="映射"): registry.validate(ToolCall(name="read", arguments=["a.txt"])) # type: ignore[arg-type] 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={}))