7da5e07726
0008:主流框架没有一家把截断量做成工具返回值上的字段(Anthropic 的 tool_result 只有三个
字段,OpenAI/LlamaIndex/Pydantic AI 的结果类型都没有),唯一结构化记下来的 LangChain 走的
是通用 artifact 通道而不是专设字段。另补「将来要加时怎么加」的正反两例:Pydantic AI 在允许
返回的类型集合里加一个新类型、旧路径不动,零兼容问题;OpenAI Agents SDK 改成运行期形状
嗅探,一个返回 {"msg": ...} 的普通工具被当成图片输出转掉、线上 400,三天后回落。
0009:Pydantic AI 2026-07 加过一条几乎同形的 meta-test(扫描包、新增公共数据类超过一个位置
参数就失败),PR 正文两句正好对上本文两条论证——「一条老在评审里被提却没有机器执行的意见」,
以及「不给已有的数据类补,那会打断按位置构造的调用方;白名单只减不增,清空它得发新 major」。
它已经付了本文想避开的那笔账。另记一条连带影响:kw_only 字段不进 __match_args__。
215 lines
15 KiB
Markdown
215 lines
15 KiB
Markdown
# Design 0008 · 工具的实现怎么挂进注册表
|
||
|
||
**日期** 2026-08-10 · **状态** 已接受(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` 那三个问题是同一族:散文读着通顺,只有要写出那行代码时才发现缺了一样。那三个是
|
||
写契约测试时撞出来的,这一个是写实现时撞出来的。
|
||
|
||
## 读本文需要的四个名字
|
||
|
||
它们都定在别处,这里各给一句,免得读到一半得去翻另外三份文档。
|
||
|
||
**`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` 上,不另开一份清单
|
||
|
||
```python
|
||
class ToolSpec:
|
||
name: str
|
||
description: str
|
||
parameters: Mapping[str, object]
|
||
replay_policy: ReplayPolicy = ReplayPolicy.NEVER
|
||
completes_run: bool = False
|
||
handler: ToolHandler | None = None # 新增,追加在末尾
|
||
```
|
||
|
||
另一条路是让 `executor()` 收一份「名字到实现」的映射。不选它,理由和 `0003` 决策四拒绝把
|
||
完成标记单独存一份清单是同一条:**那份清单里的工具名和注册表里的会漂移**,而漂移的表现是
|
||
「模型调了一个它看得见的工具,库说找不到实现」——它看起来像模型不听话,不像配置错了。
|
||
挂在规格上则不可能漂移:`restrict_to` 收窄的时候实现跟着规格一起走。
|
||
|
||
**新字段追加在末尾,不插在中间。** 按位置传参的调用方那里,插在中间会静默改掉后面每一个
|
||
参数的含义。现在还没有任何下游装上这个库,插在中间其实是安全的——但那样「这次能不能插」
|
||
就成了一个每次都要重新判断的问题,而判断错的那次不会当场报错。**规则是「新字段一律追加
|
||
在末尾」**,代价是 `handler` 和它语义上的近亲(前三个字段都在说「这个工具是什么」)隔开了。
|
||
|
||
## 决策二:`handler` 可以为空,缺了在 `executor()` 那一刻就报错
|
||
|
||
`handler` 有默认值 `None`,不是必填。
|
||
|
||
必填会打到一种真实装配:项目自己写动作执行器,同时又注册一份工具清单——模型看得见 schema、
|
||
库照常做存在性与参数校验,分发走项目自己那条路。`0006` 决策三正是按这种情形写的,它说
|
||
`action_executor` 与 `tools` 同时存在不是重复,并且只在执行器是注册表派生的时候才做那条
|
||
一致性校验。必填的话,这种项目要给每个工具写一个永远不会被调用的空壳。
|
||
|
||
代价是「注册了工具却没有实现」变成一种可构造的状态。**这个代价由 `executor()` 兜**:它在
|
||
被调用的那一刻检查本注册表里的每一份规格,只要有一份没有实现就直接报错。
|
||
|
||
**全查而不是等分发时按需查。** 按需查的话,一个缺实现的工具要等到模型正好调它的那一步才
|
||
炸,而那时前几步已经花了钱、留了轨迹,而且不同的运行会在不同的步数上炸。全查是一次遍历,
|
||
工具数量是几十的量级,`executor()` 又只在装配时调用,成本可以忽略。
|
||
|
||
## 决策三:`handler` 是协程,收参数、返回一段观察文本
|
||
|
||
```python
|
||
class ToolHandler(Protocol): # polyloop.tools
|
||
async def __call__(self, arguments: Mapping[str, object]) -> str: ...
|
||
```
|
||
|
||
**是协程。** 已知的工具全都在做 I/O(读写文件、检索、调外部服务)。允许同步实现就要在分发
|
||
处判断返回值是不是可等待的,而那种隐式判断是 `../../CLAUDE.md` §6 明确不要的。代价是纯计算的
|
||
工具也得写一个 `async def`,那是一个关键字的成本。
|
||
|
||
**只收参数,不收整个 `ToolCall` 或 `Action`。** 实现知道自己叫什么,不需要库告诉它;把整个
|
||
调用对象递进去,实现就有机会去读 `name` 然后按名字分支,而那正好把「一个规格一个实现」这条
|
||
结构拆掉。
|
||
|
||
**返回一段观察文本,不返回 `ActionOutcome`。** 返回完整结果的话,实现可以在返回值里把
|
||
`env_reported_completion` 填成真——于是一个 agent 侧的工具就伪造出了一条环境侧证据,而上面
|
||
那两条完成通路的区分正是为了不让这件事发生。同理,实现也可以把 `status` 填成「未执行」来
|
||
逃掉预算计数。这些字段由派生执行器按决策四填,实现碰不到。
|
||
|
||
**已知代价:截断计数填不进来。** `observation_truncated_chars` 记的是「产出这段观察的人截掉
|
||
了多少字」,而返回 `str` 的实现没有地方报这个数。派生执行器一律填 0——它自己不截断,所以
|
||
0 是这条路径上的真值,不是一个「不知道就填 0」的占位。一个在内部截断了输出的实现,那个数
|
||
就丢了。
|
||
|
||
接受这个代价。理由不是「将来再说」,是调研之后的三条实据,见文末那一节。
|
||
|
||
## 决策四:派生执行器怎么填 `ActionOutcome`
|
||
|
||
`executor()` 返回 `RegistryExecutor`(住 `polyloop.tools`)的实例,它持有派生它的那个注册表。
|
||
`RunRequest` 构造时用 `isinstance` 认出它,再比对它持有的注册表与本次可见的注册表
|
||
(`0006` 决策三)。
|
||
|
||
**返回一个具体类的实例,不是闭包也不是函数。** 那条一致性校验要在运行时判断「这个执行器是
|
||
不是注册表派生的」,闭包和函数从外面看不出来源;具体类是唯一能被 `isinstance` 认出的形态。
|
||
|
||
前三个字段随情况变:
|
||
|
||
| 遇到什么 | `status` | `observation` | `observation_is_synthetic` |
|
||
|---|---|---|---|
|
||
| 动作没有工具调用 | `NOT_EXECUTED` | 执行器自己写的一句 | 真 |
|
||
| `validate` 不通过 | `NOT_EXECUTED` | 执行器自己写的一句 | 真 |
|
||
| 实现抛 `ToolEnvironmentError` | `ENV_ERROR` | 那个异常的文本 | 假 |
|
||
| 实现抛别的异常 | `EXECUTED` | 那个异常的类名与文本 | 假 |
|
||
| 实现正常返回 | `EXECUTED` | 它返回的那段 | 假 |
|
||
| 实现抛 `CancelledError` | 不产出,原样穿出去 | | |
|
||
|
||
后两个字段恒定:`env_reported_completion` 恒为假,`observation_truncated_chars` 恒为 0。
|
||
|
||
**完成信号恒为假,是因为这条路径上根本没有环境可问。** 注册表派生的执行器手上只有一份工具
|
||
清单,它问不出「目标达成了吗」。走这条路的运行靠 `completes_run` 收尾,而那一档由停止判定
|
||
去查注册表,不经过动作结果(`0006` 决策六)。填成真会凭空造出一条环境侧证据。
|
||
|
||
**`validate` 不通过算「未执行」,因为动作确实没有进入环境。** 工具名不认得、必填参数缺了,
|
||
这两种情况下没有任何代码被执行、没有任何副作用发生,判成「已执行」会让预算计数把一次
|
||
空转算成一次真的动作。它也不终止运行:模型收到一段说明之后完全可能下一步就调对了。
|
||
|
||
**「动作没有工具调用」这一档是装配错了**,不是模型错了。库支持两种动作语言:一种是模型输出
|
||
一次工具调用(工具名加参数),一种是模型输出一整段代码交给环境执行。一个只认工具调用的
|
||
执行器收到后一种,说明这次运行把解释器和执行器配成了不同的语言。判成未执行而不是抛异常,
|
||
是因为抛异常会终止整次运行,而这一档在轨迹里留一条记录、让停止判定按未执行走,事后能看见
|
||
它发生过几次。
|
||
|
||
**实现抛普通异常算「已执行」**,这条是 `0003` 决策四的原话:项目自定义工具里抛一个普通
|
||
`ValueError`(参数解析时极常见)如果被判成「工具无效、不计有效步」,模型就能无限重试同一个
|
||
坏工具直到把步数上限耗尽——无效的工具调用不计入有效动作数,只计入总步数,靠总步数收敛。
|
||
|
||
观察填成异常的类名加文本,**不带调用栈**。模型要的是「哪里错了」,调用栈对它没用,还会把库
|
||
内部的路径喂进提示词。代价是排障时那个栈就没了:它既不进历史也不进事件流,因为异常对象在
|
||
这一层已经被吃掉。真需要的话补在事件流那份 design doc 里,本文不预留。
|
||
|
||
**`ToolEnvironmentError`(住 `polyloop.tools`)是新增的公共异常**,让实现有办法说「环境坏
|
||
了」。没有它,派生执行器永远产不出 `ENV_ERROR`,于是后端挂掉时模型会一遍遍重试、把预算烧
|
||
光,而轨迹上表现成「预算耗尽」——`0003` 决策四点名过这种「安静地跑到预算耗尽」正是要防的。
|
||
|
||
**未执行与环境故障这两档的观察都会被库丢掉。** `0007` 决策二定了这两档回填进历史的观察由库
|
||
从 `SyntheticObservations` 取,执行器给的那段不进历史。这里仍然认真填,是因为那段文本将来要
|
||
靠事件流送出去做审计。两条路的要求不同:进历史的东西会被模型看见,因而必须可复现——同一份
|
||
配置跑两次,模型两次看见的必须是同一段字;进审计的只被人看见,变了也不影响任何一次运行。
|
||
|
||
## 决策三为什么最终选了 `str`
|
||
|
||
初稿把这一条列成「本文最该被驳回的一条」,理由是改的方向不对称:现在选 `str`、将来要改是
|
||
破坏性变更;现在选一个带默认值的小结构(`observation` 加 `truncated_chars`)、将来不需要,
|
||
只是多了一个没人填的字段。按 `../../CLAUDE.md` §1.3「只增不删不改名」,后者听起来便宜得多。
|
||
|
||
调研之后这条推理翻过来了,三条实据:
|
||
|
||
**两个真实消费者的执行函数今天就返回纯字符串。** GovDoc 的工具 handler 签名是
|
||
`Callable[..., Coroutine[Any, Any, str]]`(`reference/GovDoc-SaaS/.../agent/registry.py:45`),
|
||
dissect 的环境执行是 `async def execute(self, action: str) -> str`
|
||
(`reference/dissect/harness/envs/protocol.py:118`)。选 `str` 是零适配,选结构是两边都要改。
|
||
|
||
**那个字段今天没有消费者,只有一个字段名。** dissect 的 `observation_truncated_chars` 在它
|
||
自己仓库里唯一的生产写入点是硬编码 0(`harness/agent/loop.py:370`),而全仓库**没有任何一处
|
||
读它**。「这个字段已经存在,所以不算预留」这个说法站不住——存在的是名字,不是需求。
|
||
|
||
**真要做的话,要留的不是这一个字段。** `reference/pi` 是六个参考仓库里唯一把工具输出截断做
|
||
完整的(TypeScript,不是我们的消费者),它记的是 `totalLines / totalBytes / outputLines /
|
||
outputBytes / maxLines / maxBytes` 这一组**绝对量**加两个布尔,不是「截掉了多少」这一个差值
|
||
(`packages/coding-agent/src/core/tools/truncate.ts:15-38`)。也就是说,现在补一个
|
||
`truncated_chars` 字段,补的大概率是个错形状——而 `scope.md` 说猜出来的接缝比没有接缝更难拆,
|
||
说的正是这种情况。
|
||
|
||
还有一条方向上的佐证:dissect 的环境协议明令不截断,理由是「截断策略属于 agent 层的统一
|
||
配置,不是环境的属性」(`harness/envs/protocol.py:121-123`),而它的 agent 层对超限的处置是
|
||
**停机不是截断**——本库把它落成了停止判定 B 档的 `context_overflow`。也就是说本库这条主路上
|
||
根本不该发生截断,`observation_truncated_chars` 恒为 0 不是缺口,是这条路径的真实情况。
|
||
|
||
**主流框架没有一家把截断量做成工具返回值上的字段。** Anthropic 的 `tool_result` 内容块只有
|
||
`tool_use_id` / `content` / `is_error` 三个字段;OpenAI Agents SDK 的三个输出类型、LlamaIndex
|
||
的 `ToolOutput`、Pydantic AI 的 `ToolReturn` 都没有截断字段。唯一把「截了多少」结构化记下来的
|
||
是 LangChain 的 shell 中间件,而它走的是通用的 `artifact` 通道(一个随便装什么的字典),不是
|
||
为截断专设的字段。截断本身也都发生在工具实现或宿主那一层,不在循环框架里。
|
||
|
||
## 留给后续的
|
||
|
||
**真出现一个要报截断量的消费者时,加的应该是一个新的返回类型,不是往 `str` 上打补丁。** 那时
|
||
把 `ToolHandler` 的返回改成联合类型(`str | ToolOutput`)对已有实现是兼容变更,而字段形状按
|
||
那个消费者真实要的来定,不按今天猜的来定。
|
||
|
||
这条路有现成的正反两例。**Pydantic AI 就是这么做的**:它在允许返回的类型集合里加了一个
|
||
`ToolReturn`,旧的「返回任意对象」那条路一点没动,所以没有兼容问题、也不需要迁移说明
|
||
(PR #2060,随 v0.3.5 发布)。**反面是 OpenAI Agents SDK**:它在 v0.4.0 里改成对返回值做运行期
|
||
形状嗅探,一个本意只是返回 `{"msg": "foobar"}` 的工具被当成图片输出转换掉,线上直接 400,
|
||
三天后不得不收紧规则、让不合形状的字典回落成 `str()`(PR #1898 → issue #1930 → PR #1965)。
|
||
区别在于加一个显式类型还是去猜一个已有类型的意图——**将来加这个能力时走前一条**。
|