fix(tools): 修掉两个假阳性与一个改得动的内部状态,按两轮独立评审

代码审查(新鲜上下文,只给 diff 与验收标准)报了五条影响正确性的,逐条核实全部成立:

1. spec_for() 交出去的 parameters 就是注册表内部那份真字典。docstring 承诺的快照只挡住了
   「调用方改自己那份」,没挡住「从注册表取出来往里伸一层改」——而后者一下同时改掉模型
   看见的 schema 和校验用的 schema。改成逐层冻成只读视图,schema_for_model 出口再化回
   普通字典与列表。
2. {type: integer} 拒掉 3.0。JSON Schema draft-06 起小数部分为零的浮点数是合法整数,
   模型写 1e2 时 json.loads 给的就是 float。这是我自己在注释里点名最怕的那种假阳性。
3. additionalProperties: false 撞上 patternProperties 时拒掉一切匹配 pattern 的键。
   那些正是这份 schema 专门要收的键,模型改名也绕不过去。patternProperties 在场就跳过。
4. 工具名不校验类型、纯空白名放行。名字要落进发给模型的 schema,不是字符串会让整个请求
   被网关拒掉,报错指向请求体不指向注册表。
5. _by_name 是可变 dict,两条查询路径能被就地改到分岔。换成只读视图。

不可哈希那条不修,改在 docstring 里写明(参数 schema 是映射,注册表放不进 set)。
scope.md 的行号引用换成条目名——行号是最容易漂的一种参数,插一行就静默指错。

0008 按一轮硕士生冷读重写:字段位置那段原来自相矛盾(一边说位置是公共承诺、插在中间会
静默改掉后面字段的含义,一边就插在中间,且没讨论追加在末尾这个同一判据下的显然选项),
改成追加在末尾并说明规则;补上四个名字的就地解释(重放策略、两条完成通路、动作结果五个
字段、restrict_to);决策四那张表原来只有三列却被正文说成填五个字段,恒定的两个单列出来
并各自给了理由;补上 validate 不通过为什么算未执行、executor() 为什么全查、为什么必须是
具体类而不是闭包、异常栈去哪了。
This commit is contained in:
2026-08-10 00:57:36 -04:00
parent 6abe13abb1
commit 956d98652d
3 changed files with 235 additions and 50 deletions
+91
View File
@@ -36,6 +36,26 @@ def test_spec_rejects_an_empty_name() -> None:
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]
@@ -55,6 +75,31 @@ def test_spec_snapshots_the_parameters_it_was_given() -> None:
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:
"""重放策略默认「绝不重放」、完成标记默认「不完成」。
@@ -276,6 +321,52 @@ def test_a_boolean_is_not_an_integer() -> None:
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"]}}})]