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:
@@ -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)。它断言的是一条对下游的
|
||||
承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是
|
||||
那条规矩自己给的:这个名字有没有对外承诺过。构造方式承诺过。
|
||||
Reference in New Issue
Block a user