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
+83 -36
View File
@@ -22,8 +22,30 @@
任何 handler,直接合成未执行」),但从没有任何一处把它写成字段或签名。所以这不是被写在别处 任何 handler,直接合成未执行」),但从没有任何一处把它写成字段或签名。所以这不是被写在别处
的东西,是从没写过的东西。 的东西,是从没写过的东西。
它和 `0007` 那三个问题是同一族:散文读着通顺,只有要写出那行代码时才发现缺了一样。这次 它和 `0007` 那三个问题是同一族:散文读着通顺,只有要写出那行代码时才发现缺了一样。那三个
实现时撞出来的,不是写契约测试时 契约测试时撞出来的,这一个是写实现时撞出来的
## 读本文需要的四个名字
它们都定在别处,这里各给一句,免得读到一半得去翻另外三份文档。
**`ReplayPolicy`(重放策略)** 是一个工具对「我幂等吗」的回答,两个取值:`SAFE` 说重复执行
一次无害(读文件、检索),`NEVER` 说有不可重复的副作用(写文件、调外部服务)。它只在恢复
时被用到:进程崩在「动作意图已写、结果还没写」之间,那个动作到底执行没执行是未知的,
`SAFE` 的可以再跑一次,`NEVER` 的不敢(`0002` 决策四)。
**两条完成通路,可信度不同。** `ToolSpec.completes_run` 是**注册表侧**的标记:这个工具一旦
被成功执行就代表目标达成。它是 agent 自报——agent 调一个提交型工具宣布自己做完了,环境
状态一点没变。`ActionOutcome.env_reported_completion` 是**环境侧**的信号:去问环境「目标达成
了吗」,环境说达成了。两者都能让一次运行以「目标达成」收尾,但一个有环境侧证据、一个没有,
所以不折算、不合并(`0006` 决策五)。
**`ActionOutcome`(动作结果)** 是动作执行接缝的返回,五个字段:`status`(已执行 / 未执行 /
环境故障)、`observation`(回填进模型对话历史的那段文本)、`observation_is_synthetic`
(这段观察是不是库自己合成的,不是环境产出的)、`env_reported_completion`(上一段那个)、
`observation_truncated_chars`(产出这段观察的人截掉了多少字)。
**`restrict_to`** 是注册表上的收窄方法:按名字取子集,返回一个新注册表,原来那个不变。
## 决策一:实现挂在 `ToolSpec` 上,不另开一份清单 ## 决策一:实现挂在 `ToolSpec` 上,不另开一份清单
@@ -32,9 +54,9 @@ class ToolSpec:
name: str name: str
description: str description: str
parameters: Mapping[str, object] parameters: Mapping[str, object]
handler: ToolHandler | None = None # 新增
replay_policy: ReplayPolicy = ReplayPolicy.NEVER replay_policy: ReplayPolicy = ReplayPolicy.NEVER
completes_run: bool = False completes_run: bool = False
handler: ToolHandler | None = None # 新增,追加在末尾
``` ```
另一条路是让 `executor()` 收一份「名字到实现」的映射。不选它,理由和 `0003` 决策四拒绝把 另一条路是让 `executor()` 收一份「名字到实现」的映射。不选它,理由和 `0003` 决策四拒绝把
@@ -42,9 +64,10 @@ class ToolSpec:
「模型调了一个它看得见的工具,库说找不到实现」——它看起来像模型不听话,不像配置错了。 「模型调了一个它看得见的工具,库说找不到实现」——它看起来像模型不听话,不像配置错了。
挂在规格上则不可能漂移:`restrict_to` 收窄的时候实现跟着规格一起走。 挂在规格上则不可能漂移:`restrict_to` 收窄的时候实现跟着规格一起走。
**字段位置排在 `parameters` 之后、两个有默认值的字段之前** 位置是公共承诺的一部分—— **字段追加在末尾,不插在中间** 位置传参的调用方那里,插在中间会静默改掉后面每一个
下游按位置传参的那一天,插在中间会静默改掉每一个参数的含义。这个位置的理由是它和前三个 参数的含义。现在还没有任何下游装上这个库,插在中间其实是安全的——但那样「这次能不能插」
一样属于「这个工具是什么」,后两个属于「怎么对待它」。 就成了一个每次都要重新判断的问题,而判断错的那次不会当场报错。**规则是「新字段一律追加
在末尾」**,代价是 `handler` 和它语义上的近亲(前三个字段都在说「这个工具是什么」)隔开了。
## 决策二:`handler` 可以为空,缺了在 `executor()` 那一刻就报错 ## 决策二:`handler` 可以为空,缺了在 `executor()` 那一刻就报错
@@ -56,13 +79,16 @@ class ToolSpec:
一致性校验。必填的话,这种项目要给每个工具写一个永远不会被调用的空壳。 一致性校验。必填的话,这种项目要给每个工具写一个永远不会被调用的空壳。
代价是「注册了工具却没有实现」变成一种可构造的状态。**这个代价由 `executor()` 兜**:它在 代价是「注册了工具却没有实现」变成一种可构造的状态。**这个代价由 `executor()` 兜**:它在
被调用的那一刻检查本注册表里的每一份规格,只要有一份没有实现就直接报错,不等到分发时才 被调用的那一刻检查本注册表里的每一份规格,只要有一份没有实现就直接报错
发现。分发时才发现的话,那是运行到第几步才炸,而前几步已经花了钱、留了轨迹。
**全查而不是等分发时按需查。** 按需查的话,一个缺实现的工具要等到模型正好调它的那一步才
炸,而那时前几步已经花了钱、留了轨迹,而且不同的运行会在不同的步数上炸。全查是一次遍历,
工具数量是几十的量级,`executor()` 又只在装配时调用,成本可以忽略。
## 决策三:`handler` 是协程,收参数、返回一段观察文本 ## 决策三:`handler` 是协程,收参数、返回一段观察文本
```python ```python
class ToolHandler(Protocol): class ToolHandler(Protocol): # polyloop.tools
async def __call__(self, arguments: Mapping[str, object]) -> str: ... async def __call__(self, arguments: Mapping[str, object]) -> str: ...
``` ```
@@ -74,23 +100,30 @@ class ToolHandler(Protocol):
调用对象递进去,实现就有机会去读 `name` 然后按名字分支,而那正好把「一个规格一个实现」这条 调用对象递进去,实现就有机会去读 `name` 然后按名字分支,而那正好把「一个规格一个实现」这条
结构拆掉。 结构拆掉。
**返回一段观察文本,不返回 `ActionOutcome`。** 返回完整结果的话,一个被标了完成标记的工具 **返回一段观察文本,不返回 `ActionOutcome`。** 返回完整结果的话,实现可以在返回值里把
可以在返回值里把完成位填成假,于是注册表上那个标记成了装饰品——`0003` 决策四要求完成标记 `env_reported_completion` 填成真——于是一个 agent 侧的工具就伪造出了一条环境侧证据,而上面
必须和注册表的其余职责同源,正是为了防这个。状态、完成位、截断计数这三样由派生出来的执行器 那两条完成通路的区分正是为了不让这件事发生。同理,实现也可以把 `status` 填成「未执行」来
按决策四那张表填 逃掉预算计数。这些字段由派生执行器按决策四填,实现碰不到
**已知代价:截断计数填不进来。** `ActionOutcome.observation_truncated_chars` 记的是「产出这段 **已知代价:截断计数填不进来。** `observation_truncated_chars` 记的是「产出这段观察的人截掉
观察的人截掉了多少字」,而返回 `str` 的实现没有地方报这个数。派生执行器一律填 0——它自己 了多少字」,而返回 `str` 的实现没有地方报这个数。派生执行器一律填 0——它自己不截断,所以
不截断,所以 0 是真值不是占位。一个在内部截断了输出的实现,那个数就丢了。 0 是这条路径上的真值不是一个「不知道就填 0」的占位。一个在内部截断了输出的实现,那个数
就丢了。
现在接受这个代价,因为两个已知消费者都没有这个需求:那个字段是给「环境自己截断了输出」 现在接受这个代价,因为两个已知消费者都没有这个需求:那个字段是给「环境自己截断了输出」那条
那条路用的,而那条路走的是项目自己写的执行器,不经过这里。真需要的那天,把返回类型从 路用的,而那条路走的是项目自己写的执行器,不经过这里。真需要的那天,把返回类型从 `str` 换成
`str` 换成一个带默认值的小结构是破坏性变更——所以这一条是本文最该被驳回的一条,见文末。 一个带默认值的小结构是破坏性变更——所以这一条是本文最该被驳回的一条,见文末。
## 决策四:派生执行器怎么填 `ActionOutcome` 的五个字段 ## 决策四:派生执行器怎么填 `ActionOutcome`
`executor()` 返回 `polyloop.tools` 里一个具体类的实例,它持有派生它的那个注册表。 `executor()` 返回 `RegistryExecutor`(住 `polyloop.tools`的实例,它持有派生它的那个注册表。
`RunRequest` `isinstance` 认出它再比对注册表`0006` 决策三)。 `RunRequest` 构造时用 `isinstance` 认出它再比对它持有的注册表与本次可见的注册表
`0006` 决策三)。
**返回一个具体类的实例,不是闭包也不是函数。** 那条一致性校验要在运行时判断「这个执行器是
不是注册表派生的」,闭包和函数从外面看不出来源;具体类是唯一能被 `isinstance` 认出的形态。
前三个字段随情况变:
| 遇到什么 | `status` | `observation` | `observation_is_synthetic` | | 遇到什么 | `status` | `observation` | `observation_is_synthetic` |
|---|---|---|---| |---|---|---|---|
@@ -101,29 +134,43 @@ class ToolHandler(Protocol):
| 实现正常返回 | `EXECUTED` | 它返回的那段 | 假 | | 实现正常返回 | `EXECUTED` | 它返回的那段 | 假 |
| 实现抛 `CancelledError` | 不产出,原样穿出去 | | | | 实现抛 `CancelledError` | 不产出,原样穿出去 | | |
`env_reported_completion` 恒为假,`observation_truncated_chars` 恒为 0。 后两个字段恒定:`env_reported_completion` 恒为假,`observation_truncated_chars` 恒为 0。
**「动作没有工具调用」这一档是装配错了**,不是模型错了:一个只认工具调用的执行器收到一段 **完成信号恒为假,是因为这条路径上根本没有环境可问。** 注册表派生的执行器手上只有一份工具
代码,说明这次运行把两种动作语言配串了。判成未执行而不是抛异常,是因为抛异常会终止整次 清单,它问不出「目标达成了吗」。走这条路的运行靠 `completes_run` 收尾,而那一档由停止判定
运行,而这一档在轨迹里留一条记录、让停止判定按未执行走,事后能看见它发生过几次 去查注册表,不经过动作结果(`0006` 决策六)。填成真会凭空造出一条环境侧证据
**`validate` 不通过算「未执行」,因为动作确实没有进入环境。** 工具名不认得、必填参数缺了,
这两种情况下没有任何代码被执行、没有任何副作用发生,判成「已执行」会让预算计数把一次
空转算成一次真的动作。它也不终止运行:模型收到一段说明之后完全可能下一步就调对了。
**「动作没有工具调用」这一档是装配错了**,不是模型错了。库支持两种动作语言:一种是模型输出
一次工具调用(工具名加参数),一种是模型输出一整段代码交给环境执行。一个只认工具调用的
执行器收到后一种,说明这次运行把解释器和执行器配成了不同的语言。判成未执行而不是抛异常,
是因为抛异常会终止整次运行,而这一档在轨迹里留一条记录、让停止判定按未执行走,事后能看见
它发生过几次。
**实现抛普通异常算「已执行」**,这条是 `0003` 决策四的原话:项目自定义工具里抛一个普通 **实现抛普通异常算「已执行」**,这条是 `0003` 决策四的原话:项目自定义工具里抛一个普通
`ValueError`(参数解析时极常见)如果被判成「工具无效、不计有效步」,模型就能无限重试同一个 `ValueError`(参数解析时极常见)如果被判成「工具无效、不计有效步」,模型就能无限重试同一个
坏工具直到上界耗尽。观察填成异常的类名加文本,不带调用栈——模型要的是「哪里错了」,调用栈 坏工具直到把步数上限耗尽——无效的工具调用不计入有效动作数,只计入总步数,靠总步数收敛。
对它没用,还会把库内部的路径喂进提示词。
**`ToolEnvironmentError` 是新增的公共异常,让实现有办法说「环境坏了」。** 没有它,派生执行器 观察填成异常的类名加文本,**不带调用栈**。模型要的是「哪里错了」,调用栈对它没用,还会把库
永远产不出 `ENV_ERROR`,于是后端挂掉时模型会一遍遍重试、把预算烧光,而轨迹上表现成「预算 内部的路径喂进提示词。代价是排障时那个栈就没了:它既不进历史也不进事件流,因为异常对象在
耗尽」——`0003` 决策四点名过这种「安静地跑到预算耗尽」正是要防的 这一层已经被吃掉。真需要的话补在事件流那份 design doc 里,本文不预留
**这两档的观察都会被库丢掉。** `0007` 决策二定了未执行与环境故障两档的观察由库从 **`ToolEnvironmentError`(住 `polyloop.tools`)是新增的公共异常**,让实现有办法说「环境坏
`SyntheticObservations` 取,执行器给的那段不进历史。这里仍然认真填,是因为那段文本将来要 了」。没有它,派生执行器永远产不出 `ENV_ERROR`,于是后端挂掉时模型会一遍遍重试、把预算烧
靠事件流送出去做审计——进历史的东西必须可复现,进审计的不必 光,而轨迹上表现成「预算耗尽」——`0003` 决策四点名过这种「安静地跑到预算耗尽」正是要防的
**未执行与环境故障这两档的观察都会被库丢掉。** `0007` 决策二定了这两档回填进历史的观察由库
`SyntheticObservations` 取,执行器给的那段不进历史。这里仍然认真填,是因为那段文本将来要
靠事件流送出去做审计。两条路的要求不同:进历史的东西会被模型看见,因而必须可复现——同一份
配置跑两次,模型两次看见的必须是同一段字;进审计的只被人看见,变了也不影响任何一次运行。
## 留给后续的 ## 留给后续的
**`observation_truncated_chars` 是本文最该被驳回的一条。** 决策三让实现返回 `str`,于是 **`observation_truncated_chars` 是本文最该被驳回的一条。** 决策三让实现返回 `str`,于是那个
那个字段在这条路径上永远是 0。另一条路是返回一个带默认值的小结构(`observation` 字段在这条路径上永远是 0。另一条路是返回一个带默认值的小结构(`observation`
`truncated_chars`),代价是多一个公共类型、而那个类型的第二个字段现在没有消费者。 `truncated_chars`),代价是多一个公共类型、而那个类型的第二个字段现在没有消费者。
两条路的不对称在于改的方向:现在选 `str`、将来要改,是破坏性变更;现在选结构、将来不需要, 两条路的不对称在于改的方向:现在选 `str`、将来要改,是破坏性变更;现在选结构、将来不需要,
+61 -14
View File
@@ -3,9 +3,10 @@
读者是给库注册工具的人,以及循环里要问「这个工具声明了什么」的三个纯逻辑模块。 读者是给库注册工具的人,以及循环里要问「这个工具声明了什么」的三个纯逻辑模块。
**注册、模型可见 schema 的生成、存在性与参数校验、分发——四者由同一个注册表实例驱动** **注册、模型可见 schema 的生成、存在性与参数校验、分发——四者由同一个注册表实例驱动**
`research-wiki/explanation/scope.md` 第 68 行那条要求)。不同源就会漂移:模型看见一个已经 `research-wiki/explanation/scope.md` 界内清单里「工具的注册、模型可见 schema 生成、存在性
删掉的工具,或者校验放行了一个分发时找不到的名字。重放策略与完成标记同住这里,理由一样 与参数校验、分发」那一条)。不同源就会漂移:模型看见一个已经删掉的工具,或者校验放行了
——它们都是「关于某个工具的一条事实」,分开存就会跟工具清单漂移。 一个分发时找不到的名字。重放策略与完成标记同住这里,理由一样——它们都是「关于某个工具的
一条事实」,分开存就会跟工具清单漂移。
注册表是**不可变值对象**,取子集返回新实例,不是进程级单例:同一进程里可能同时持有多份 注册表是**不可变值对象**,取子集返回新实例,不是进程级单例:同一进程里可能同时持有多份
不同的窄集合(`research-wiki/design/0003-public-api-shape.md` 决策二)。 不同的窄集合(`research-wiki/design/0003-public-api-shape.md` 决策二)。
@@ -17,9 +18,9 @@
已经能用了。 已经能用了。
""" """
import copy
from collections.abc import Collection, Iterable, Mapping, Sequence from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from types import MappingProxyType
from polyloop.ports import ToolCall from polyloop.ports import ToolCall
from polyloop.types import ReplayPolicy from polyloop.types import ReplayPolicy
@@ -39,6 +40,33 @@ class ToolValidationError(ValueError):
""" """
def _frozen(value: object) -> object:
"""把一份 JSON Schema 逐层变成改不动的形状:映射变只读视图,列表变元组。
只冻最外面一层不够。真正会发生的改法是从注册表里把规格取出来、往里伸一层去改
`spec_for("read").parameters["properties"]["path"]["type"] = ...`),那一下同时改掉了
模型看见的 schema 和校验用的 schema,而这次修改没有任何地方记录得到——事后翻轨迹,
模型当时到底看见的是哪一份,查不出来。
"""
if isinstance(value, Mapping):
return MappingProxyType({key: _frozen(item) for key, item in value.items()})
if isinstance(value, list | tuple):
return tuple(_frozen(item) for item in value)
return value
def _plain(value: object) -> object:
"""把冻过的形状变回普通字典与列表。
交给模型的那份 schema 要能直接 `json.dumps`,而只读视图与元组里只有元组能被序列化。
"""
if isinstance(value, Mapping):
return {key: _plain(item) for key, item in value.items()}
if isinstance(value, tuple):
return [_plain(item) for item in value]
return value
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ToolSpec: class ToolSpec:
"""一个工具的全部声明。 """一个工具的全部声明。
@@ -46,9 +74,8 @@ class ToolSpec:
`parameters` 是一份普通的 JSON Schema 字典,不是任何第三方库的模型对象——签名上一旦出现 `parameters` 是一份普通的 JSON Schema 字典,不是任何第三方库的模型对象——签名上一旦出现
第三方类型,那个包的 major 就是我们的 major。 第三方类型,那个包的 major 就是我们的 major。
构造时 `parameters` 会被深拷贝一份存下来。调用方传进来的那个字典之后再被改,注册表看见 构造时 `parameters` 会被逐层冻成只读的形状存下来,两个方向都堵上:调用方传进来的那个
的仍是注册那一刻的形状;不拷贝的话,「模型看见的 schema」和「校验用的 schema」会随调用方 字典之后再被改,注册表看见的仍是注册那一刻的形状;从注册表里把规格取出来往里改,改不动。
在别处的一次修改一起变,而那次修改没有任何地方记录得到。
""" """
name: str name: str
@@ -70,11 +97,15 @@ class ToolSpec:
用显式异常而不是 `assert``python -O` 会把断言整条移除(`CLAUDE.md` §6)。 用显式异常而不是 `assert``python -O` 会把断言整条移除(`CLAUDE.md` §6)。
""" """
if not self.name: if not isinstance(self.name, str):
raise ValueError("工具名不能为空串:空名字在提示词里不可见,模型永远调不到它") raise TypeError(f"工具名必须是字符串,收到 {type(self.name).__name__}")
if not self.name.strip():
raise ValueError("工具名不能是空串或纯空白:这种名字在提示词里不可见,模型永远调不到它")
if not isinstance(self.description, str):
raise TypeError(f"工具说明必须是字符串,收到 {type(self.description).__name__}")
if not isinstance(self.parameters, Mapping): if not isinstance(self.parameters, Mapping):
raise TypeError(f"parameters 必须是一份映射,收到 {type(self.parameters).__name__}") raise TypeError(f"parameters 必须是一份映射,收到 {type(self.parameters).__name__}")
object.__setattr__(self, "parameters", copy.deepcopy(dict(self.parameters))) object.__setattr__(self, "parameters", _frozen(dict(self.parameters)))
def _matches_one_json_type(value: object, type_name: str) -> bool: def _matches_one_json_type(value: object, type_name: str) -> bool:
@@ -90,7 +121,14 @@ def _matches_one_json_type(value: object, type_name: str) -> bool:
return isinstance(value, bool) return isinstance(value, bool)
if type_name == "integer": if type_name == "integer":
# 布尔在 Python 里是整数的子类,而 JSON 里不是。不排掉的话 `True` 会被判成合法的整数。 # 布尔在 Python 里是整数的子类,而 JSON 里不是。不排掉的话 `True` 会被判成合法的整数。
return isinstance(value, int) and not isinstance(value, bool) if isinstance(value, bool):
return False
if isinstance(value, int):
return True
# JSON Schema draft-06 起,小数部分为零的浮点数是合法的整数。模型写出 `1e2` 或者
# `3.0``json.loads` 给的就是 float——照「必须是 int」判会拒掉一次合法调用,而模型
# 怎么改都过不去。
return isinstance(value, float) and value.is_integer()
if type_name == "number": if type_name == "number":
return isinstance(value, int | float) and not isinstance(value, bool) return isinstance(value, int | float) and not isinstance(value, bool)
if type_name == "string": if type_name == "string":
@@ -124,6 +162,9 @@ class ToolRegistry:
**相等按「注册了哪些规格、什么顺序」判,不按对象身份判。** 构造 `RunRequest` 时要比对 **相等按「注册了哪些规格、什么顺序」判,不按对象身份判。** 构造 `RunRequest` 时要比对
「执行器持有的注册表」和「本次可见的注册表」是不是同一份,两份内容相同的注册表在模型 「执行器持有的注册表」和「本次可见的注册表」是不是同一份,两份内容相同的注册表在模型
看见的 schema 与实际分发上完全一致,没有可失败的地方,按身份判会把它们错判成冲突。 看见的 schema 与实际分发上完全一致,没有可失败的地方,按身份判会把它们错判成冲突。
**不可哈希**,因为参数 schema 是映射。放进 `set` 或者拿它当字典键会抛 `TypeError`
要按注册表分组的话用 `names()` 那份元组当键。
""" """
__slots__ = ("_by_name", "_specs") __slots__ = ("_by_name", "_specs")
@@ -142,7 +183,9 @@ class ToolRegistry:
by_name[spec.name] = spec by_name[spec.name] = spec
ordered.append(spec) ordered.append(spec)
self._specs: tuple[ToolSpec, ...] = tuple(ordered) self._specs: tuple[ToolSpec, ...] = tuple(ordered)
self._by_name: dict[str, ToolSpec] = by_name # 只读视图而不是那个 dict 本身:两条查询路径(`names`/`schema_for_model` 走 `_specs`
# `spec_for`/`validate` 走这里)一旦有一条被就地改过,四者同源当场破掉。
self._by_name: Mapping[str, ToolSpec] = MappingProxyType(by_name)
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, ToolRegistry): if not isinstance(other, ToolRegistry):
@@ -206,7 +249,7 @@ class ToolRegistry:
{ {
"name": spec.name, "name": spec.name,
"description": spec.description, "description": spec.description,
"parameters": copy.deepcopy(dict(spec.parameters)), "parameters": _plain(spec.parameters),
} }
for spec in self._specs for spec in self._specs
] ]
@@ -245,7 +288,11 @@ class ToolRegistry:
if missing: if missing:
raise ToolValidationError(f"{call.name!r} 缺少必填参数:{missing}") raise ToolValidationError(f"{call.name!r} 缺少必填参数:{missing}")
if schema.get("additionalProperties") is False: # `patternProperties` 在场时,「哪些键是被声明过的」要靠正则匹配才答得出,而这里
# 不实现正则匹配那一档。跳过这项检查,不拿一份答不出的问题去拒调用——匹配到 pattern
# 的键必然不在 `properties` 里,照下面那行判会把每一次合法调用都拒掉,而模型改名字
# 也绕不过去。
if schema.get("additionalProperties") is False and "patternProperties" not in schema:
unknown = sorted(key for key in call.arguments if key not in properties) unknown = sorted(key for key in call.arguments if key not in properties)
if unknown: if unknown:
raise ToolValidationError( raise ToolValidationError(
+91
View File
@@ -36,6 +36,26 @@ def test_spec_rejects_an_empty_name() -> None:
ToolSpec(name="", description="", parameters={}) 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: def test_spec_rejects_non_mapping_parameters() -> None:
with pytest.raises(TypeError, match="parameters"): with pytest.raises(TypeError, match="parameters"):
ToolSpec(name="search", description="", parameters=["query"]) # type: ignore[arg-type] 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"}} 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: 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})) 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: def test_a_type_may_be_declared_as_a_list_of_alternatives() -> None:
registry = ToolRegistry( registry = ToolRegistry(
[_spec("head", parameters={"properties": {"n": {"type": ["integer", "null"]}}})] [_spec("head", parameters={"properties": {"n": {"type": ["integer", "null"]}}})]