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:
@@ -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`、将来要改,是破坏性变更;现在选结构、将来不需要,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"]}}})]
|
||||||
|
|||||||
Reference in New Issue
Block a user