77ffad9dc4
0006 决策六定的 ToolSpec 五个字段里没有一处装工具本身的实现,注册表派生的执行器 拿到一次合法调用之后无处分发。handler 这个词只在 0003 的正文里出现过,从没写成字段。 本文定四件事:实现挂在 ToolSpec 上(不另开清单,防漂移)、可以为空但 executor() 那一刻就查、签名是收参数返回观察文本的协程、派生执行器怎么填 ActionOutcome 五个字段。 状态待确认,要过 CLAUDE.md §2 人类门。
135 lines
8.8 KiB
Markdown
135 lines
8.8 KiB
Markdown
# Design 0008 · 工具的实现怎么挂进注册表
|
|
|
|
**日期** 2026-08-10 · **状态** 待确认
|
|
|
|
**补充** `0006-public-names-and-signatures.md` 决策六。那一条定了 `ToolSpec` 的五个字段与
|
|
`ToolRegistry` 的五个方法,本文补上其中缺的那一样:工具本身的实现挂在哪里。
|
|
|
|
**触及** `../../src/polyloop/tools/__init__.py`。那个模块现在没有 `executor()`,缺口就是本文
|
|
要补的这个。
|
|
|
|
**它要过 `../../CLAUDE.md` §2 那道人类门**,因为要给一个公共类型加字段。在确认之前
|
|
`executor()` 不写,也不写一个「先占位、以后再改」的版本——那种版本会让下游以为分发已经
|
|
能用了。
|
|
|
|
## 洞在哪
|
|
|
|
`ToolRegistry.executor()` 要返回一个动作执行器,它拿到一次合法的工具调用之后得把调用真的
|
|
执行掉。但 `0006` 定的 `ToolSpec` 五个字段——名字、说明、参数 schema、重放策略、完成标记——
|
|
**没有一处装工具本身的实现**。照现在的签名,那个执行器分发无门。
|
|
|
|
`0003` 决策四的正文里出现过 `handler` 这个词(「工具不存在与参数不合 schema 由它判定、不经过
|
|
任何 handler,直接合成未执行」),但从没有任何一处把它写成字段或签名。所以这不是被写在别处
|
|
的东西,是从没写过的东西。
|
|
|
|
它和 `0007` 那三个问题是同一族:散文读着通顺,只有要写出那行代码时才发现缺了一样。这次是
|
|
写实现时撞出来的,不是写契约测试时。
|
|
|
|
## 决策一:实现挂在 `ToolSpec` 上,不另开一份清单
|
|
|
|
```python
|
|
class ToolSpec:
|
|
name: str
|
|
description: str
|
|
parameters: Mapping[str, object]
|
|
handler: ToolHandler | None = None # 新增
|
|
replay_policy: ReplayPolicy = ReplayPolicy.NEVER
|
|
completes_run: bool = False
|
|
```
|
|
|
|
另一条路是让 `executor()` 收一份「名字到实现」的映射。不选它,理由和 `0003` 决策四拒绝把
|
|
完成标记单独存一份清单是同一条:**那份清单里的工具名和注册表里的会漂移**,而漂移的表现是
|
|
「模型调了一个它看得见的工具,库说找不到实现」——它看起来像模型不听话,不像配置错了。
|
|
挂在规格上则不可能漂移:`restrict_to` 收窄的时候实现跟着规格一起走。
|
|
|
|
**字段位置排在 `parameters` 之后、两个有默认值的字段之前。** 位置是公共承诺的一部分——
|
|
下游按位置传参的那一天,插在中间会静默改掉每一个参数的含义。这个位置的理由是它和前三个
|
|
一样属于「这个工具是什么」,后两个属于「怎么对待它」。
|
|
|
|
## 决策二:`handler` 可以为空,缺了在 `executor()` 那一刻就报错
|
|
|
|
`handler` 有默认值 `None`,不是必填。
|
|
|
|
必填会打到一种真实装配:项目自己写动作执行器,同时又注册一份工具清单——模型看得见 schema、
|
|
库照常做存在性与参数校验,分发走项目自己那条路。`0006` 决策三正是按这种情形写的,它说
|
|
`action_executor` 与 `tools` 同时存在不是重复,并且只在执行器是注册表派生的时候才做那条
|
|
一致性校验。必填的话,这种项目要给每个工具写一个永远不会被调用的空壳。
|
|
|
|
代价是「注册了工具却没有实现」变成一种可构造的状态。**这个代价由 `executor()` 兜**:它在
|
|
被调用的那一刻检查本注册表里的每一份规格,只要有一份没有实现就直接报错,不等到分发时才
|
|
发现。分发时才发现的话,那是运行到第几步才炸,而前几步已经花了钱、留了轨迹。
|
|
|
|
## 决策三:`handler` 是协程,收参数、返回一段观察文本
|
|
|
|
```python
|
|
class ToolHandler(Protocol):
|
|
async def __call__(self, arguments: Mapping[str, object]) -> str: ...
|
|
```
|
|
|
|
**是协程。** 已知的工具全都在做 I/O(读写文件、检索、调外部服务)。允许同步实现就要在分发
|
|
处判断返回值是不是可等待的,而那种隐式判断是 `../../CLAUDE.md` §6 明确不要的。代价是纯计算的
|
|
工具也得写一个 `async def`,那是一个关键字的成本。
|
|
|
|
**只收参数,不收整个 `ToolCall` 或 `Action`。** 实现知道自己叫什么,不需要库告诉它;把整个
|
|
调用对象递进去,实现就有机会去读 `name` 然后按名字分支,而那正好把「一个规格一个实现」这条
|
|
结构拆掉。
|
|
|
|
**返回一段观察文本,不返回 `ActionOutcome`。** 返回完整结果的话,一个被标了完成标记的工具
|
|
可以在返回值里把完成位填成假,于是注册表上那个标记成了装饰品——`0003` 决策四要求完成标记
|
|
必须和注册表的其余职责同源,正是为了防这个。状态、完成位、截断计数这三样由派生出来的执行器
|
|
按决策四那张表填。
|
|
|
|
**已知代价:截断计数填不进来。** `ActionOutcome.observation_truncated_chars` 记的是「产出这段
|
|
观察的人截掉了多少字」,而返回 `str` 的实现没有地方报这个数。派生执行器一律填 0——它自己
|
|
不截断,所以 0 是真值不是占位。一个在内部截断了输出的实现,那个数就丢了。
|
|
|
|
现在接受这个代价,因为两个已知消费者都没有这个需求:那个字段是给「环境自己截断了输出」
|
|
那条路用的,而那条路走的是项目自己写的执行器,不经过这里。真需要的那天,把返回类型从
|
|
`str` 换成一个带默认值的小结构是破坏性变更——所以这一条是本文最该被驳回的一条,见文末。
|
|
|
|
## 决策四:派生执行器怎么填 `ActionOutcome` 的五个字段
|
|
|
|
`executor()` 返回 `polyloop.tools` 里一个具体类的实例,它持有派生它的那个注册表。
|
|
`RunRequest` 靠 `isinstance` 认出它、再比对注册表(`0006` 决策三)。
|
|
|
|
| 遇到什么 | `status` | `observation` | `observation_is_synthetic` |
|
|
|---|---|---|---|
|
|
| 动作没有工具调用 | `NOT_EXECUTED` | 执行器自己写的一句 | 真 |
|
|
| `validate` 不通过 | `NOT_EXECUTED` | 执行器自己写的一句 | 真 |
|
|
| 实现抛 `ToolEnvironmentError` | `ENV_ERROR` | 那个异常的文本 | 假 |
|
|
| 实现抛别的异常 | `EXECUTED` | 那个异常的类名与文本 | 假 |
|
|
| 实现正常返回 | `EXECUTED` | 它返回的那段 | 假 |
|
|
| 实现抛 `CancelledError` | 不产出,原样穿出去 | | |
|
|
|
|
`env_reported_completion` 恒为假,`observation_truncated_chars` 恒为 0。
|
|
|
|
**「动作没有工具调用」这一档是装配错了**,不是模型错了:一个只认工具调用的执行器收到一段
|
|
代码,说明这次运行把两种动作语言配串了。判成未执行而不是抛异常,是因为抛异常会终止整次
|
|
运行,而这一档在轨迹里留一条记录、让停止判定按未执行走,事后能看见它发生过几次。
|
|
|
|
**实现抛普通异常算「已执行」**,这条是 `0003` 决策四的原话:项目自定义工具里抛一个普通
|
|
`ValueError`(参数解析时极常见)如果被判成「工具无效、不计有效步」,模型就能无限重试同一个
|
|
坏工具直到上界耗尽。观察填成异常的类名加文本,不带调用栈——模型要的是「哪里错了」,调用栈
|
|
对它没用,还会把库内部的路径喂进提示词。
|
|
|
|
**`ToolEnvironmentError` 是新增的公共异常,让实现有办法说「环境坏了」。** 没有它,派生执行器
|
|
永远产不出 `ENV_ERROR`,于是后端挂掉时模型会一遍遍重试、把预算烧光,而轨迹上表现成「预算
|
|
耗尽」——`0003` 决策四点名过这种「安静地跑到预算耗尽」正是要防的。
|
|
|
|
**这两档的观察都会被库丢掉。** `0007` 决策二定了未执行与环境故障两档的观察由库从
|
|
`SyntheticObservations` 取,执行器给的那段不进历史。这里仍然认真填,是因为那段文本将来要
|
|
靠事件流送出去做审计——进历史的东西必须可复现,进审计的不必。
|
|
|
|
## 留给后续的
|
|
|
|
**`observation_truncated_chars` 是本文最该被驳回的一条。** 决策三让实现返回 `str`,于是
|
|
那个字段在这条路径上永远是 0。另一条路是返回一个带默认值的小结构(`observation` 加
|
|
`truncated_chars`),代价是多一个公共类型、而那个类型的第二个字段现在没有消费者。
|
|
|
|
两条路的不对称在于改的方向:现在选 `str`、将来要改,是破坏性变更;现在选结构、将来不需要,
|
|
只是多了一个没人填的字段。按 `../../CLAUDE.md` §1.3「只增不删不改名」,后者便宜得多。
|
|
|
|
选 `str` 的理由只有一条:`scope.md` 说界外的需求只登记不实现、也不为它预留结构。这一条到底
|
|
算不算「预留结构」,是这次要拍板的地方——它不是一个纯粹的猜测(那个字段已经存在于
|
|
`ActionOutcome`,不是为将来新造的),但也确实没有消费者在要。
|