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),有测试守着。
This commit is contained in:
2026-08-10 01:19:15 -04:00
parent fa09e873c5
commit 6fafd95d6c
10 changed files with 1082 additions and 49 deletions
+33 -13
View File
@@ -110,9 +110,7 @@ class ToolHandler(Protocol): # polyloop.tools
0 是这条路径上的真值,不是一个「不知道就填 0」的占位。一个在内部截断了输出的实现,那个数
就丢了。
现在接受这个代价,因为两个已知消费者都没有这个需求:那个字段是给「环境自己截断了输出」那条
路用的,而那条路走的是项目自己写的执行器,不经过这里。真需要的那天,把返回类型从 `str` 换成
一个带默认值的小结构是破坏性变更——所以这一条是本文最该被驳回的一条,见文末。
接受这个代价。理由不是「将来再说」,是调研之后的三条实据,见文末那一节。
## 决策四:派生执行器怎么填 `ActionOutcome`
@@ -167,15 +165,37 @@ class ToolHandler(Protocol): # polyloop.tools
靠事件流送出去做审计。两条路的要求不同:进历史的东西会被模型看见,因而必须可复现——同一份
配置跑两次,模型两次看见的必须是同一段字;进审计的只被人看见,变了也不影响任何一次运行。
## 决策三为什么最终选了 `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 不是缺口,是这条路径的真实情况。
## 留给后续的
**`observation_truncated_chars` 是本文最该被驳回的一条。** 决策三让实现返回 `str`,于是那个
字段在这条路径上永远是 0。另一条路是返回一个带默认值的小结构(`observation`
`truncated_chars`),代价是多一个公共类型、而那个类型的第二个字段现在没有消费者
两条路的不对称在于改的方向:现在选 `str`、将来要改,是破坏性变更;现在选结构、将来不需要,
只是多了一个没人填的字段。按 `../../CLAUDE.md` §1.3「只增不删不改名」,后者便宜得多。
`str` 的理由只有一条:`scope.md` 说界外的需求只登记不实现、也不为它预留结构。这一条到底
算不算「预留结构」,是这次要拍板的地方——它不是一个纯粹的猜测(那个字段已经存在于
`ActionOutcome`,不是为将来新造的),但也确实没有消费者在要。
**真出现一个要报截断量的消费者时,加的应该是一个新的返回类型,不是往 `str` 上打补丁。** 那时
`ToolHandler` 的返回改成联合类型(`str | ToolOutput`)对已有实现是兼容变更,而字段形状按
那个消费者真实要的来定,不按今天猜的来定
@@ -0,0 +1,70 @@
# Design 0009 · 公共数据类一律只收关键字参数
**日期** 2026-08-10 · **状态** 已接受(2026-08-10 项目负责人授权本文自行调研后决定)
**补充** `0006-public-names-and-signatures.md`。那份文档定了每个公共类型有哪些字段、什么顺序;
本文定的是**字段顺序算不算一份对外承诺**。答案是不算——而要让它不算,得在第一版就把构造方式
限死。
**触及** `../../src/polyloop/` 下每一个数据类,以及 `../../tests/unit/` 里守它的那条扫描测试。
## 问题
`../../CLAUDE.md` §1.3 写着「公共类型的字段只增不删不改名,新增字段必带默认值」。这条管住了
名字,没管住**位置**:只要下游能按位置构造,字段顺序就自动成为承诺的一部分,往中间插一个
字段会静默改掉后面每一个参数的含义——不报错,只是每个值都进错了字段。
这不是假想。`0008` 要往 `ToolSpec` 加一个 `handler` 字段,第一稿把它插在 `parameters` 之后,
一轮代码审查当场指出:下游若写过 `ToolSpec("submit", "提交", {}, ReplayPolicy.SAFE)`,那一行
在字段插入之后设的就变成了 `handler=ReplayPolicy.SAFE`,而 `replay_policy` 静默退回默认值
——声明了可重放的工具在恢复时不再被重放,同时执行器会把一个枚举当协程去调。
## 实验室里已经有一个现成的教训
PolyGateway 是这个实验室里唯一另一个「库」形态的项目,它的 `LLMResponse` 有 18 个字段,其中
前 11 个的顺序是**锁死的**。它的模块 docstring 写着:
> `LLMResponse` 前 11 个字段与三参考项目逐字保序——它们的测试按位置构造 fake,字段顺序即
> 公共承诺;新增字段只增不删且必带默认值。
> `reference/PolyGateway/src/polygateway/types.py:1-5`
它还专门写了一条测试守这件事,函数名就叫 `test_eleven_legacy_fields_positional`
`tests/unit/test_types.py:36-42`),断言那 11 个位置实参的调用必须零改动成立。它的
`CLAUDE.md` §4.3 把这条列为「不考虑向后兼容」这一全项目原则的**唯一例外**。
**这个约束不是 PolyGateway 选的,是它继承的**:三个下游项目在它出现之前就把 fake 写成了位置
构造,它只能事后把那个形状锁住。代价是它从此再也不能往前 11 个字段中间插任何东西。
PolyLoop 还没有任何下游装上,这个约束现在可以不长出来。
## 决策:`src/polyloop/` 下每一个数据类都加 `kw_only=True`
包括三个内部模块里的。一条规则不留判断余地,也才写得成机器检查。
**为什么不按类型大小分档。** 一个「字段多的用关键字、小壳保持位置可用」的规则,每加一个类型
都要判一次,而判错那次不会当场报错。`../../CLAUDE.md` 开头那条「能交给机器的就别靠自觉」在这里
直接适用:统一规则可以写成一条扫描测试,分档规则写不成。
**代价照实认下:适配器代码里那些两三个字段的小壳要多打字。** `TextBlock(text="hi")`
`TextBlock("hi")` 长六个字符,而它在写适配器和测试替身时会出现很多次。这笔账认下来,换的是
「字段顺序永远不是承诺」这一条对所有类型同时成立。
**这不违反实验室惯例,因为那个惯例并不存在。** 七个仓库 223 个 `@dataclass``kw_only` 出现
零次,但那是默认行为,不是任何一处做过的选择——没有一份文档或注释论证过位置构造。同一批仓库
里另有 66 个 pydantic 模型,它们天生只收关键字,所以「关键字构造」本来就是这个实验室里更常见
的那一种体验。
**加 `kw_only` 本身是破坏性变更,所以只能现在做。** 一旦有下游按位置构造过任何一个公共类型,
再加就会让那行代码直接 `TypeError`。这也是本文不能推迟的理由:它的成本随时间从零跳到「发一个
新 major」。
## 机器保证
`tests/unit/` 下一条扫描测试遍历 `polyloop` 包内所有数据类,断言每一个的
`__dataclass_params__.kw_only` 为真。它带 fail-closed 守卫:先断言扫到的数据类数量不为零,
否则包被改名或搬走之后这条测试会扫到空列表然后安静地绿,而绿的含义从「全都合规」变成
「什么都没检查」。
**这条测试不违反「测试绑行为不绑实现」**`../../CLAUDE.md` §1.8)。它断言的是一条对下游的
承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是
那条规矩自己给的:这个名字有没有对外承诺过。构造方式承诺过。