Files
iomgaa 7da5e07726 docs(design): 给 0008 与 0009 补上外部先例,两个决定都是印证不是改动
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__。
2026-08-10 01:21:12 -04:00

215 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。
区别在于加一个显式类型还是去猜一个已有类型的意图——**将来加这个能力时走前一条**。