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`)对已有实现是兼容变更,而字段形状按
那个消费者真实要的来定,不按今天猜的来定