From e701563a0b2c0ffc9d41cff6d309367223aa6586 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 27 Aug 2026 03:58:08 -0400 Subject: [PATCH] =?UTF-8?q?docs(design):=20=E8=90=BD=E5=AE=9A=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E4=B8=8B=E6=B8=B8=E6=8F=90=E7=9A=84=E4=BA=94?= =?UTF-8?q?=E4=B8=AA=E7=BC=BA=E5=8F=A3=E7=9A=84=E4=B8=89=E4=BB=BD=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。 三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置 知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。 0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志 「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」 正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后 它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到 任何异常。 --- .../0014-contract-suite-distribution.md | 458 ++++++++++++++++++ .../0015-parameter-snapshot-contract.md | 270 +++++++++++ .../design/0016-action-executor-failure.md | 181 +++++++ 3 files changed, 909 insertions(+) create mode 100644 research-wiki/design/0014-contract-suite-distribution.md create mode 100644 research-wiki/design/0015-parameter-snapshot-contract.md create mode 100644 research-wiki/design/0016-action-executor-failure.md diff --git a/research-wiki/design/0014-contract-suite-distribution.md b/research-wiki/design/0014-contract-suite-distribution.md new file mode 100644 index 0000000..2c2ddbe --- /dev/null +++ b/research-wiki/design/0014-contract-suite-distribution.md @@ -0,0 +1,458 @@ +# Design 0014 · 契约套件怎么发给下游 + +**日期** 2026-08-26 · **状态** 已接受(2026-08-26 项目负责人确认) + +**回答** 实验室 Gitea 上 PolyLoop 仓库的两份 issue,都由下游项目 dissect2 提出:#1「契约测试 +套件不随包发布,第一个下游拿不到准入标准」,#5「stores 的两笔欠账:承诺的内存实现不存在, +下游自实现的准入路径断了」。**dissect2 是 dissect 的第二版,正在重建**——这是提 issue 的那一方 +在 #1 里的自述,本仓库里除这三份 design doc 之外没有第二处记着它,`../migrations/dissect.md` +写的仍然是同一个下游、用的还是旧名字。哪天那份迁移文档跟着改名,这句话就可以删了。 + +**兑现** `0003-public-api-shape.md` 否决方案那一节里那句承诺——存储接缝改成必填,另外加一个 +显式命名的、明确不提供恢复的内存实现。必填那一半做到了,那个实现至今没写,本文把它补上。 + +**触及** `../../tests/contract/` 整个目录、`../../src/polyloop/stores/`、`../../pyproject.toml`, +`../explanation/architecture.md` 第七、八节,以及 `polyloop/stores/` 的模块 docstring 与 +`../../README.md` 阶段⑤那两句「契约套件是它的准入标准」。不回写这几处,这份决策就是死的 +(`../README.md` 第 3 节)。 + +最后那两句在套件搬走之后**仍然成立**,要改的只是指向——从 `tests/contract/` 指到 +`polyloop.testing`。提 issue 的那一方明确说过「说了准入标准但拿不到,比不说更糟」;套件发得 +出去之后,那句话第一次真正成立。 + +这套东西要过 `../../CLAUDE.md` §2 那道人类门,因为它新增的名字——契约基类、基类上的 fixture、 +每一条用例的方法名——一旦发出去就是下游子类要继承的东西,改名的代价和改公共类型的字段一样。 +确认在同一天完成。 + +## 几个词的最短解释 + +- **fixture**——pytest 里一个被声明为「测试的输入」的函数。测试函数的形参名就是它要的 fixture + 名,pytest 按名字找到那个函数、调用它、把返回值传进来。 +- **`conftest.py`**——一个约定名字的文件,放在测试目录里,pytest 自动加载它,里面定义的 + fixture 对同目录及其子目录下的测试可见。 +- **收集**——pytest 启动后扫描文件、找出哪些是测试的那个阶段。 +- **断言重写**——pytest 在 import 测试模块时改写它的 `assert` 语句,失败时能打印出等号两边的 + 实际值。不被重写的模块只会抛一个不带任何值的 `AssertionError`。 +- **marker**——给测试打的标签,用来分组和筛选,例如本仓库的 `contract`。 +- **`xfail` 与 XPASS**——`xfail` 标记一条「预期会失败」的测试;被这么标记的测试如果居然通过了, + pytest 报告成 XPASS,而 `strict=True` 让 XPASS 直接算失败。 +- **entry point**——Python 包在自己的元数据里声明的一个挂钩,装上之后别的程序能按名字找到它。 + pytest 用的那个组名叫 `pytest11`,声明了它的包会被 pytest 当成插件自动加载。 + +## 背景 + +`pip install polyloop` 之后,site-packages 里没有 `tests/`。`pyproject.toml` 的 +`[tool.setuptools.packages.find]` 只收 `src/` 下面的包,而 `tests/` 不在 `src/` 下,所以那套 +契约用例根本不进产物。这就是 issue #1。 + +它的分量来自 `tests/contract/conftest.py` 的模块 docstring 给这套东西定的位置:它不针对任何 +具体实现,写的是「不管你怎么实现,都必须满足这些行为」;下游写完自己的存储或适配器,在自己的 +`conftest.py` 里覆盖同名 fixture、返回自己的实现,跑一遍全绿就算合格。`../../CLAUDE.md` §0 +把它列成「任何新适配器的准入标准」。一份拿不到的准入标准不成其为标准。 + +issue #5 是这件事的具体后果。`polyloop/stores/` 的模块 docstring 与 `../../README.md` 阶段⑤ +都写着关系数据库那种形态由下游自己实现、契约套件是它的准入标准,而准入标准发不出去,这条路 +实际上是断的。同一份 issue 还记了另一笔:`0003` 承诺过的那个内存实现不存在, +`../migrations/dissect.md` 已经登记了这一条,并提醒读者不要照着那句承诺去找一个不存在的类。 + +**但这件事比两份 issue 说的更严重,严重在另一个方向。** + +`tests/contract/test_model_client.py` 有四条测试调用 `records.model_call(...)` +(第 21、34、47、55 行),而 `tests/contract/conftest.py` 里的 `_Records` 工厂**没有这个方法**。 +它有 `model_call_intent` 和 `model_call_result`,没有 `model_call`。这个错误至今没炸,是因为 +`model_client` fixture 的函数体是一句 `pytest.skip`,那四条测试从库落地到今天一次都没有被执行过。 + +五个接缝里只有存储那一套被真跑过——它接的是库自带的逐行追加实现 `JsonlRunStore`。另外四套 +全是跳过。2026-08-26 的基线是 283 通过、16 跳过、2 xfail;16 条跳过里有 15 条正是这四套接缝 +(动作执行 4 条、决策解释 5 条、模型调用 5 条、事件出口 1 条),第 16 条是 e2e 那道默认关闭的闸。 + +所以现在的状态是:一份从没被执行过的准入标准,正准备发给第一个下游当准入标准用。下游接上去 +的第一件事会是 `AttributeError`。所以这件事有两半:把套件发出去是一半,让套件真的被跑起来是 +另一半,见决策六。 + +## 决策一:套件搬进 `src/polyloop/testing/`,接法从「覆盖同名 fixture」换成「继承基类」 + +下游写成这样: + +```python +from polyloop.testing import RunStoreContract + +class TestMyPostgresStore(RunStoreContract): + @pytest.fixture + def store(self, pg_pool): return MyPostgresStore(pg_pool) +``` + +**现在这个接法在包发布之后走不通。** pytest 的 `conftest.py` 只沿着**被收集文件**的目录链往上 +查找。套件装在 site-packages 里,收集它的时候 pytest 看的是 site-packages 那条路径链,看不见 +下游仓库里的 `conftest.py`,于是下游根本没有位置去覆盖那些 fixture。就算把文件原样发出去了, +下游还是得把它们拷进自己的 `tests/` 才接得上——而拷贝正是 issue #1 明确说不想要的:拷出去的 +那一份从此不跟着升级。 + +「让下游直接跑装在包里的那个测试模块」这条路同样不成立——`pytest --pyargs polyloop.testing` +是它的具体形状。一个装在包里的测试模块,收集时拿不到下游的实现实例:套件必须先有那个实例才有 +东西可测,而这条命令没有任何位置能把它递进去。 + +**继承基类为什么解决它。** 下游的子类定义在下游自己的测试文件里,那个文件在下游仓库的目录链 +上;fixture 在类作用域内覆盖同名 fixture 是 pytest 的原生行为,不需要任何 `conftest.py` 魔法。 + +顺带三个好处。一个接缝可以接多个实现,各写一个子类——现在的 fixture 覆盖法一个接缝只能接 +一个实现,这正是库自带的**注册表分发器**至今没接上动作执行契约的原因。 + +那个分发器是 `polyloop.tools` 里的 `RegistryExecutor`:调用方把一组工具规格注册进 +`ToolRegistry`,`registry.executor()` 从这个注册表派生出一个动作执行器,它按模型给出的工具名 +查表、校验参数、调到那个工具的实现上。它是库唯一自带的动作执行接缝实现,另一种形态(把模型 +输出的一整段代码交给一个已经开好的会话去跑)由下游自己写。`tests/contract/conftest.py` 里那条 +fixture 的理由写着「接在这里会让套件只验得了那一种」,而那个限制是接法带来的,不是套件本身的。 + +下游仓库里多出一个看得见的文件,「我过了准入」不再是一个隐性状态,而是一段能被 review +的代码。库这边加一条用例,下游升级之后自动多跑一条。 + +**四家做同一件事的先例都是继承基类。** pandas 给第三方 ExtensionArray 作者的一致性套件、 +fsspec 给第三方文件系统的套件、zarr 给第三方存储后端的套件、OpenTelemetry 给第三方 +instrumentation 的套件,形态一致。zarr 和 OpenTelemetry 还把套件放在库自己的包里 +(`zarr/testing/`)而不是 `tests/` 下,理由和这里一样:`tests/` 出不了发行包。唯一的例外是 +SQLAlchemy 给第三方方言作者的那一套,它走「星号导入测试类」,下游用一行 `from ... import *` +把测试类拉进自己的模块;代价是它必须自带一个接管收集过程的钩子,复杂度高一个量级,而且 +pytest 9 新增的 `collect_imported_tests` 配置项设成 false 时会让这条路彻底失效。 + +**子包叫 `testing` 不是随手取的名字,它是这个生态里的通用叫法**:`numpy.testing`、 +`pandas.testing`、`zarr.testing` 都在同一个位置放同一类东西。下游看到这个名字就知道里面装的是 +给测试用的东西、不是运行时的一部分,不必先读文档才敢判断能不能在生产代码里 import 它。这个包名 +一旦发出去就是公共承诺(见代价一节),所以它值得是一个不用解释的名字。 + +## 决策二:基类的形状,七条 + +**一、类名避开 `Test` 前缀**,用 `RunStoreContract` 这种形状。pytest 的 `python_classes` 配置项 +默认就是按 `Test` 前缀匹配测试类,所以基类本身不会被当成测试类直接收集,而下游写的 +`TestMyStore` 会。 + +**这道防线依赖下游没改 `python_classes`,而这条取舍是认下来的,不加第二道防线。** 改了那个 +配置项的下游会把基类本身也收集进去,那时每一条用例都在必需 fixture 上撞第三条里那个 +`NotImplementedError`,而它的消息写着「在你的子类里覆盖这个 fixture」——一个直接跑到基类的人 +看得懂这条报错。改 `python_classes` 的下游本来也极少,因为那会影响它自己所有的测试类。 + +不加防线是因为**唯一的两条都比病更糟,两条都把一个响亮的失败换成一个静默的失败**,而这个仓库 +宁可要前者。把基类做成真正的抽象基类是第一条——`inspect.isabstract` 为真的类 pytest 不收集, +但子类没覆盖必需 fixture 时它**仍然是抽象的**,于是也不被收集,「忘了覆盖」从一条响亮的报错变成 +零条测试跑过而报告全绿。给基类设 `__test__ = False` 是第二条——这个属性**会被子类继承**,下游 +忘了在自己的子类上设回来,同样是静默零测试。 + +**二、不继承 `unittest.TestCase`。** 那种类绕过 `python_classes` 判定,无论叫什么名字都会被 +收集,于是第一条那道防线直接失效。 + +**三、每个必需 fixture 都在基类里定义出来,函数体 `raise NotImplementedError`**,消息里写明 +「在你的子类里覆盖这个 fixture」以及它该返回什么。不定义的话,下游忘了覆盖时 pytest 报的是 +`fixture 'store' not found`,后面跟着一整屏「available fixtures」列表,而那条错误指向的是 +site-packages 里的库文件——一个刚接上准入套件的人看到这个,第一反应是库坏了。pandas 和 fsspec +各自独立采用了同一个改善手段,五家先例里只有这一条被两家共同验证过。 + +**四、需要样本输入的 fixture,docstring 只写抽象形状和不变量,一个具体值都不给。** 套件不认识 +任何一家的动作语言:拿一家的代码围栏去喂另一家的 JSON 解析器,后者正确地返回「无效决策」,而 +写死输入的套件会把这个正确行为判成失败。这条纪律现在只对决策解释接缝落实了——`samples` fixture +要求实现方提供两段模型输出,套件只断言拿到之后的形状。动作执行那一套仍然写死着输入,见决策六。 +pandas 的同类 fixture 连退化情况都写进 docstring(某个 dtype 只有两个非空取值时第三个样本填 +什么),那是把这种 fixture 做成契约、而不是做成一张许愿单的关键。 + +**五、套件里一个自定义 marker 都不用。** 下游开了 `--strict-markers` 而没有注册库用的那个 +marker 的话,炸掉的是**整个测试文件的收集**,不是一条失败;而报错指向的同样是库的文件。 +pandas、zarr、fsspec 三家都是靠一个自定义 marker 都不用躲过这件事的。实现之间的能力差异一律 +走 fixture 加运行期 `pytest.skip()` 表达。本仓库自己要给这些用例打 `contract` 标记,打在自己 +那几个子类文件上——那些文件在本仓库里,marker 也在本仓库注册。 + +**六、「这一层验不了」统一成无条件 `pytest.skip`,两种现有写法都改。** + +套件里现在有两种装置在表达同一件事——「这条承诺是真的,但套件所在的这一层没有能力验证它」。 +一种是 `xfail(strict=True)` 加一句 `pytest.fail(...)`,`tests/contract/test_run_store.py` 里有 +两条:原子写的「崩在中间时两者都不可见」那一半,以及前缀持久性。另一种是**函数体只有一段 +docstring、一个断言都没有**,`tests/contract/test_action_executor.py` 里两条(三个状态各自的 +触发条件、动作被拒绝时那段观察由谁给),`tests/contract/test_event_sink.py` 里三条(一个连不上 +后端就抛异常的出口仍然合规、投递失败不再转成事件从同一个出口重发、审计留痕由存储承担而不由 +事件流承担)。 + +xfail 那种带一个陷阱:一个做得比库预期更好的下游实现会把一条 `xfail(strict=True)` 的测试跑通, +于是拿到 XPASS 判失败,而且下游取消不掉——子类上加一个类级 xfail 覆盖不了从函数级继承下来的 +那个,唯一的出路是整条重写方法。现有这两条触发不了它,因为它们不接触任何实现,只调 +`pytest.fail`,无条件失败。但套件发出去之后,下游跑准入时会看到两条与自己的实现毫无关系的 +xfail,而报告里没有任何东西能让它判断那是库的已知缺口还是自己漏了什么。 + +**空函数体那种更糟,糟在它在报告里是 PASSED。** 2026-08-26 的契约层基线是 15 通过、15 跳过、 +2 xfail,而那 15 条通过里有 5 条正是这种:它们一次都没有碰过任何实现,却和真的验过的那 10 条 +在报告里长得一模一样。一个准入标准里出现假绿,比出现跳过糟得多——下游跑完看到全绿,会以为 +自己的实现在这几条上被验过了。跳过至少诚实地说了「这条没验」。 + +**语义上跳过也是最准的那一个。** 这七条说的都不是「这个功能预期会失败」,更不是「这条已经 +验过了」,而是「这一层没有能力验证它」,那就是跳过。七条一律改成无条件 `pytest.skip`,理由 +字符串里写全三件事——这条承诺是什么、为什么这一层验不了、下游该在哪儿自己验。 + +统一之后契约层的报告是 10 通过、22 跳过、0 xfail:22 条里 15 条是接缝没有实现,另外 7 条是 +这一批。原来选 xfail 是为了让那两条每次跑都被看见,跳过同样被看见——`pytest -rs` 把每一条的 +理由逐行列出来,而那五条空函数体在报告里从来什么都不说。代价是这七条混在别的跳过里,不再 +各自占一行 xfail 报告。 + +`tests/contract/test_run_store.py` 模块 docstring 里讲那两条的一节跟着改名,别留着 `xfail` 的 +字样指向已经不存在的形状。`test_action_executor.py` 与 `test_event_sink.py` 的模块 docstring +里各有一句指向文末那几条说明,它们指向的形状同样变了,一并核对。 + +**七、不把测试类和 fixture 类拆成两个 mixin。** fsspec 是那么做的,下游写 +`class TestMyStore(RunStoreContract, MyStoreFixtures)`。它的收益是同一份 fixture 类能被多个 +测试类复用,而本库五个接缝各自的 fixture 只有一到两个、彼此不共用,拆出来的第二个 mixin 会是 +个空壳。将来某个接缝的 fixture 长到几个测试类都要用同一份时,这条拆分就值得回来做。 + +## 决策三:发一个 pytest11 entry point,只为换回断言重写 + +`pyproject.toml` 里声明 `[project.entry-points.pytest11]`,指向 `polyloop.testing` 下一个极轻的 +插件模块。 + +**它换回来的是断言重写。** 契约模块不在下游的 `python_files` 匹配范围里,pytest 默认不重写它的 +断言语句,于是一条契约测试失败时下游看到的是光秃秃的 `AssertionError`:没有左右两边的值,没有 +差异摘要。带了 pytest11 entry point 之后,pytest 把这个发行包整个标记为可重写,同一条失败就带 +上完整的比对信息。 + +**这是个纯静默的失败。** 不做的话没有任何东西会报错,下游只会觉得这套契约的报错难读,而且永远 +不会知道自己少了什么。SQLAlchemy 让下游在 `conftest.py` 里手写一行 +`pytest.register_assert_rewrite(...)`,并把它说成「压掉一个假警告」——那行才是它的断言输出可读 +的真正原因。 + +**插件模块的边界定死。** 它只提供一个空的配置钩子,自己只 import pytest 与标准库:不 import +任何存储实现、不 import `session`、不 import `adapters`。最后一条尤其硬——`adapters` 会把网关 +连同它的 provider 目录一起拉起来,而「`import polyloop` 之后 `sys.modules` 里不许出现 +`polygateway`」正是架构文档第七节那九条依赖规则的最后一条(规则九)要防的事,只不过这次的 +触发路径不是 `import polyloop`,是 pytest 自动加载插件。这条边界不靠自觉:import-linter 的分层契约把 +`testing` 和 `session`、`stores`、`adapters` 放在同一层且互不 import,这三条 import 写下去就红。 + +**插件里不接管事件循环。** 技术上可以在插件里钩住函数调用、对自家契约类的协程用 `asyncio.run()` +自己跑一遍,这样下游用什么 async 测试插件、什么模式都不影响。这条被否掉,理由是下游的 fixture +很可能是异步的——一个数据库存储实现的连接池就是。那个 fixture 在下游的事件循环里创建,而库自己 +开的循环是另一个,跨循环使用 asyncio 对象会炸,而且炸得很难查,那正是这个仓库最不能接受的 +那一类失败(`../../CLAUDE.md` §3 第三类)。库不接管事件循环,就不会和下游的任何 async 安排打架。 + +**兜底要写进用法文档。** `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1` 这个环境变量在 CI 里很常见,一设 +上 entry point 就不加载了。那时下游要自己写一行 `pytest.register_assert_rewrite("polyloop.testing")`。 + +## 决策四:async 用例的事件循环归下游管 + +契约套件里的用例照常写成 `async def`,套件不做任何事件循环安排。`testing` extra 带上 +`pytest-asyncio`,用法文档里写明要把 `asyncio_mode` 设成 `"auto"`,或者下游自己给子类打上对应 +的标记。 + +**为什么不由库保证它在任何配置下都能跑**,见决策三末尾那条:库一旦自己开循环,下游的异步 +fixture 就跨循环了。 + +**没配对的失败是响亮的。** pytest 会明说「async def 函数不被原生支持」,那条信息直接指向解法, +不是一条静默跳过。zarr 的 async 契约套件就是这么处理的,它是这五家先例里仅有的一个有异步接口的。 + +## 决策五:补上那个内存存储实现,命名为 `VolatileRunStore` + +`0003` 承诺过它,`../explanation/architecture.md` 第七节的分层图里也画着「jsonl / 内存」两种, +而 `polyloop/stores/` 里只有一个。 + +**名字不叫「不提供恢复」,叫「易失」。** `0003` 的原话是「显式命名的、明确不提供恢复的内存 +实现」,但一个真的读不回自己写过的东西的存储**过不了自家的契约套件**——套件第一条就要求写进去 +的意图读得回来——而这一层的准入标准就是那套套件。一个过不了自家准入标准的实现不该存在。它真正 +不提供的是**跨进程恢复**:进程一退,日志就没了。`Volatile` 说的正是这件事,而且它不撒谎。选它 +就是选「我不要跨进程恢复」,这仍然是一次看得见的选择,`0003` 那句承诺的意图达到了。 + +**写入走一遍编解码往返,不直接存对象引用。** 五个记录类都是 frozen 的,但 `RunStarted` 带着的 +参数快照是一个 `Mapping`,调用方构造完之后还能改自己手里那个 dict——存引用就有别名 bug,读回来 +的快照会跟着调用方后来的改动变。逐行追加那个实现因为要序列化成 JSON 文本,天然免疫这件事。 +往返让两个实现在契约上结构性等价,而不是靠人肉逐条对齐。 + +**第二个理由是往返顺带校验了「这条记录编不编得出来」。** 一个下游用易失存储跑自己的测试时, +如果构造了一条编不出来的记录,逐行追加那个实现会在写文件时炸;而一个只存对象引用的内存实现 +完全不会炸。于是同一份测试在两个存储上行为不同,下游会以为自己的记录没问题,直到换成落盘的 +那个实现才发现。往返让两个实现在这一点上也等价。(编解码路径上那道 schema major 校验在这里 +确实不可能失败——刚编出来的就是当前版本。有用的是编得出来这件事本身,不是版本号。) + +代价是每次写多一次编解码,而这个实现本来就是给测试和「不要恢复」那一档用的。 + +**顺带把 `stores` 里那两张平行的表合成一张。** 现在有「类型到标签」和「标签到解码器」两张表, +加上内存实现要用的「类型到解码器」就是三张,而三张表之间有一个谁也不检查的一致性要求:必须 +覆盖同样那五个记录类。合成一张三元组的表,再从它派生出需要的几个视图,那个要求就不可能被违反。 + +**`stores` 拆成三个文件**:模块 docstring 讲这一层装什么和不装什么,两个实现各占一个文件。 +公共 import 路径不变。理由是现在那份模块 docstring 混着讲两件事——这一层的边界,以及逐行追加 +那个实现的文件布局;加第二个实现之后这个混淆会加剧。 + +## 决策六:五套契约全部接上,一条用例都不留在从没被执行过的状态 + +发一份从没被执行过的准入标准出去,等于把背景那一节说的那个缺失方法直接交给下游。五个接缝各 +接上什么、子类写在哪一层: + +| 接缝 | 接哪个实现 | 子类落在 | +|---|---|---| +| 存储 | 逐行追加存储 `JsonlRunStore` | 契约层 | +| 存储 | 易失存储 `VolatileRunStore`(决策五新写的) | 契约层 | +| 动作执行 | 由工具注册表派生的分发器 `RegistryExecutor` | 契约层 | +| 模型调用 | PolyGateway 模型适配器 | integration 层 | +| 决策解释 | 库没有实现,写一个最小的测试替身 | 契约层 | +| 事件出口 | 库没有实现,写一个最小的测试替身 | 契约层 | + +模型适配器那一行落在 integration 而不是契约层,是因为它连的是真网关:按 `../../CLAUDE.md` §1.9 +的分层判据,测试归哪一层看它依赖什么,不看它叫什么。 + +**接分发器要求先改动作执行那套契约的输入。** 它现在把输入写死成一段文本形式的动作 +(`tests/contract/test_action_executor.py` 第 51 行那句 `raise RuntimeError()`),那是「模型输出 +一整段代码」那种动作语言;喂给一个按工具名分发的执行器,它正确地返回「未执行」,而套件会把这个 +正确行为判成失败。改成由实现方提供两个样本:一个能正常执行完的动作,一个执行了但动作本身报错的 +动作——两者的状态都该是「已执行」,这正是那条契约要断言的。这个改法就是决策二第四条那条纪律, +它本来就该对这套用例成立,只是当初没落实。 + +**为什么要专门造两个替身,而不是等第一个下游接上来时这几条自然就被执行到了。** 等下去的话, +那个下游会替我们撞上库自己的 bug,而它手上没有第二个实现做对照,判断不了到底是自己写错了还是 +套件本身有问题。缺失的那个工厂方法正是这个形态:它表现成「实现一接上去就 `AttributeError`」, +而一个刚接上准入套件的人看到这个,第一反应是自己接错了,不是库错了。测试替身把这次撞击挪到 +发布之前、挪到有能力判断的人手上——库这边的人知道套件和记录工厂都是自己写的,一眼看得出问题 +在哪一侧。 + +**测试替身不违反「库不带默认实现」那条。** 那条禁的是 `src/` 下出现一个默认实现——带了就等于 +替某一家定了动作语言或投递协议,而下游装了包就能拿到它,就会有人直接用。测试替身住在 `tests/` +里,不进 wheel,任何下游都拿不到。它存在的唯一目的是让套件的每一条用例至少被真的执行一次。 +判据是「下游拿不拿得到」,不是「代码里有没有一个能跑的实现」。 + +**换来的是五套契约的每一条用例都至少被执行过一次,`records.*` 上的每一处引用都被真的求值过。** +缺失的那个工厂方法是靠人读代码撞出来的;这批测试替身之后,同类问题在提交之前就会红。 + +记录工厂本身照样要有一层单元测试,但它守的是**工厂造出来的记录合不合法**,不是「用例引用的 +方法存不存在」。后者只有真的执行那条用例才查得出——一个测试引用了工厂里不存在的方法,工厂自己 +的单元测试怎么写都看不见它。 + +## 否决的方案 + +**把 `tests/` 打进发行包,接法不动。** 只解决 issue #1 的字面,解决不了 fixture 覆盖在 +site-packages 里无处落脚这件事,下游拿到文件之后还是只能拷贝。见决策一。 + +**让下游直接跑装在包里的那个测试模块**(`pytest --pyargs polyloop.testing`)。收集时拿不到 +下游的实现实例。见决策一。 + +**SQLAlchemy 那种星号导入测试类。** 要自带一个接管收集的钩子,而且 pytest 9 的 +`collect_imported_tests` 一关就失效。见决策一末尾。 + +**继承 `unittest.TestCase` 来省掉「类名避开 `Test` 前缀」这条约定。** 它让基类自己也被收集。 +见决策二第二条。 + +**把基类做成真正的抽象基类,或者给它设 `__test__ = False`。** 这两条是给「下游改了 +`python_classes`」加第二道防线的仅有做法,两条都把一个响亮的失败换成静默零测试。见决策二 +第一条。 + +**不定义必需 fixture,靠 pytest 的「fixture not found」报错提示下游。** 那条报错指向库文件, +而且带一整屏无关列表。见决策二第三条。 + +**给能力差异用自定义 marker。** 下游的 `--strict-markers` 会让整个文件的收集失败。 +见决策二第五条。 + +**把 fixture 拆成独立的 mixin 类。** 本库每个接缝的 fixture 只有一到两个、彼此不共用,拆出来 +是个空壳。见决策二第七条。将来某个接缝的 fixture 长起来时可以回来拆。 + +**在 pytest11 插件里接管事件循环,替下游跑协程。** 下游的异步 fixture 会跨循环,而跨循环的 +asyncio 对象炸得很难查。见决策三。 + +**把那个内存实现命名成「不提供恢复」的字面意思。** 一个读不回自己写过的东西的存储过不了自家的 +契约套件。见决策五。 + +## 代价 + +**`polyloop.testing` 的基类名、fixture 名、用例方法名从此是公共承诺。** 下游的子类按它们写。 +改 fixture 名的后果至少是响亮的:一个被改了名的 fixture 覆盖不到任何东西,而基类里那个 +`raise NotImplementedError` 的默认实现会立刻失败。用例方法名不同——下游要豁免某一条时按名字 +重绑它,改名之后那个豁免会悄悄失效,跑出来照样全绿。 + +**entry point 让每个装了本库的项目在 pytest 启动时多 import 三处东西。** 插件被加载时,Python +会先把 `polyloop.testing` 这个父包执行一遍,而它 re-export 五个契约基类,于是 `ports` 和 `types` +一并被 import 进来。这三处都是纯类型定义,没有 I/O 也没有网络;但它确实发生在每一个装了本库的 +下游项目的每一次 pytest 启动上,包括根本不用契约套件的那些。不做惰性 import 把这点开销省掉, +是因为那要在包的 `__getattr__` 上做手脚,而 `../../CLAUDE.md` §6 要求显式优于隐式,省下的几 +毫秒不值这个魔法。 + +**pytest 进了 extra,版本只写下界。** 这和 `dev` 那一组「必须钉死」的规矩正好相反,理由也正好 +相反:`dev` 是本仓库自己的工具链,钉死是为了本地和 CI 一致;`testing` 装在下游的环境里,钉死会 +和下游自己的 pytest 版本打架。这个区别要写进 `pyproject.toml` 的注释,不然下次有人会「顺手统一 +一下」。 + +**契约套件从此有两个读者**,一个是本仓库的开发者,一个是下游的实现者,而后者手上没有本仓库的 +上下文。写用例时要照后者写:报错信息里不能出现只有本仓库开发者看得懂的指代。 + +**`../explanation/architecture.md` 第七节的分层图、第八节的代码地图、第九节的依赖规则都要跟着 +改**,而那三节是本仓库唯一由机器断言的架构描述。`polyloop.testing` 是一个新的公开模块,它 import +`ports` 与 `types`,它在分层里的位置和它与其余模块的独立性都要落进 import-linter 契约。 + +## 留给后续的 + +**决策解释接缝与事件出口那两套用例跑起来了,但跑的是库自己写的测试替身。** 替身按套件的期望 +写,所以它证明得了「这几条用例执行得下去、引用的东西都存在」,证明不了「一个真实的下游实现 +接上来也说得通」——一条只对替身成立的用例,要等第一个下游接上自己的解析器那天才暴露,而那时 +问题会表现成「库的套件有 bug」。这一半的成本仍然由第一个下游承担。 + +**契约套件没有版本协商。** 下游装的是哪一版库,跑的就是哪一版套件。库加一条更严的用例时,一个 +原本合格的下游实现会在升级之后变红,而它自己什么都没改。这件事该不该有一个宽限机制——比如让 +新用例先以警告出现一个 minor 版本——等第一个下游真的撞上再说。 + +## 落地时的修订 + +上面的决策确认之后、写代码的过程中改了三处:两处在决策二里,一处在决策五里。其余决策照原样 +落地。 + +### 模型调用那套契约多了一个 `failing_call` fixture + +决策二第四条那条纪律——需要样本输入的 fixture 由实现方提供、一个具体值都不写死——当时只对 +决策解释和动作执行两套点了名。模型调用那套当时靠的是一个约定俗成的暗号:`result_id` 等于某个 +特定串时,实现方就该让这次调用失败。 + +这条对真适配器不成立。它不认识那个暗号,于是那次调用正常成功,用例判它不合格,而它其实是 +对的——这正是第四条那句「拿一家的样本去喂另一家」的同一个形态,只是这次写死的不是输入内容 +而是一个失败信号。所以第三个样本 fixture 按同一条纪律加上了。 + +`ModelClientContract.failing_call` 返回一个 `polyloop.ports.ModelCall`,拿它调用被测客户端 +必须抛异常;抛什么类型由实现定,契约只要求「抛」。三条附加约束写在它的 docstring 里:这次 +调用不许真的花钱,也不许在失败之前留下外部副作用,最省的做法是让它在入参校验那一关就挂掉; +它和别的用例共用同一个 `model_client` 实例,所以失败之后那个实例必须还能接着服务。 + +退化情况也写进了 docstring:不存在「无论如何都不会失败」的合规实现。契约规定失败以异常表达, +不以「返回一个内容为空的正常回复」表达——库靠这个区分基础设施故障与「模型真的回了空字符串」。 +所以一个给不出这个 fixture 的实现,要么是把失败吞成了空回复(那就是不合格,正是这条用例要 +抓的),要么是还没想过失败路径。 + +### 决策二第六条那组数字描述的是套件搬走、还没接实现的那一刻 + +「10 通过、22 跳过、0 xfail」说的是七条统一成 `pytest.skip` 之后、五套契约还一个实现都没接 +上时的报告。它不是终态。 + +接上实现之后是另一组。2026-08-26,`pytest tests/contract -q` 的报告是 **29 通过、10 跳过、 +0 xfail**。多出来的通过数来自决策六那张表:同一套存储用例在两个实现上各跑一遍,动作执行接上 +由工具注册表派生的分发器,决策解释与事件出口各接一个测试替身。模型调用那一套按同一张表落在 +integration 层,不计在这个数里。 + +剩下的 10 条跳过全是「这一层验不了」那一类。决策二第六条统一过来的那七条占其中 9 条——存储 +那两条(原子写崩在中间、前缀持久性)在两个实现上各跳一次。第 10 条是取消:被测执行器在一个 +事件循环 tick 之内就返回,取消发出去时它已经跑完,根本没有机会吞掉取消,那条契约对它无从 +谈起。 + +### 决策五第二条理由里「写的时候炸」那半不成立 + +那一条说编解码往返顺带校验了「这条记录编不编得出来」,判据是:一个下游用易失存储跑自己的 +测试时构造了一条编不出来的记录,逐行追加那个实现会在写文件时炸,而一个只存对象引用的内存 +实现完全不会。前半句是错的——逐行追加那个实现在写入时只做 `json.dumps`,一条字段类型不对的 +记录仍然是合法 JSON,写得进去,要到 `read_log` 解码时才炸。 + +落地时按「两个实现一致地在读的时候才炸」改了实现:写入只编码不解码,读取现解一遍。解一遍 +再把结果丢掉确实能让坏记录在写入时就炸,但那样一来同一条记录在两个实现上一个写得进去一个 +写不进去,同一段下游代码在易失存储上写就红、换成落盘存储要到读才红。等价的对齐点是读—— +两边都放行,两边都在 `read_log` 抛同一个解码错误。 + +防别名那一半照旧成立,而且这么改之后更干净:桶里存的是编出来的载荷,`read_log` 每次现解出 +一批新对象,于是写这一侧(调用方构造完之后接着改自己手里那个 dict)和读这一侧(读回来之后 +改它就倒着改掉存储里那一份)的别名同时堵上。 + +## 登记一条缺口:独占开新运行不是端口承诺 + +两个自带存储实现都把「同一个运行标识不许开第二次」做成了必须——逐行追加那个靠 `O_EXCL` +独占创建文件,易失那个靠一句显式检查。理由是驱动入口在开工前的那次「先读后写」挡不住两个 +进程同时开同一个标识,而同一个标识被重开之后,交错的记录序会让恢复读到同一步的两条意图。 + +但 `polyloop.ports.RunStore` 没把这条写成端口承诺,契约套件里也没有对应用例。于是一个不做 +独占的下游存储能把整套准入标准跑全绿,接上驱动入口之后那个并发窗口照样敞开。这不是这批改动 +引入的——套件还住在 `tests/` 里的时候缺口就在;变化的是它现在是对外的准入标准,而一条准入 +标准没查的事,下游有理由认为不需要查。 + +补进契约要单独决定,不搭这批的车:那是往一份已经发出去的准入标准里加一条更严的用例,一个 +原本合格的下游实现会在升级之后变红,而它自己什么都没改。「留给后续的」那一节记的版本协商 +问题正是这个形态。 diff --git a/research-wiki/design/0015-parameter-snapshot-contract.md b/research-wiki/design/0015-parameter-snapshot-contract.md new file mode 100644 index 0000000..055a8ba --- /dev/null +++ b/research-wiki/design/0015-parameter-snapshot-contract.md @@ -0,0 +1,270 @@ +# Design 0015 · 参数快照的内容契约 + +**日期** 2026-08-26 · **状态** 已接受(2026-08-26 项目负责人确认) + +**回答** 实验室 Gitea 上 PolyLoop 仓库的两个 issue,提出者都是下游项目 dissect2:#3 标题是 +「提示词模板的哈希在参数快照里没有位置」,#4 标题是「注入的『通道』维度在参数快照里被拍平」。 +两份指的是同一类缺口——一件影响这次运行的事实没有进参数快照。 + +**补充** `0003-public-api-shape.md` 决策三里请求那张字段表,往上加一个字段;以及 +`0006-public-names-and-signatures.md` 定的公共名字与签名,本文给新增的快照键定形状。这两份的 +其余部分不受影响。 + +**触及** `../../src/polyloop/session/__init__.py` 里 `RunRequest` 的字段与两处 +`parameter_snapshot`、`../../src/polyloop/_assembly/__init__.py` 里 `injected_entry_ids` 的返回 +类型、`../../src/polyloop/types/__init__.py` 里 `Injection.entry_id` 的注释,以及 +`../explanation/architecture.md` 第十节「装配形态」里讲请求持有什么的那一段。**不回写这几处, +本文就是死的**——写代码的人读的是代码和常青文档,不会为了传一个字段跑来翻 `design/`。 + +## 背景 + +一次运行的配置分成两半,各是一个不可变对象,两个合起来叫这次运行的**装配**。跨运行不变、 +可以并发复用的那一半是 `AgentDefinition`:模型调用、决策解释、存储、事件出口四个接缝,以及 +库在动作被拒绝或环境故障时合成的那几段观察。每次运行都不同的那一半是 `RunRequest`:运行 +标识、预算、动作执行接缝、本次可见的工具集、上下文、注入内容、模型绑定等等。参数快照就是从 +这两个装配对象上现算出来的一份「这次跑的是什么设置」。 + +参数快照是续跑守卫的全部依据。`resume` 把当前装配现算的快照和日志里存着的那份逐字段比对, +任何一项对不上就抛 `ParameterDriftError`,拒绝往下跑。它拦的是一种没有失败现场的事故:崩溃 +之后用同一个运行标识、换一份配置续跑,前几步和后几步来自两套配置,而全程零报错,两段轨迹在 +文件里看起来是同一次运行。 + +守卫的强度完全由快照的内容决定。**一件影响这次运行的事实没有进快照,等于它换了也不会有人 +知道**——比对的时候那一项根本不在场。 + +现在快照里有这些:预算的四项、模型调用的重放策略、观察模板、取消宽限期、本次可见的工具名 +清单、模型绑定的全部键值、这次贴进上下文的那些注入条目的标识(键 `request.injected_entry_ids`), +以及五个接缝各自上报的参数(四个挂在定义上,动作执行接缝挂在请求上)。issue #3 与 #4 各指出 +一处漏在外面的事实,两份都来自同一个下游、同一档实验需求。 + +## 决策一:请求上开一个 `fingerprints` 字段,收「这次运行用的是哪一版配方」 + +`RunRequest` 新增字段 `fingerprints: Mapping[str, str]`,默认空映射。它的每一个键值都进快照, +键形如 `request.fingerprint.`——单数,与已有的 `request.binding.` 对齐。 + +**默认空映射时快照里一个键都不写**,不是写一个值为空串的键。这样今天已经在跑的配置算出来的 +快照逐字节不变,只有真的传了指纹的运行才多出那几项。 + +### 为什么需要它 + +上下文的正文与注入条目的正文是刻意不进快照的,理由是它们属于这次运行的输入**数据**而不是 +参数:进快照会让快照变成一份数据副本,而它们可能很大。 + +**注入这一侧不进快照的只有正文,条目的标识是进的。** 正文和标识是两样东西:正文是贴给模型 +看的那段文本,标识是这条注入的名字。所以「这次贴了哪几条」事后查得到,查不到的只是那几条各自 +写了什么。上下文那一侧没有对应的标识可以留,它是一段已经渲染好的消息序列,本身不带名字。 + +把正文挡在外面这条理由没错,但它顺手把**生成这些数据的东西**也挡在了外面。提示词模板不是 +数据,是参数——它是一份跨运行复用的配方,每次运行拿它渲染出这一次的上下文。 + +提 issue 的下游研究的正是「改这份文本会让 agent 表现好多少」,模板是被系统地改动的东西之一。 +不记的话,「这次用的是哪一版提示词」就只剩下 harness 的 git 提交这一个粒度,而同一个提交下 +完全可以试好几份不同的模板。具体的失败场景是:换一份模板、用同一个运行标识续跑,前几步用 +A、后几步用 B,全程零报错,那次运行的数据已经废了却没有任何东西提示。 + +### 为什么放请求不放定义 + +请求级能表达定义级能表达的一切,反过来不行。一样东西每次运行都一样,把它放在请求上、每次传 +同样的值就是了,快照比对照样成立;而一样东西每次运行都不同,放在定义上就没有办法表达——定义 +是跨运行并发复用的,它上面的值不能随运行变。 + +库无从知道下游会往 `fingerprints` 里放什么。已知的那个需求(提示词模板的 sha)确实跨运行不 +变,但同一个字段将来会收「这次注入的技能库是哪一版」这类每次都变的东西。既然要选一个位置, +就选能覆盖两种情况的那个。 + +辅一条:缺口的位置本来就在请求这一侧。定义上那四个接缝各有一个 `parameters()` 方法,能自报 +自己的指纹;请求这边只有动作执行接缝有。剩下没有人能替它们说话的是上下文和注入内容——它们是 +纯数据,没有一个对象可以发问,而模板正是生成上下文的东西。 + +那定义与请求那个「跨运行变不变」的切点怎么办?它是切分两个装配对象的依据,但它不是一条能 +逐字段套用的规则,`Context.run_level` 就是现成的反例:那一段装的是角色说明、示例演示、能力 +描述,在一批运行里通常一字不变,它却住在请求上——因为它和逐题变化的 `goal_level` 是同一份 +渲染的产物,拆到两个装配对象上会逼调用方在两个地方保持一致。模板的指纹和它渲染出来的上下文 +是同一件事的两面,同理。 + +### 它和 `model_binding` 的区别 + +两个字段形状相同:都是下游自由定义键名的字符串映射,都进快照,库都不解释内容。含义不同。 + +| 字段 | 记的是什么 | 库怎么用 | +|---|---|---| +| `model_binding` | 这次运行属于哪一格:哪个账本、第几轮、哪道题、第几次尝试 | 进快照,并原样透传给每次模型调用 | +| `fingerprints` | 这次运行用的材料是哪一版 | 只进快照 | + +坐标和配方版本混在一个字段里,事后分不开:一组键值里既有「第 3 轮」又有一个 sha,要靠键名的 +命名约定去猜哪个是哪个,而命名约定不在任何一处被断言。提 issue 的下游已经在考虑把模板的 sha +塞进 `model_binding`——那样功能上是通的,透传出去的绑定里多一个键,网关也不在乎;但语义不 +对,而且下一个下游看见了会跟着学,几个月后这个字段里什么都有。 + +### 值的形状库不解释,但 docstring 里给一条建议 + +建议值里带上算法前缀,形如 `sha256:`。理由是换算法的那天,不带前缀的旧记录和新记录会以 +「两个不同的十六进制串」的形式参与比对,报出来的漂移看不出是换了算法还是内容真的变了;带前 +缀则一眼看得出。 + +这是建议,不是校验。库不解释这个值,也就没有立场规定它长什么样——真去校验,等于替下游定了它 +能用哪几种哈希。 + +### 业界怎么用 fingerprint 这个词 + +最贴的先例是实验记录框架 Sacred。它把「这次用到的源文件及其 md5」放在 `sources` 里,与参数 +`config` 平级分开,而不是塞进同一个字典——和这里让 `fingerprints` 与 `model_binding` 各占一个 +字段是同一个形状,理由也一样:材料版本和参数坐标混在一处,事后分不开。带算法前缀那条跟的是 +OCI 镜像摘要的写法。 + +## 决策二:注入的通道维度在快照里保留,一个通道一个键 + +请求上的注入内容是一个映射:键是**通道名**,值是这个通道里的一串**条目**,每个条目由一个标识 +和一段正文组成。通道名由调用方自己定,库里没有任何预定义的通道,也不校验通道名的形状;一个 +通道里放几条同样由调用方决定。通道存在的意义是把来源不同的注入分开——同一次运行里,来自两个 +不同挑选过程的材料各占一个通道。 + +**库只负责贴和记录贴了什么,不负责生成、评测、挑选。** 装配时 `_assembly.injection_messages()` +把这些条目摊平成一串消息贴进提示词,一个条目一条消息,正文原样,前后不加任何标题或分隔符; +`_assembly.injected_entry_ids()` 把同一批条目的标识收成快照要写的那份记录。挑哪几条进来是调用 +方在调 `run()` 之前就做完的事。所以「声明了这个通道但一条都没选中」这种情形,从库这一侧看到的 +只是一个条目为空的通道,那次筛选的过程库全程不在场。 + +注入按通道分组进快照,一个通道一项:键是 `request.injected_entry_ids.<通道名>`,值是那个通道 +里的条目标识按 `injection_messages` 的同一顺序拼成的逗号串。原来那个把所有通道拍平成一个键的 +写法作废。 + +`_assembly.injected_entry_ids()` 的返回类型跟着改:从扁平元组改成按通道名字典序排好的映射。 + +### 两个排序的理由不一样 + +**通道内的条目跟着 `injection_messages` 排,因为这个顺序有意义。** 它决定这几条注入贴进提示词 +的先后,也就决定了模型看到的是什么。顺序变了就是配置变了,续跑该报漂移。两个函数同序还有一条 +更硬的理由:不同序的话,快照记的贴入顺序和模型真正看到的顺序是两回事,而续跑守卫照样全绿。 + +**通道之间按通道名字典序,纯粹是为了确定性,这个顺序本身不承载任何含义。** 调用方传进来的是一 +个映射,它的迭代顺序取决于调用方怎么构造它——用推导式从一个集合建出来的话,Python 的字符串哈希 +每进程随机,于是同一份配置在不同进程里摊平出的消息顺序不同,渲染出来的提示词也就不同。字典序 +把这个不确定性去掉。 + +### 拍平之后丢掉的两样东西 + +通道名在拍平的写法里只用来定顺序,排完就没了,于是「哪几条来自哪个通道」事后查不到。 + +更要紧的是另一样:「声明了这个通道但一条都没选中」和「压根没有这个通道」在拍平之后是同一个 +结果——两种情况下这个通道对快照的贡献都是零,它在记录里彻底不出现。提 issue 的下游有一档实验 +要测「注入的内容到底起没起作用」,它要比较的正是这两种情形:一组运行声明了通道而选中零条, +另一组连通道都不声明。两者在记录里长得一样,那一档就测不了。 + +按通道成键之后这两种情形分得开:声明了通道但为空,是一个值为空串的键;压根没有这个通道,是 +这个键不存在。 + +### 为什么不在 `RunResult` 上另透一份 + +issue 里提到的另一个做法是把带通道的结构挂到 `RunResult` 上。否掉,两条理由。`RunResult` 是会 +被下游存进数据库和实验数据集的持久化结构,往它上面加字段要同时抬 schema 版本,代价高一档。 +而且同一个事实放两处,迟早有一处被改而另一处没改,到那时两处不一致,谁对没有答案。快照已经 +承载了这个事实,让它承载全。 + +### 顺带改正三处把快照说成轨迹的 docstring + +代码里有三处说条目标识「进轨迹」:`types/__init__.py` 里 `Injection.entry_id` 的字段注释、 +`session/__init__.py` 里 `RunRequest.parameter_snapshot` 的 docstring、`_assembly/__init__.py` +里 `injected_entry_ids` 的 docstring。这个说法是错的,本次一并改正。 + +轨迹是步记录的序列,一步一条,记的是这一步模型说了什么、动作是什么、观察是什么。条目标识不 +在里面。它进的是运行开始记录里的参数快照,一次运行只写一条,写在开工之前。照现在的 docstring +去找,下游会在步记录里翻一个不存在的列,翻不到之后多半会得出「库没记这件事」的结论,而它 +明明记了。 + +改正和决策二本来就要做的事发生在同一处:`Injection.entry_id` 那段注释还要补上下面那条分隔符 +约束。 + +### 分隔符是一个已知的、不打算修的限制 + +快照的值是字符串,把一串条目标识压进一个值里就得选一个分隔符。条目标识里如果真含逗号,两组 +不同的注入可能拼出同一个串,于是一次本该报出来的漂移没有报。通道名同理。 + +不修的理由是:快照的值只用于逐字段比对,从来不被解析回列表;而人要肉眼看快照排查漂移,换成 +JSON 编码会让它读不动——一份几十行的快照里混着转义引号和方括号,「哪一项变了」这个问题的答案 +就得靠工具才看得出来。已有的 `request.tools` 是同样的形状、同样的限制,这里不为新键单独定 +一套。 + +代价是给下游留一句约束:标识里不要放逗号。这句话写进 `Injection.entry_id` 的注释,因为那是 +写代码的人会读到的地方。 + +## 决策三:快照的取值必须是字符串,在开跑之前就守住 + +两个校验点。`RunRequest.__post_init__` 校验 `fingerprints` 与 `model_binding` 的每一个键和每 +一个值都是 `str`。两处 `parameter_snapshot()`——`AgentDefinition` 那个和 `RunRequest` 那个——在 +聚合接缝上报的参数时,校验接缝返回的键值都是 `str`。不合格用显式异常拒绝。 + +**用异常不用 `assert`**:`python -O` 会把断言整条移除,下游拿 `-O` 跑的那天这道校验就静默消失 +了,而它守的正是一件静默出错的事。 + +### 只有第一个校验点在构造期 + +`RunRequest.__post_init__` 那个是真正的构造期:此时什么都还没发生,没有 I/O、没有日志、没有 +模型调用,拒绝的代价是零。 + +聚合那个不在构造期。`AgentDefinition.parameter_snapshot()` 是方法不是字段,构造定义时不向任何 +接缝发问,发问发生在首次算快照的时候,而那时 `run()` 已经读过一次存储日志了。它保证的不是零 +代价,是**校验发生在写运行开始记录之前,也就是在任何一次模型调用之前**:不会跑完一整次运行、 +把钱花光,才在续跑时发现快照里有一项存不下去。 + +两个点的强度不同:**一个构造得出来的定义对象并不保证算得出合法快照**——构造它的时候那四个 +接缝一次都没被问过。某个接缝的 `parameters()` 返回一个整数,定义照样构造成功,要到首次算 +快照时才被拒绝。 + +### 为什么要提前到这里 + +快照的取值类型已经是持久化契约的一部分:反序列化那一侧读到非字符串会直接失败, +`tests/unit/test_serialization.py` 有一条测试钉着它。但那个失败发生在**续跑读日志的时候**—— +这次运行已经完整跑过一遍,钱花完了,日志也已经写下去了,才发现里面有一项读不回来。而且发现 +它的前提是真的有人来续跑;没人续跑,那份存坏了的日志就一直躺着,直到有人拿它做统计。 + +### 为什么连接缝上报的参数一起校验 + +接缝实现由下游写,它返回什么算外部输入(`../../CLAUDE.md` §6:适配器返回算外部输入)。五个 +接缝里任何一个的 `parameters()` 返回一个整数,症状都一样:这次运行照常跑完,续跑时才炸。 + +校验放在聚合的那一处,而不是分散到五个实现里,是为了让「快照的取值都是字符串」成为一条真的 +被守住的不变量。写在五个实现里的话,它只是五份各自的自觉,而下游写的适配器根本不在我们的 +自觉范围内。 + +### `model_binding` 一起改是有意的 + +它和 `fingerprints` 语义同族、形状相同,只给新字段加校验会让两个看起来一样的字段行为不一样。 +那种不一致比两个都不校验更难查——查的人会先怀疑自己传错了字段,而不是怀疑库对两个同形状的 +字段处置不同。 + +## 代价 + +**快照的键形状变了,跨版本续跑会报漂移。** 用旧版本跑到一半的运行,升级本库之后再 `resume`, +会因为 `request.injected_entry_ids` 这个键消失、`request.injected_entry_ids.<通道名>` 那几个键 +出现而抛 `ParameterDriftError`。这个失败是响亮的,不是静默的:错误信息会把漂移的键逐个列 +出来。 + +**这不构成 `../../CLAUDE.md` §1.3 意义上的破坏性变更。** 快照的键集合从来不是公共承诺。下游 +换一个存储实现、改一个接缝的 `parameters()` 返回什么,快照就变、续跑就报漂移——这本来就是 +这套设计的一部分,也是它该有的行为。库自己改快照的键属于同一类事件,处置也一样:那次运行 +重新开始,或者接受它跑不完。 + +**`fingerprints` 有变成垃圾桶的风险。** 一个「什么都能塞」的自由映射,判据不写清楚就会长成 +第二个 `model_binding`:今天进去一个模板 sha,明天进去一个「本次实验的备注」,后天进去一个 +时间戳,而时间戳每次都不同,续跑必然报漂移。缓解只有两条,都不是机器能查的——docstring 里 +那条「坐标还是配方版本」的分界,以及评审。 + +**决策三的第一个校验点让请求的构造多了一次遍历。** 请求承诺构造廉价:无 I/O、无网络校验、 +无哈希计算。遍历两个通常只有个位数条目的映射不违背这条承诺,但它确实不是零成本。记在这里, +是为了下次有人往 `__post_init__` 里加东西时能看见这笔账已经开过一次。 + +## 留给后续的 + +**快照的键空间没有任何机器保证不撞车。** `request.binding.`、`request.fingerprint.`、 +`request.injected_entry_ids.<通道名>` 三处的后半截都是下游给的自由字符串,库不校验它们的形状。 +现在三个前缀互不相同,所以撞不了;再往快照里加一个带自由后缀的前缀时,要重新检查这件事。 + +**`0003` 决策三的请求字段表有一笔没做的欠账,而它即将被盖掉。** 那张表里有「工具段渲染 +样式」这一项,`architecture.md` 第十节跟着写请求持有十一样数据;代码里 `RunRequest` 只有十个 +字段,搜不到任何对应物。这是那张表里唯一一笔有表无码的欠账。`0014` 处理的另外两笔不在这张 +表里:承诺过的内存存储实现来自 `0003` 否决方案那一节,契约套件发不出去则和 `0003` 无关。 + +危险不在这处漂移本身,在于它即将被盖住:加上 `fingerprints` 之后请求的字段数恰好变成十一, +第十节那个数字会重新对上,而组成完全不同。所以回写第十节时要照代码把那一段的字段逐项重写, +不是把数字改对——数字对上的那天,这笔欠账就再也没人看得见了。 diff --git a/research-wiki/design/0016-action-executor-failure.md b/research-wiki/design/0016-action-executor-failure.md new file mode 100644 index 0000000..5f4759c --- /dev/null +++ b/research-wiki/design/0016-action-executor-failure.md @@ -0,0 +1,181 @@ +# Design 0016 · 动作执行接缝抛异常时的契约 + +**日期** 2026-08-26 · **状态** 已接受(2026-08-26 项目负责人确认) + +**回答** 实验室 Gitea 上 PolyLoop 仓库的 issue #2,标题是「ActionExecutor 的契约没说抛异常时 +会怎样,而库这一侧不捕获」,提出者是下游项目 dissect2。 + +**补充** `0007-seam-behaviour.md` 决策一。那一条定的是三个动作状态各自在什么条件下被赋上, +说的全是协议之内的事;本文往下定协议之外那条路——实现方不返回结果、直接抛出时会怎样。 +`0007` 的其余部分不受影响。 + +**触及** `../../src/polyloop/ports/__init__.py` 里 `ActionExecutor` 的 docstring、 +`../../tests/contract/` 里动作执行接缝那一份,以及 `../../tests/unit/test_session.py`。要回写的 +是决策一那三条正面表述:docstring 补上「环境故障走返回值、抛出的异常库不接管」,契约那份补 +一条不带断言的说明,指向库这一侧真正断言它的地方,而那条断言本身加在 +`tests/unit/test_session.py` 里(见文末)。**不回写这三处,本文就是死的**——写适配器的人读的是 +`ActionExecutor` 的 docstring,而它现在通篇不提异常。 + +## 背景 + +issue #2 指出的事实逐条核过都成立。 + +`ActionExecutor` 的 docstring 只写了一件事:动作本身报错算「已执行」,不算环境故障。那句话 +管的是**返回值**里状态那一列该填什么。实现方如果干脆不返回、直接抛出,那句话一个字都没覆盖。 +`session` 里调用执行器的那一行外面也确实没有 `try`:`_Driver._execute` 写完动作意图就 `await` +执行器,拿到 `ActionOutcome` 往下走,异常原样穿出去。 + +对照之下,`DecisionParser` 的 docstring 明写了「不许抛异常」,并且写清了真抛了库也不接管、 +以及为什么不接管。同一个模块里的两个接缝,一个把这件事写死了,另一个从没提过。所以这不是 +「写得不够细」,是一处真实的契约空白。 + +issue 给了两条路,提出者倾向第二条: + +- 在契约里明写「不许抛异常」,环境故障一律走返回值; +- 库捕获执行器抛出的异常,把它转成 `ActionStatus.ENV_ERROR`,这样那一步的步记录和这次运行 + 的结束记录一定会落地。 + +倾向第二条的理由是:它对「日志一定自洽」这件事的保证更硬。 + +契约按第一条定,并写得比 issue 那句话更精确。第二条被否,理由在决策二。 + +## 决策一:环境故障走返回值,异常穿出不被库接管 + +契约的正面表述有三条。 + +环境自己坏了——连不上、协议不对、开好的会话没了——执行器返回 `ActionStatus.ENV_ERROR`, +不要以异常表达。 + +实现方真的抛出了异常,库不捕获,异常原样穿出 `run()` 与 `resume()`。调用方拿到的是那个异常 +本身,不是一个正常返回的运行结果。 + +`asyncio.CancelledError` 必须原样穿过,不许捕获吞没。这一条来自 `../../CLAUDE.md` §1.6,对 +每一个执行器实现都成立,契约套件里已经有它的断言。这里重复一遍,是因为上一条读快了容易读成 +「异常一概不用管」,而取消恰恰是那个必须管的异常——管的方式是让它穿过去,并且在 `finally` +里把 in-flight 资源放掉。 + +这条契约划出的责任分界是:**把可预期的环境异常翻译成 `ENV_ERROR` 是适配器的正常工作,不是 +catch-all**。一个 HTTP 客户端的连接超时、一个容器会话的「会话已关闭」,适配器知道这些异常长 +什么样,也知道它们意味着环境不能接着服务了,捕获它们并返回 `ENV_ERROR` 是在履行契约。剩下 +的那些——实现方自己都没预料到的异常——是 bug,让它穿出去。 + +**这条契约拦不住存心的实现方,代价认下来。** 一个执行器完全可以在自己的 `execute` 外面套一层 +`except Exception: return ENV_ERROR`,产生的效果和被否掉的方案二一模一样,只是发生在下游而不是 +库里。库这一侧分不出这两者——它收到的都是一个填好了状态的 `ActionOutcome`,看不出那个状态是 +判出来的还是兜出来的,也没有任何机器手段能拦。 + +**这样仍然比方案二好,差的不是能不能拦住,是谁知道自己做了这个选择。** 下游那么写,是它在 +自己的仓库里、对自己的数据做的一次决定;它知道自己这么做了,出问题时它查得到那一行。库那么 +写,是替所有下游做了同一个决定,而且没有任何一个下游有机会知道——它们只会看到一批 +`ENV_ERROR`,然后去查环境,查一个根本没坏的环境。所以契约保证的不是「不可能被绕过」,是 +**默认行为是对的**:一个照着契约写、没有多套一层的实现,它的 bug 不会被伪装成环境故障。 + +## 决策二:为什么不采纳「库捕获并转成 `ENV_ERROR`」 + +### 第一层:「日志不自洽」这个前提本身不成立 + +执行器抛异常时,日志里的状态是明确的:这一步的模型调用意图有、模型调用结果有、动作意图有、 +步记录没有。 + +`_recovery` 判一次执行处在哪一态时只看两个维度。「一次执行」指一次模型调用,或者一次动作 +执行;两个维度是「它的意图写进日志了没有」与「它的结果写进日志了没有」。四种组合各有一个 +含义(`0002-step-level-resume.md` 决策二): + +| 意图 | 结果 | 含义 | 恢复做什么 | +|---|---|---|---| +| 无 | 无 | 还没开始 | 重跑这一步 | +| 有 | 有 | 执行完了 | 跳过 | +| 有 | 无 | 状态未知 | 按那条意图上记着的重放策略决定 | +| 无 | 有 | 结构上说不通 | 判为日志损坏,拒绝续跑 | + +执行器抛异常落在第三行。`plan_resume` 按那条动作意图上记着的重放策略分岔:声明为「可安全 +重放」的给出 `ResumeAction.REPLAY_LAST_ACTION`,续跑时重新解释那条已经存下来的模型回复,再 +执行一次动作;否则给出 `ResumeAction.STOP_UNKNOWN`,`resume()` 拿着它走收尾,以 +`StopReason.RESUME_STATE_UNKNOWN` 结束这次运行。 + +那条重放策略的值来自**工具规格**——给库注册一个工具时连同名字、描述、参数 schema 一起声明的 +那份说明,其中一项就是「这个工具重复执行一次是否无害」。有一类动作问不出规格:模型输出的是 +一整段代码而不是一次工具调用,没有工具名可查。这一类一律取「绝不重放」。 + +这不是误判。执行器抛异常之后,副作用到底发生没发生本来就是未知的——异常可能来自环境返回的 +错误,也可能来自适配器在拿到结果之后的一行代码,从库这一侧看不出区别。这和进程崩在动作执行 +中途是同一种状态,也正是四态表那一档要描述的东西。日志现在记的就是事实。 + +### 第二层:库替它写一条步记录反而是在编造 + +`StepCompleted` 有一条构造期不变量:`result_id` 为空当且仅当 `action_outcome` 也为空。要给 +出异常的这一步写一条步记录,就必须同时编一个 `ActionOutcome` 出来——状态、观察、完成信号、 +截断字符数,四个字段全都是库现造的,没有一个来自执行器。 + +而一条带着动作结果的完整步记录,恢复读到的是「上一步走完了」,于是接着往下跑,那个未知状态 +就被抹掉了。这比不写更糟:不写只是少一条记录,日志停在「意图有、结果无」,恢复照实判成未知; +写了是把「不知道」改写成「知道,而且是这个值」,而后面每一步都建立在这个值上。 + +### 第三层:它把实现方的 bug 伪装成环境故障,然后送进下游的统计 + +执行器里一个 `AttributeError` 会被转成 `ActionStatus.ENV_ERROR`,这次运行以 +`StopReason.ENV_ERROR` 正常收尾,`run()` 返回一个正常的运行结果,调用方拿不到任何异常。一个 +包装类的 bug 就这样变成了「这批实验里若干次运行环境故障」。 + +这正是 `DecisionParser` 那段「不许抛」论证反对的事:库接住一个不属于协议的异常,就得给它编 +一个停止原因,而任何一个编出来的原因都会把实现的 bug 伪装成「这次运行以某某原因结束」。提 +issue 的下游自己也说了不想要「环境真的故障」和「我们的包装类有 bug」在数据里分不开,而方案二 +正好造成这个后果。 + +## 决策三:模型调用接缝捕获、动作执行接缝不捕获,这个不对称从哪来 + +`_call_model` 里捕获了 `Exception`,写一条失败的模型调用结果记录,然后以 +`StopReason.LLM_ERROR` 收尾;`_execute` 里什么都不捕获。同一份代码里两个接缝待遇相反,这不是 +疏忽,有两条理由。 + +**两个接缝的契约对「失败怎么表达」的规定正好相反。** `ModelClient` 的契约规定失败**必须**以 +异常表达,不许返回一个内容为空的正常回复——后者会让库没有任何办法把基础设施故障和「模型真的 +回了空字符串」分开,而这两者在分析里属于完全不同的类别。所以库捕获 `ModelClient` 抛出的东西, +处理的是**协议内的正常路径**:那个异常就是契约规定的失败表达方式。`ActionExecutor` 的契约 +规定环境故障**必须**以 `ENV_ERROR` 返回值表达,于是抛异常落在协议之外,库不接管。 + +**库能不能确定地知道这一步该结算成什么。** 模型调用抛出异常意味着没拿到回复,这一点是确定的: +本库要为这一步结算的问题是「有没有一条模型回复可用」,而这个问题有确定答案。所以库写下一条 +失败的模型调用结果,记的是事实,恢复读到它也不会再去猜。钱花没花是 PolyGateway 那一层的账, +不是本库要结算的东西,而且「花了钱却没有留痕」这件事不会发生——那条 `ModelCallResult` 一定会 +落地,`failure` 字段说明这次失败的形态,对账要用的东西都在那儿。 + +动作执行抛出异常意味着环境状态未知。副作用发生了没有、发生了多少,库无从知道,日志里也没有 +任何一处记着,所以库没有资格替它结算成任何一个具体的值。不写恰好是照实:日志停在「动作意图 +有、步记录无」,恢复照四态表把它读成未知。 + +## 决策四:提 issue 的下游实际要做的事 + +他们的硬需求有两条:环境故障那一步也必须记,因为那一步的模型调用已经成功、钱已经花了、账已经 +在网关那边记了;以及丢掉那一步会连带丢掉模型在出故障那一步说了什么。 + +在返回 `ENV_ERROR` 那条路上,这个需求已经满足了。`_loop` 拿到执行结果之后**无条件**写一条 +步记录,然后才做完成判定并以 `StopReason.ENV_ERROR` 收尾——写步在前、判停在后,环境故障那一步 +和别的步一样有完整记录。 + +在抛异常那条路上,这一步的步记录确实没有,但账目没有丢:`_call_model` 是在拿到回复之后立刻 +写模型调用结果记录的,写完才返回,而执行器要等解释完决策之后才被调用。所以异常抛出时,那条 +模型调用结果早已经在日志里,「模型说了什么」从 `RunLog.model_results` 里读得到。丢的只是那一 +步的步记录,而步记录本来就是「这一步走完了」的标记。 + +他们担心的另一件事——「要在自己的包装类里写 catch-all,而我们的协作约定禁止这种形状」——也 +不成立。捕获具体的环境异常(连接失败、超时、会话已关闭)并返回 `ENV_ERROR`,捕获的是有名有姓 +的几个异常类型,这不是 catch-all,`../../CLAUDE.md` §1.7 禁的是吞掉错误,而这里错误没有被吞: +它变成了一个明确的状态值,还带着给模型看的观察文本。 + +## 留给后续的 + +**`ModelClient` 抛出的实现方 bug 会被记成 `StopReason.LLM_ERROR`。** 适配器里的一个 +`AttributeError` 和模型网关真的连不上,在日志里长得一模一样。这是既定设计的已知代价,暂不 +动:那条路的正确性建立在「库能确定地知道这一步该结算成什么」上,不是建立在「能分辨 bug 和真 +故障」上。要改的话得先想清楚库凭什么区分这两者,而不是在 `_call_model` 里多加几个 `except` +分支。 + +**契约套件验不了「实现方在环境故障时返回 `ENV_ERROR` 而不是抛异常」。** 这套件面对的是一个 +任意实现,没有办法逼它进入环境故障——真去把它的网络掐掉既不可移植,也会把那些根本没有网络的 +合法实现判成不合格。这和三个状态的触发条件验不到那一层是同一个原因,那条已经作为一段不带 +断言的说明留在动作执行接缝的契约文件里,本文这一条按同样的口径写。 + +能验的是库这一侧,也必须验:执行器抛出一个普通异常时,那个异常穿出 `run()`,且日志停在 +「动作意图有、步记录无」。这条断言落在 `../../tests/unit/test_session.py`——那里现在还没有它, +是本文落地时要加的。