Files
PolyLoop/research-wiki/design/0008-tool-handlers.md
T
iomgaa 6fafd95d6c feat(tools): 落成 executor() 与派生分发器;公共数据类一律只收关键字参数
两个自行调研后决定的问题,各自的证据写进了 design doc:

一、handler 返回 str 而不是带截断计数的小结构(0008 决策三,文末新增一节)。三条实据:
两个真实消费者的执行函数今天就返回纯字符串(GovDoc 的 handler 是 Coroutine[..., str],
dissect 的环境 execute 是 -> str);dissect 的 observation_truncated_chars 唯一的生产写入点
硬编码 0 且全仓零读取点,存在的是名字不是需求;reference/pi 是唯一把截断做完整的,它记的是
totalBytes/outputBytes/maxBytes 这组绝对量而不是一个差值——现在补 truncated_chars 补的
大概率是错形状,正是 scope.md 说的「猜出来的接缝比没有接缝更难拆」。

二、新增 design 0009:src/polyloop/ 下每个数据类都加 kw_only=True,另加一条扫描测试守它。
实验室七个仓库 223 个 dataclass 里 kw_only 出现零次,但那是默认行为不是选择。真正的证据是
PolyGateway:它的 LLMResponse 前 11 个字段顺序被三个下游的测试替身按位置构造锁死,模块
docstring 写着「字段顺序即公共承诺」,还得专门写一条 test_eleven_legacy_fields_positional
守着,从此再也插不进字段。那个约束不是它选的是它继承的,而本库还没有下游装上。
扫描测试查的是构造签名不是那个装饰器参数——要守的承诺是「按位置构造不了」。

executor() 在派生那一刻全查一遍实现,缺一个就报错,不拖到分发时才炸。RegistryExecutor 是
具体类而不是闭包,因为 RunRequest 要用 isinstance 认它。CancelledError 不被那个
except Exception 接住(它继承 BaseException),有测试守着。
2026-08-10 01:19:15 -04:00

14 KiB
Raw Blame History

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 上,不另开一份清单

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_executortools 同时存在不是重复,并且只在执行器是注册表派生的时候才做那条 一致性校验。必填的话,这种项目要给每个工具写一个永远不会被调用的空壳。

代价是「注册了工具却没有实现」变成一种可构造的状态。这个代价由 executor():它在 被调用的那一刻检查本注册表里的每一份规格,只要有一份没有实现就直接报错。

全查而不是等分发时按需查。 按需查的话,一个缺实现的工具要等到模型正好调它的那一步才 炸,而那时前几步已经花了钱、留了轨迹,而且不同的运行会在不同的步数上炸。全查是一次遍历, 工具数量是几十的量级,executor() 又只在装配时调用,成本可以忽略。

决策三:handler 是协程,收参数、返回一段观察文本

class ToolHandler(Protocol):        # polyloop.tools
    async def __call__(self, arguments: Mapping[str, object]) -> str: ...

是协程。 已知的工具全都在做 I/O(读写文件、检索、调外部服务)。允许同步实现就要在分发 处判断返回值是不是可等待的,而那种隐式判断是 ../../CLAUDE.md §6 明确不要的。代价是纯计算的 工具也得写一个 async def,那是一个关键字的成本。

只收参数,不收整个 ToolCallAction 实现知道自己叫什么,不需要库告诉它;把整个 调用对象递进去,实现就有机会去读 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、将来要改是 破坏性变更;现在选一个带默认值的小结构(observationtruncated_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 不是缺口,是这条路径的真实情况。

留给后续的

真出现一个要报截断量的消费者时,加的应该是一个新的返回类型,不是往 str 上打补丁。 那时 把 ToolHandler 的返回改成联合类型(str | ToolOutput)对已有实现是兼容变更,而字段形状按 那个消费者真实要的来定,不按今天猜的来定。