30 Commits

Author SHA1 Message Date
iomgaa 622b17f90c chore(release): 定版 1.0.3
CHANGELOG 的「未发布」段落定成 1.0.3(2026-08-29),上面留一个空段给下一版接着攒;两处版本号
(pyproject.toml 与 polyloop/__init__.py)同步。

发之前跑了一轮完整压测,与 1.0.2 那次验收逐项可比:正常负载 400 次运行、3501 次真实模型调用,
十一条不变量全部通过、零击穿、零无法判定;九类故障注入 58 条判据全部通过、零击穿,剩下那条
无法判定与 1.0.2 是同一条。产物在 tools/soak/runs/v1.0.3/(该目录在 gitignore 里)。

第一次开跑那轮作废:模型中转连返 20 次 503 打开了网关熔断,400 次运行全部快速失败以 llm_error
收尾,整轮 181 秒「跑完」。正常负载那一轮的记分板不开 --allow-undetermined,当场拦下、退出码
非零——一次什么都没验到的跑没有冒充通过,那道闸是对的。产物留在 v1.0.3-aborted-503/。

README 的三条安装命令不用改:约束是 polyloop==1.0.*,1.0.3 落在里面。这意味着钉了那个约束的
下游一次例行升级就会拿到这一版,而这一版有一次破坏性的行为变更(绑定里不带前缀的 session_id
与 parent_call_id 从静默转发变成抛 ValueError)——眼下没有下游在用,所以实际影响为零,
CHANGELOG 那一节把迁移写法写清楚了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:19:30 -04:00
iomgaa c0d9d7b66e docs: 回写 CHANGELOG、GovDoc 迁移,以及压测那条过期注释
CHANGELOG 落在「未发布」段,按那一段自己的规矩不提前写版本号。写清了四条会抛 ValueError 的
情形与迁移写法,因为这是一次破坏性变更。

migrations/govdoc-saas.md 加一节讲租户命名空间怎么传,含迁完算不算数的四条判据,最终判据是
「同一段文本由两个租户各提交一次,各自拿到自己的那份输出」。参数一律指向 0017 不复述。这份
文档原来说 GovDoc 侧「还没有可迁移的东西」,现在有了第一条能逐条验的接入动作,文件头那句
状态说明跟着补了一句例外。

tools/soak/run_soak.py 那份空绑定上方的注释在复述旧转发规则,顺手改对。它给的理由本来就不准
——让绑定留空的真正原因是绑定的全部键值都进参数快照,每批都不同的值会让故障注入那一步续跑时
报一次假的参数漂移,和转发哪些键无关。三份压测绑定常量里都没有裸键,所以这次变更打不到压测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:20 -04:00
iomgaa fd9cae7da5 fix(adapters): 转发改按 gateway. 前缀,四条防御各自报错
落实 0017。模块级白名单换成三个常量:保留前缀、结构性参数拒绝集合、历史裸键元组。
_forwarded_binding 按排序后的键遍历(多个键同时违规时报出来的总是同一个),带前缀的剥掉前缀
当关键字参数名,不带前缀的照旧不传也不报错。

**这是一次破坏性的行为变更**:绑定里不带前缀的 session_id 与 parent_call_id 从静默转发变成抛
ValueError,错误信息里给出 gateway.session_id 这个改法。静默不传是又一次静默的行为变更——
下游的网关遥测会悄悄不再按会话分组而没有任何提示;不设弃用期是因为那要求这一版继续按旧机制
转发,等于把要拆的撞名机制再留一个版本。

空值那条防御拒的是「空串或纯空白」,不只是空串。这一条是 Codex 对抗审查抓出来的:空白在网关
那边是真值,会被原样当成命名空间用,于是所有拿到这份坏配置的租户共用同一格,正是 issue #6
那个跨租户串读场景换了个入口。判据是这个取值带不带信息,不是格式对不对——本库不解释绑定的
取值,值原样转发不做 strip,"acme:v1:tenant:" 这种少了一截的它拦不住也不该拦。

四条判断都在 chat() 的实参求值阶段完成,所以出错那次调用一次都没发出去,每条用例都断言了
替身的 calls 为空。空值那条用例是 2 个参数名 × 3 种取值的参数化——独立审查指出单参数版本
钉不住「对所有带前缀的键一视同仁」:把实现写成只认 cache_namespace 也照样绿,而那个错实现下
gateway.cache_salt="" 会被转发成一次读到缓存的调用。

371 passed / 16 skipped,六条 import 契约全 KEPT。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:00 -04:00
iomgaa d2be742383 docs(design): 落定网关转发改用保留前缀的方案
回答 GovDoc-SaaS 提的 issue #6,取代 0012 决策四。那一条的结论(只转发 session_id 与
parent_call_id)和它的理由(网关只有两个槽位)都不成立:0012 定于 2026-08-10,而依赖下界
polygateway>=1.1 里 registry 上唯一装得到的 1.1.1 发布于 08-06,那一版的 chat() 上已经有四个
str | None 的槽位。被挡在外面的 cache_namespace 恰恰是上游指定的租户隔离手段,挡掉之后两个
租户提交相同文本时,第二个会读到第一个的模型输出。

要换掉的不是那份白名单的取值,是「哪些键往下传由下游取的名字和网关取的名字偶然相同来决定」
这个机制——补成五个键只是把同一个陷阱重新上好膛。改成绑定里带 gateway. 前缀的键才转发,前缀
之后整段当 chat() 的关键字参数名。由此本库不必列举网关能接受哪些坐标维度,依赖下界照旧
>=1.1,<2,上游此后加维度也不用本库发版(限于取值是字符串的维度,绑定装不下别的类型)。

issue 给的三条路都不采纳,理由分别在决策一到决策三;meta 这一维不做,写在决策五。四条防御
(结构性参数、空串或纯空白、前缀后无参数名、不带前缀的两个历史裸键)都在 call() 里抛
ValueError,表现为一次「第 0 步就以 LLM_ERROR 收尾」的运行。

过了两轮硕士生冷读。第二轮抓出一处事实错误:初稿跟着 issue 写了「下游自己写的 ModelClient
契约套件覆盖不到」,而 polyloop.testing.ModelClientContract 1.0.2 起就随包发布,继承它就能跑,
取消传播恰恰是它覆盖的五条之一。背景那一段换成了成立的那条代价——两份网关适配器各自漂移。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:52:37 -04:00
iomgaa 72cfba5996 docs(guides): 补上验 sdist 那条命令的完整形状,以及漏掉 --no-build-isolation 的坑
第八步原来只说「sdist 要单独取,加 --no-binary :all:」,没给完整命令。照着上一条 pip
download 拼出来的那条**跑不通**,这是发 1.0.2 时实测撞上的。

原因是 --no-binary :all: 让 pip 取 sdist 之后会去构建它的元数据,而构建要先装
build-system.requires 里那个 setuptools——而 --index-url 已经把索引整个换成了这个 registry,
那儿只有 polyloop。报出来的是「Failed to build 'polyloop' when installing build
dependencies」,读起来像刚传上去的包坏了,实际上包好好的、坏的是命令的索引配置。这个失败
形态指向错误的方向,所以值得单独记一段。

--no-build-isolation 用当前 conda 环境里已经装着的 setuptools(dev 那组的 build 带着它),
不去索引找。跳过构建隔离不影响这一步的判据:这里验的是 registry 上那份 sdist 的内容,不是
「下游能不能从源码构建它」——真要验后者,索引那一项得写成 --extra-index-url。

补进去的命令逐字实测过,PKG-INFO 9566 字符,与文档里写的数字一致。
2026-08-27 05:44:54 -04:00
iomgaa d833b7a4d9 chore(release): 定版 1.0.2
CHANGELOG 的「未发布」段落定成 1.0.2(2026-08-27),上面留一个空段给下一版接着攒;
两处版本号(pyproject.toml 与 polyloop/__init__.py)同步。

发之前跑了一轮完整压测,与 1.0.1 那次验收逐项可比:正常负载 197 次运行、1845 次真实模型
调用,十一条不变量全部通过零击穿;九类故障注入 58 条判据全部通过、零 failed。库本身没有被
压出 bug。产物在 tools/soak/runs/v1.0.2/(该目录在 gitignore 里)。

README 的三条安装命令不用改:约束是 polyloop==1.0.*,1.0.2 落在里面。这同时意味着钉了那个
约束的下游一次例行升级就会拿到这一版,而这一版有一处行为变化(参数快照里注入内容的键形状)
——眼下没有下游在用,所以实际影响为零,CHANGELOG 里那一节把失败形态写清楚了。
2026-08-27 05:29:28 -04:00
iomgaa c4e5732587 docs: 回写全仓库对契约套件的指向,以及 README、架构与 CHANGELOG
套件从 tests/contract/ 搬进 polyloop.testing 之后,全仓库 28 处引用要重新指过。修了 12 处,
其余在 design/(只增不改)与 scratch/(由人清理)里。

**CLAUDE.md 改了四处事实**:§0 权威表里行为契约的权威、§0 那句依赖规则的条数、§5 目录树与
模块数、§1.8 那句「谁断言公共 Protocol 的签名」。§1 的其余硬约束与 §2 的人类门一条没动。

**architecture.md**:分层图第 4 层加一格,装配层从三个变四个;代码地图加一行;第十节按代码
逐项重写——那笔「工具段渲染样式」的欠账**没有被数字对上盖掉**,加了 fingerprints 之后请求
的字段数恰好还是十一,而组成已经换过,所以那一节正面写着它仍然欠着;新增第十条依赖规则
(pytest 只在 testing 那个 extra 里,别处 import 它会让下游的生产环境一 import 本库就
ModuleNotFoundError),带静态与运行时两半;删掉「src/ 下一行代码都没有」那段过期状态说明;
决策索引补齐 0008 到 0016,其中四行原描述说的不是那份文档真正定的东西。

**migrations/dissect.md** 那笔「内存实现不存在」的欠账还掉了。

**压测那边**三条测试守的是一条已经撤销的公共契约,改名并写清它们现在守的是场景自己的选择。
AppWorld 那处刻意的偏离(不补三个反引号)留着不恢复——那条路径要模型输出被 stop 序列截断才
触发,而压测不配 stop 序列,恢复的收益不抵重跑一次压测的成本。但注释的理由改对了:它现在是
一笔有出处的欠账,不是一个决定。

CHANGELOG 攒在「未发布」段,版本号不提前写(§1.10)。
2026-08-27 03:59:39 -04:00
iomgaa 1fac387e75 feat(testing): 契约套件搬进 polyloop.testing 随包发布,五套全部接上实现
tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个
下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在
site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名
fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。

**搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用
records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。
五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。

所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法
换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边
永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各
接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」,
判据是下游拿不拿得到。

**一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个
准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的
长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意
不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」
统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。

**发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里,
默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西
会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有
包文件),要装真 wheel 才验得出来。
2026-08-27 03:59:16 -04:00
iomgaa 3492a2994a feat(session): 参数快照补上配方指纹与注入通道,并写定执行器抛异常的契约
四件事,都来自第一个下游的 issue。

**RunRequest 新增 fingerprints。** 上下文与注入内容刻意不进快照(它们是数据不是参数),
而这条规则把生成它们的提示词模板也一起挡在外面了。具体的失败场景:换一份模板续跑不报错,
前几步用 A 模板、后几步用 B,那次运行的数据已经废了却没有任何提示。键形状
request.fingerprint.<name>,和 model_binding 那个坐标分开——一个是「这次运行属于哪一格」,
一个是「用的配方是哪一版」,混在一个字段里事后分不开。默认空映射时一个键都不写,所以已有
配置算出来的快照逐字节不变。

**注入的通道维度不再拍平。** 一通道一键,于是「声明了通道但一条都没选中」和「压根没有这个
通道」分得开:前者是值为空串的键,后者是键不存在。有一档实验要比较的正是这两种情形。

**ActionExecutor 的 docstring 写定抛异常时会怎样**:环境故障走 ENV_ERROR 返回值,实现方真
抛了库不接管、异常原样穿出。理由在 design 0016;简言之库替它编一个结算结果就是在编造,而
副作用状态在那一刻是未知的。

**三个映射字段在构造期冻成只读。** 对抗审查发现 frozen=True 不禁止改字段里那个 dict,于是
构造期那道「快照取值必须是字符串」的校验能被绕过去:构造完往 fingerprints 里塞一个整数,
它一路进日志,要到续跑读日志时才炸——而那时这次运行已经完整跑过一遍。复用 tools 里已有的
_frozen,没另写一套。
2026-08-27 03:58:49 -04:00
iomgaa 92785eb3e1 feat(stores): 补上易失存储实现,stores 拆成四个文件
design 0003 的否决方案一节承诺过「显式命名的、明确不提供恢复的内存实现」,那个实现一直
没写,migrations/dissect.md 还专门登记着「不要照那句话去找一个不存在的类」。

**名字取 VolatileRunStore 不取「不提供恢复」**:一个真的读不回自己写过的东西的存储过不了
自家的契约套件(套件第一条要的就是「写进去的意图读得回来」),而这一层的准入标准就是那套
套件。一个过不了自家准入标准的实现不该存在。它不提供的是跨进程恢复,Volatile 说的正是这
件事。

桶里存编码后的载荷、读的时候才解码,两个方向的别名都堵上:调用方写完再改自己手里那个 dict
改不到日志,读回来的日志被就地改动也污染不了存储本身。落盘那个实现每次重新解析文件,天然
如此,这个实现靠同一条路径对齐它。**写入只编码不解码**——落盘那边写的时候只做 json.dumps,
一条字段类型不对的记录写得进去、读的时候才炸,两边现在一致。

两张平行的表(记录类→标签、标签→解码器)合成一张三元组再派生视图。加上易失实现要用的第三
张视图之后,三张表之间那个谁也不检查的一致性要求就不可能被违反了。
2026-08-27 03:58:29 -04:00
iomgaa e701563a0b docs(design): 落定第一个下游提的五个缺口的三份方案
0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。
三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置
知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。

0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志
「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」
正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后
它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到
任何异常。
2026-08-27 03:58:08 -04:00
iomgaa e10f024c9b docs: 撤掉阶段清单的自毁条款
原话是「开发结束后把本节删掉即可」。六个阶段现在都走完了,而这份清单没有变成过期条文——
它记的是每个阶段各自的验收标准与结论,那是「当初凭什么算完成」的答案,删掉就没有别处
记着了。留着它不需要维护:勾完就不再动。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:31:49 -04:00
iomgaa 1b8e8367f9 docs: ⑥ 验收完成,两半都交付了
压测那一半的结果写进阶段清单:193 次运行、1743 次真实调用、十一条不变量全过,九类故障
注入 58 条判据零击穿。库本身没有被压出 bug——压出来的四个问题全在压测这一侧,各自的
commit 里写了是怎么发现的。

AppWorld 那一路的步数分布与 dissect 已有的 937 条真实轨迹基本重合,那是「同一个 benchmark
换个内核驱动、轨迹形状没变」的证据。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:57:06 -04:00
iomgaa 0752723c3c docs(explanation): 写自造压测负载这份常青文档,过了一轮硕士生冷读
讲这套压测是什么、九类故障各守什么、判据为什么全是结构不变量、以及怎么重跑。具体数字
一个都不写——那些是一次实测的结果,放进常青文档等于种下过期条文。

冷读抓到的九条全部采纳:产物那段自相矛盾(谁写了那四个文件)、GovDoc 三个阶段全文一个字
没有而后面两节都建立在它上面、判据的权威出现两个说法、六个核心概念没解释就在用、哨兵那个
例子整句读不懂、四处只写结论没写理由、重跑那节缺环境前提、故障注入为什么没有自己的报告、
以及一步没写出来的因果。

冷读同时报了三段跳读,按它给的事实处置:脱敏那节压掉后半段、九类列表重排成只读粗体也拿得到
每类守什么、打包那两段压成一段。

更新触发点写在文首:这套压测是一次性工具,验收阶段过去之后如果它被删掉,这份文档跟着删——
一份描述已经不存在的目录的常青文档,比没有更坏。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:54:15 -04:00
iomgaa ff59457482 fix(soak): 记分板的真空成立与停止原因覆盖,两处都会把「什么都没验」显示成绿
Codex 报了两条数据不足时真空成立的不变量,顺着同类找齐了七条:零步的「步号连续」与
「动作结果与步记录一致」、不足两步的「提示词字符数单调不减」、零意图的「意图都有归宿」、
零载荷的「步记录的内部不变量」、零记录的「不串台」、零行的「日志能被读回来」。同一类
缺陷改一半,剩下那一半照样会在某天把一次什么都没验的跑显示成绿。

各条的数据下限不一样,反直觉的三处写进了说明:「步记录的内部不变量」数的是打着标签的行
不是解出来的记录(违反配对的行本来就解不出记录,按记录数当下限会把它最该判的对象数漏);
「动作结果与步记录一致」不要求那条步记录带动作结果;「不串台」两半各判各的,合成一个的话
一半的真空会被另一半的绿盖住。

「停止原因与轨迹自洽」原本只覆盖四个取值、另外六个直接放行——不是数据不足,是判据本来就
该覆盖而没覆盖,后果和真空成立一样。六个都补了规矩,llm_error 那条按库自己的判据写
(解析失败必定带说明,模型调用失败那一步压根没走到解释器,只看有没有动作结果分不开这两者)。
另加一条断言十个取值一个不漏,将来加了取值而这里没跟上会显式报「还没有规矩」。

「提示词字符数单调不减」的说明原本承诺「历史只追加」,实现只比较库自己记录的数——承诺了
它,读者看见绿就以为截断被排除了。改成只承诺它验得到的,真正的对账在故障注入那侧。

拿 193 次真实运行重跑:十一条仍然全过,而这次那 8 次解析失败连击、1 次模型调用失败、
1 次撞步数上限是被真规矩判过的。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:53:56 -04:00
iomgaa 5a4d13b0f2 fix(soak): 按 Codex 对抗审查加固故障注入的判据
九类实跑全过之后让 Codex 专门找「判据其实验不到它声称要验的东西」,它报的每条都带具体
失败场景。这次的绿不是假的——实跑数据里这些判据都有料可判——但它们在别的输入下会假绿。

真空成立三处改成无法判定:空日志的「全都解得开」、零步的「没有 env_error」、前后都空的
「审计账没变」。上一版实跑里最后那条正是 0 vs 0 通过的。

「环境是在完整一步之后坏的」原本只有通过和无法判定两档,没有击穿分支——那条判的是注入器
自己,它对外宣称在第 N 次执行之后动手,整类故障的结论都建立在这句话上。

提示词那两条原本只读库写进日志的数,而这套东西反复强调不能拿库的自述验库的行为——它在
自己的核心主张上破了例。现在包一层模型客户端记下每次真正发出去的消息字符数,跑完逐步
对账。字符数口径与库的算法对拍过,不然会全程假击穿。

绝不重放那条补了两个角度:崩溃时账上那几条续跑之后要逐行原样还在(旧条目被改写、被抹掉
原来一路绿灯),以及续跑段里不许出现与崩溃前完全相同的条目。Codex 提的「同一文件不同
内容」在 b 档已被条数判据挡住,a 档不能加同样的规则——那一档续跑本来就该接着跑,模型
再写一次是正常行为,加了会变成随机红。

取消补了环境侧的静默判据与事件文件完整性。容器里已经发出去的执行在客户端计数上不留痕,
这个盲区如实写进已知缺口,没假装验到;崩溃形态只能落在写入调用返回之后,同样记下来。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:53:00 -04:00
iomgaa e590bea70e fix(soak): 环境客户端把传输层失败也翻成 AppWorldError,「连不上」那一档原本没实现
execute 的 docstring 一直写着「只有环境自己坏了——连不上、HTTP 非 2xx、返回体不是约定
形状——才抛 AppWorldError」,但实现里只处理了后两档。连不上、读超时、连接中途断掉时,
httpx 抛的是它自己的异常类型,而上层的动作执行接缝只认 AppWorldError,于是容器一挂整次
运行会以一个未捕获的第三方异常炸出去,正确的行为是记成环境故障、由库合成一段观察、以
env_error 收尾。

这个缺口是压测里真把容器 docker kill 掉之后才暴露的——在那之前它只写在 docstring 里。
顺带覆盖了另一个一直没验过的路径:客户端读超时同属这一类。

CancelledError 继承 BaseException,不在 httpx.HTTPError 的范围内,取消照旧原样穿过去。

修完重跑那一类,七条判据仍然全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:16:08 -04:00
iomgaa 67f0d355d0 feat(soak): 补上 context_overflow 与 env_error 两类,七类变九类
一次 193 个运行的全量跑完之后,停止原因的十个取值里有两个一次都没出现过。没出现不等于
它们是对的,只等于没验过——这正是「全绿要先怀疑负载」该指向的地方。

context_overflow 的判据不能照字面写成「最后一步的提示词超过上限」:规模判定在调模型之前
做,命中时不产生步记录,所以落盘的每条步记录必定不超上限,那样断言等于断言契约的反面。
改成判「再走一步会有多大」,公式拿全量里 865 对相邻步验过,0 处不符。

env_error 是真把容器 docker kill 掉,不是用测试替身。它自己起一个池、用另一个端口——
共用那个 size=1 的池的话,排在它后面的每一类都会跑在一个不存在的环境上。

实跑:两类都通过,击穿 0、无法判定 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:14:04 -04:00
iomgaa 5d5371792d fix(soak): 父子两侧的崩溃条件对齐,并给自杀留一个窗口
上一轮实跑里确定性自杀一次都没走到——父进程一看见日志尾部形态对上就发信号,而自杀条件
比它严一档(还要求审计账非空),于是外部信号永远抢先,自杀路径成了死代码,崩溃点又变回
碰运气。表现是崩得太早:动作还没执行过,最硬的那条判据没有实料可判。

两处对齐:父进程的命中条件也要求审计账非空(做成必传参数,给默认值等于让某个调用点静默
跳过这一条,而这正是这次出问题的方式);兜底 SIGKILL 之前先等两秒看子进程是不是自己以
137 退出。

实跑结果:两档都走确定性自杀路径,都崩在「写入执行过之后」,绝不重放那条判据在两档都有
实料——审计账 1 条、去重后仍 1 条,续跑前后也都是 1 条。整套七类击穿 0 条、无法判定 0 条。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:41:27 -04:00
iomgaa a641a1fc11 fix(soak): 合成观察那两档不该逐字比对,判据写错了
193 次真实运行报了 9 处击穿,核下来是判据的错不是库的错:动作被拒绝或环境故障时,库
刻意不把执行器给的观察回填进历史,而是换成合成观察那一段。两个字段承载的本来就不是同
一件事——一个是执行器原文的留痕,一个是真正喂给模型的文本。

改判据时顺着源码发现被替换的是三列不是一列:观察文本、是否合成、截断字符数在那两档下
全部由库填。所以旧判据在环境故障那一档上必然也会误报,只是这 193 次里没撞上真的环境
故障;截断数那一列则是恰好两边都是 0,潜伏着没炸。

现在 executed 档三列仍然逐字比对,另两档改成断言步记录的「是否合成」标记确实立起来了——
库既然替换了观察,不立这个标记才是真出了问题。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:35:25 -04:00
iomgaa fe33faa3ec fix(soak): 崩溃改成子进程内部的确定性自杀,外部 SIGKILL 抢不到那个窗口
第一次实跑的结果:时机 A(一步完整落地之后崩)三次都没命中,每次都是「发信号与子进程停笔
之间又写进了记录」。原因是库写完步记录之后紧接着就写下一条意图,中间只有内存计算,窗口
窄到外部信号挤不进去。这个观察本身留在模块 docstring 里——它说明自然崩溃几乎总是落在
「有意图没结果」那一态上。

改成在子进程里包一层存储,在写入落盘返回之后按条件调 os._exit(137)。os._exit 不跑
finally、不跑 atexit、不 flush,对磁盘的效果与 SIGKILL 等价,而 JsonlRunStore 本来就
写完即 fsync,没有未刷缓冲要指望退出时替它写。外部 SIGKILL 那条路降级成兜底,没有删。

自杀条件都要求工作区审计账已经非空,即那个声明绝不重放的写入真的执行过。上一次实跑里
最硬的那条判据(绝不重放的动作没被执行两次)报的是无法判定,就是因为崩得太早、审计账
是空的,去重比对真空成立——判定器诚实地报了无法判定而不是绿,现在给它补上实料。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:20:01 -04:00
iomgaa 43971573b7 feat(soak): 故障注入——崩溃续跑、取消、撞预算、解析失败连击
这七类是压测真正要看的东西:一百个任务顺利跑完什么都证明不了,能证明东西的是这些没有
失败现场的路径上库有没有守住承诺。

判据一条都不依赖模型的确定性,全是结构不变量。最硬的那条是「声明绝不重放的动作没有被
执行两次」——证据取自环境侧自己记的账(工作区的审计文件),不取库报的步数或动作数,
后者是库对自己行为的陈述,拿它验库的行为就是我们和我们自己对账。

崩溃是真 SIGKILL 子进程,不是模拟的注入点,而且分两种时机:一步完整落地之后崩(续跑应
该真的接着跑),以及意图落盘、结果还没落盘时崩(那条意图声明绝不重放,库应该判定状态
未知、干净停下)。第二种的命中条件是「最后一条意图的重放策略是 never」而不是「最后一条
是意图」——只读工具声明的是 safe,悬在那种意图上续跑会重放接着走,判据会时对时错,而错
的那几次看起来只像模型走了别的路。

判定和记分板一样分三档,「无法判定」不折算成通过:审计账为空时「去重前后条数相等」是真
空成立的,报成通过等于把「什么都没验到」显示成绿。命中不了时机也报无法判定,不降级成
另一种时机假装验过。

调用数护栏按每类故障的边界拦,不在模型客户端里抛异常——在那里抛的话库会把它记成模型调用
失败、合成观察接着跑,护栏本身就成了一次注入进来的故障,把要验的停止原因搅乱了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:05:05 -04:00
iomgaa a426cbbd21 feat(soak): 压测入口——预算护栏、并发上限、干跑,以及记分板要的四个产物
--budget-calls 与 --concurrency 都没有默认值:一个能跑飞的压测入口迟早会跑飞。达到调用
上限时停止派发新任务,但已经在跑的让它跑完——半路砍断会制造一批没有结束记录的日志,而
那和崩溃长得一模一样,会污染故障注入那边的判定。

--dry-run 不打模型,只把任务集列出来、把每个 RunRequest 真的装配一遍,确认容器起得来、
语料读得进、脱敏闸过得了。它也刻意不往 runs-dir 写东西,写了的话紧接着的全量会撞上
RunIdentityError。

两个场景同时跑时任务轮流排开而不是拼接:共用一份预算,拼接的话排在前面的场景会把预算
吃光,而报告看起来只是「因预算停在第 N 个任务」——一次只压了一半的跑长得像一次正常的跑。

测试里最有价值的一条是真的把产物喂给记分板:手工搭两个 GovDoc 任务共六次运行(真的走
polyloop.session.run,模型是写死的替身),再 import 记分板判定,验十一条不变量全绿。
两边的 sidecar 约定对不上的话这条会当场红。

实跑过一次干跑:两道 AppWorld 题各装配出 12.4k 字符上下文,GovDoc 两个任务六次运行全部
装配成功,脱敏替换 64 处,跑完零残留容器。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:02:53 -04:00
iomgaa 10f4a49f37 fix(soak): 给 plan 与 execute 补上完成通路,它们原本必然跑满预算
打真实模型跑通一个完整任务时发现的:这两个阶段的工具集里没有带完成标记的工具,解释器
也从不产出最终回答,于是模型在第 4 步写完产出物之后还在继续读文档,直到撞上步数上限。
按原来的 50/50/16 算,二十个任务要两千三百多次调用,而且整批的停止原因会全是
step_budget,别的什么都压不出来。

解释器加一条最终回答支路({"final_answer": "..."}),plan 与 execute 的提示词写死收尾
动作;summarize 不变,仍然只能靠 submit_finding 结束。这样三条完成路径同时在场:模型
自报最终回答、agent 调用带完成标记的工具、环境报告完成(AppWorld 那路),它们的可信度
各不相同,压测正需要这个对照。

预算随之下调到 20/25/16——实测每阶段真实用 5 到 7 步,留了一倍余量。原来那个 50 的来历
是 gov-auditor.yaml 的 turns 上限,但那边靠编排校验产物落盘来收阶段,跑满 turns 无所谓;
这里靠模型自己收尾,上限定高只会让它在产出物写完之后接着白烧。

实测:plan 7 步 agent_finished、execute 6 步 agent_finished、summarize 5 步
task_completed,一个任务 18 次调用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:58:57 -04:00
iomgaa f277197071 feat(soak): GovDoc 形态的场景——JSON 工具调用、按次收窄、提交型完成
这条路径至今零真实负载,而它和 AppWorld 那路压的东西完全不同:动作是 JSON 工具调用而
不是代码,完成由 agent 自报(带完成标记的提交工具)而不是环境报告,工具集按阶段收窄。
一个任务拆成三次运行(plan / execute / summarize),因为「按次收窄工具集」在这个库里
只能这么表达——治理单位是一次运行,一次运行只有一个工具注册表,阶段之间靠工作区传状态。

语料是真实公文,但先脱敏再用,且原文与脱敏文本都只在内存里,一个字节不落盘、不入库。
机构名、电话、信用代码、邮箱、联系人姓名换成明显是假的稳定假名(同一原名整份文档换成
同一假名,否则模型会以为是不同主体);金额、项目编号、日期原样保留,它们是审核判断的
依据,换掉任务就没得判了。脱敏后必须过一遍独立的检出校验,有残留就拒绝启动——把未脱敏
的第三方真实信息发给外部模型服务是不可逆的,而漏一处的表现是「压测正常跑完」。

实测:主招标文件 17.5 万字符,校验函数对原文报 61 处、对脱敏后放行,六份语料全过,
40 个金额与全部日期无误伤。

write_note 与 submit_finding 声明为绝不重放,每次调用在工作区留一行审计——那份审计是
环境侧的实际执行次数证据,故障注入要验的「绝不重放的动作没有被执行两次」数的就是它。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:36:58 -04:00
iomgaa 292a936d42 feat(soak): 记分板——十一条不变量逐条判定,任一被击穿整批失败
判定分三档:通过 / 击穿 / 无法判定。第三档不许折算成通过——缺文件、缺字段导致判不了,
和判过了是两回事,混起来会让一次什么都没验的跑看起来全绿。退出码 1 是击穿,2 是只有
无法判定,--allow-undetermined 只放行后者。

守的东西:日志读得回来、跨进程的结果与内存里逐字段相等、步号连续、意图都有归宿(至多
一条悬空且必须在末尾,那正是崩溃点)、动作结果与步记录说同一件事、提示词字符数单调不
减、事件条数等于本进程走完的步数、投递失败计数对得上、并发之间不串台、停止原因与轨迹
自洽。

报告里不出现模型原文、观察、工具名与文档片段——压测语料里有第三方的真实文档,而报告
是要贴给人看的。有四条测试拿哨兵字符串验它确实漏不出去,其中一条是拿恶意工具名试出来
的:原本打算按字符白名单放行工具名,白名单恰好把哨兵放了过去。

每条不变量都配一个「构造出违反它的日志、验它确实报击穿」的用例——一个永远返回通过的
判定器比没有判定器更糟。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:27:09 -04:00
iomgaa 21a9a30ee9 feat(soak): AppWorld 场景适配器——代码围栏解释器与容器执行器
复刻 dissect 的动作协议当压测负载:两条正则、first_only 策略、五条纠错说明逐字照抄,
提示词模板连同出处一起收进 tools/soak/prompts/。有两处刻意不同,都写在代码注释里——
未闭合围栏那一支不补回三反引号(公共契约要求回填历史的文本不长于模型原文),空围栏
那一支不截断。

执行器在 execute 内部顺带问一次 is_done 填 env_reported_completion,并把环境自己数的
执行次数透出来。那个数字是故障注入的判据来源:验「声明绝不重放的动作没有被执行两次」
必须数环境侧,数库自己的计数器等于我们和我们自己对账。

端到端真跑过一道题(82e2fac_1):8 步 task_completed,环境判分通过,事件数与步数与
环境执行次数三者相等,耗时 30 秒。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:16:31 -04:00
iomgaa 4cbdb056b6 feat(soak): 压测的环境层——AppWorld 容器池与薄 HTTP 客户端
⑥ 的验收要自己造负载压,环境用 AppWorld:733 个任务与 197M 数据都在本机、docker 镜像已
拉好、而且它自带评测端点做程序化判分——换成让另一个模型判对错,等于往验收里再塞一个非
确定源,验收本身就不可复现了。

**不 import dissect**(§1.2 禁止反向 import),照它那两个文件当协议文档自己写了一份。
副作用是这反而更强:证明一个不认识 dissect 的第三方只用公共 API 就能驱动真实环境。

复刻了 dissect 记下的几个坑:httpx 必须 trust_env=False,否则本机代理会把 127.0.0.1 的
请求也劫走、表现成 502;健康检查失败先抓容器日志再删;关闭失败按端口分开计数,用全局
计数器的话坏容器的失败会被别的容器的成功清零。

放在仓库根的 tools/ 下而不是 tests/ 下:打包只收 src/,所以它不进 wheel 也不进 sdist
(实测两个产物里 soak 命中数都是 0);而 §1.9 的四层是按「依赖什么」分的,压测不属于其中
任何一层,塞进 tests/ 要么破坏分层要么和 e2e 共用同一道花钱的闸。Makefile 的检查目标
跟着加上 tools/。

冒烟真跑通:起容器、初始化真题、跨调用保持变量、错误代码返回 traceback 而不抛异常、
评测、无容器残留;另验 2 容器并发的租借与归还。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 07:38:08 -04:00
iomgaa 71e7762814 docs(migrations): 撤掉一张空头支票——那个「不提供恢复的内存实现」不存在
0003 的否决方案那一节定过要给一个明确命名的、不提供恢复的存储实现,好让「我不要恢复」
成为一次看得见的选择。那个实现至今没写,polyloop.stores 里只有 JsonlRunStore,而
dissect 那份迁移文档一直在叫人去装配它。

调研 dissect 迁移面时撞出来的。对 dissect 不构成阻塞(它本来就要恢复能力),但一份指着
不存在的类的迁移指引,读的人会先怀疑自己装错了包。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:46:01 -04:00
iomgaa aa2cc46ef8 docs: ⑥ 的验收标准换成自造负载 + 迁移方案交付
原来写的是「真的把 dissect 迁过来、以它原有测试全绿为准」,两个前提都不成立了:
GovDoc-SaaS 的实现已经整体清空,没有东西可迁;dissect 正在 Phase-1 中间,而且它的方案
该由那边自己排期,不该由本库直接改它的代码。

换成两件:自己照三个消费者将来的用法造约一百个任务的真实负载压一遍——这一步必须自己做,
因为三个消费者一个都还没到能用它的时候,而「从没被任何人用过」是它现在最大的未验证项;
以及把 dissect 的迁移方案写成一份提到它 issue 上的东西。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:32:51 -04:00
69 changed files with 20014 additions and 1049 deletions
+7
View File
@@ -13,6 +13,13 @@
/logs/
/tests/outputs/
# 压测产物。日志与报告里带着模型原文和文档片段,体量也大(一次全量约一百多个运行),
# 不该进版本库;`.cache/` 是从环境取一次就不变的那些(app 描述清单)。
/tools/soak/.cache/
/tools/soak/runs/
/tools/soak/reports/
/tools/soak/workspaces/
# Python
__pycache__/
*.py[cod]
+155
View File
@@ -9,6 +9,161 @@
## 未发布
**版本号在真的要发布的那一刻才定,这里不提前写。** 只 bump 版本号不叫发布(`CLAUDE.md`
§1.10):PolyGateway 的 1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry
长期停在 1.0.5,下游 `pip install` 拿不到任何修复且无人发现。提前把号写进这一段就是在重演
那个形态——读到号的人会以为那一版已经在 registry 上,而它不在。
## 1.0.32026-08-29
回应 GovDoc-SaaS 提的 issue #6:网关适配器的转发白名单把 `cache_namespace` 挡在外面,而那是
PolyGateway 指定的租户隔离手段。
**这一版发出去之前跑了一轮完整压测**,与 1.0.2 那次验收逐项可比:正常负载 400 次运行、3501 次
真实模型调用,十一条不变量全部通过、零击穿、零无法判定;九类故障注入 58 条判据全部通过、零
击穿,剩下那一条无法判定与 1.0.2 那次是同一条(`cancel_env` 那次运行一条事件都没发出过,
事件文件的完整性无从判起)。库本身没有被压出 bug。产物在 `tools/soak/runs/v1.0.3/`
(该目录在 gitignore 里)。
第一次开跑那轮作废了,原因不在库:模型中转连返 20 次 503 打开了网关的熔断,其后每次调用都
快速失败,400 次运行全部以 `llm_error` 收尾。正常负载那一轮的记分板不开「允许无法判定」,
于是它当场拦下、退出码非零——一次什么都没验到的跑没有冒充通过。那批产物留在
`tools/soak/runs/v1.0.3-aborted-503/`
### 网关适配器改按保留前缀转发绑定,而不是按键名撞
**这是一次破坏性的行为变更。** 绑定里不带前缀的 `session_id``parent_call_id` 从「静默转发
给网关」变成「抛 `ValueError` 并给出改法」,改法是把键名写成 `gateway.session_id`
```python
model_binding={
"book": "b7", # 项目自己的坐标,不传
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace=...)
"gateway.tenant_id": "x7",
}
```
绑定里键名以 `gateway.` 开头的,前缀之后那一段当作网关 `chat()` 的关键字参数名,值原样传下去;
其余的键照旧不传、也不报错。**本库不再持有一份网关参数名单**——上游哪天再加一个字符串维度,
下游当天就能用,本库不发版,声明的依赖下界也不用跟着抬。
原来那份写死的白名单只认两个键,把 `cache_namespace` 挡在外面,而那是 PolyGateway 指定的租户
隔离手段:挡掉之后,两个租户提交内容相同的一段文字,第二个会读到第一个那次的模型输出。给出
那份白名单的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立——
当时装得到的最低版本上已经有四个。
四条会抛 `ValueError` 的情形:带前缀的键名指向 `messages``stream``structured``overlay`
之一(这些改变请求本身,另有权威);带前缀的键取值是空串或纯空白(在网关那边和「没传」分不
开,或者不带任何信息,而一个看着配了、实际没配的隔离比没配更糟);键恰好是 `gateway.`、前缀
后面没跟参数名;以及不带前缀的那两个历史裸键。
判断发生在把请求交给网关之前,所以出错的那次调用不会真的发出去。
转发的键照旧全部进参数快照,键名不变(`request.binding.gateway.cache_namespace`),所以换一个
命名空间续跑会照常撞参数漂移。
方案与被否掉的三条路见 `research-wiki/design/0017-gateway-forwarding.md`
## 1.0.22026-08-27
回应下游项目在 PolyLoop 仓库上提的五个 issue。
**这一版发出去之前跑了一轮完整压测**,与 1.0.1 那次验收逐项可比:正常负载 197 次运行、1845 次
真实模型调用,十一条不变量全部通过、零击穿;九类故障注入 58 条判据全部通过、零击穿。库本身
没有被压出 bug。
**升级时唯一会变的行为在参数快照**(见下面「注入内容的通道维度」那一节):用 1.0.1 跑了一半的
运行,升上来之后续跑会抛 `ParameterDriftError`,错误信息里逐个列出漂移的键。失败是响亮的,
不会静默跑出两段来自不同配置的轨迹。
### 契约套件随包发布
五个接缝的公共行为一致性用例从 `tests/contract/` 搬进 `polyloop.testing`,跟着 wheel 一起
装到下游去。**`pip install polyloop` 之后 site-packages 里没有 `tests/`**,所以在此之前那套
被称作「任何新适配器的准入标准」的用例,第一个下游根本拿不到。
**接法同时换了**:原来是在自己的 `conftest.py` 里覆盖同名 fixture,现在是继承契约基类。
```python
from polyloop.testing import RunStoreContract
class TestMyPostgresStore(RunStoreContract):
@pytest.fixture
def store(self, pg_pool): return MyPostgresStore(pg_pool)
```
换掉是因为覆盖同名 fixture 在装到 site-packages 之后无处落脚——pytest 的 `conftest.py` 只沿着
被收集文件的目录链往上找,而套件所在的那条链在 site-packages 里,看不见下游仓库里的
`conftest.py`。继承则不需要任何 `conftest.py` 魔法:子类定义在下游自己的测试文件里。顺带一个
接缝可以接多个实现,各写一个子类。
跑它要装 `polyloop[testing]`pytest 与 pytest-asyncio,版本只写下界,不和下游已经在用的
pytest 打架)。async 用例的事件循环归下游管:把 `asyncio_mode` 设成 `"auto"`,或者自己给子类
打标记。本包还声明了一个 pytest 插件入口,它唯一的作用是让 pytest 重写这些模块里的
`assert`,失败时打印出等号两边的实际值。
**基类名、基类上的 fixture 名、每一条用例的方法名从此是公共承诺**,改名的代价和改公共类型的
字段一样。
### 新增一个内存存储实现
`polyloop.stores.VolatileRunStore`:日志攒在进程内存里,进程一退就没了。它不提供的是**跨进程
恢复**,不是「读不回来」——同一个进程里写进去的意图照样读得回来,它和逐行追加那个实现跑的是
同一套存储契约。桶里存的是编码后的载荷、读的时候才解码,所以调用方后来改自己手里那个 dict
改不到已经写下去的快照,读回来的日志被就地改动也污染不了存储本身——落盘那个实现每次都重新
解析文件,天然如此,这个实现靠同一条路径对齐它。一条字段类型不对的记录在两个实现上的下场也
一样:写得进去,读的时候抛同一个解码错误。
对下游的意义是测试和「我不要跨进程恢复」那一档不必再自己写一个存储:存储接缝是必填的,
在此之前不想落盘的人只能自己造一个。关系数据库那种形态仍然由下游自己实现。
### `RunRequest` 新增 `fingerprints` 字段
`Mapping[str, str]`,默认空映射,**已有代码不受影响**。它记的是这次运行用的材料是哪一版——
提示词模板的哈希、技能库的版本这类——全部键值以 `request.fingerprint.<name>` 进参数快照,
不透传给模型调用。
它和 `model_binding` 的分界是「坐标还是配方版本」:绑定记这次运行属于哪一格(哪个账本、
第几轮、哪道题),指纹记这次用的材料是哪一版。**一条指纹都没有时快照里一个键都不写**,
所以今天已经在跑的配置算出来的快照逐字节不变。
同时补上一道校验:`fingerprints``model_binding` 的键值必须都是字符串,在构造请求时就拒绝
非字符串;五个接缝 `parameters()` 上报的键值在聚合成快照时同样校验。在此之前这类值要等到续跑
读日志的那一刻才炸——那时这次运行已经跑完、钱已经花了。
### ⚠ 参数快照里注入内容的键形状变了
**这是这一批里唯一一处会让已有行为变化的改动。** 注入的条目标识原来拍平成一个键
`request.injected_entry_ids`,现在按通道分组,一个通道一个键
`request.injected_entry_ids.<通道名>`
**用旧版本跑了一半的运行,升级之后 `resume` 会抛 `ParameterDriftError`。** 失败是响亮的,不是
静默的:错误信息把漂移的键逐个列出来,能看到旧的那个键消失、新的那几个键出现。处置有两条,
和快照里任何一项变了时一样——那次运行重新开始,或者接受它跑不完。跑完了的运行不受影响,
参数快照只在续跑时被比对。
改形状是因为拍平之后「声明了这个通道但一条都没选中」和「压根没有这个通道」得出同一个结果,
而有下游要比较的正是这两种情形。分组之后前者是一个值为空串的键,后者是这个键不存在。通道
之间按通道名字典序,通道内的顺序和贴进提示词的顺序一致。
**快照的键集合从来不是公共承诺**,所以这不是 `CLAUDE.md` §1.3 意义上的破坏性变更:下游换一个
存储实现、改一个接缝的 `parameters()` 返回什么,快照照样会变、续跑照样报漂移,这本来就是这
套设计的一部分。
### 动作执行接缝的契约补上「抛异常时会怎样」
`ActionExecutor` 的 docstring 原来只说了动作本身报错算「已执行」,实现方干脆不返回、直接抛出
时会怎样一个字都没写,而库这一侧不捕获。现在正面写清三条:环境自己坏了(连不上、协议不对、
会话没了)返回 `ActionStatus.ENV_ERROR`,不要以异常表达;真抛出来的异常库不捕获,原样穿出
`run()``resume()``asyncio.CancelledError` 必须原样穿过。
**行为没有变,变的是它被写下来了。** 库不把执行器抛出的异常转成 `ENV_ERROR`,是因为那样会
把适配器自己的 bug 伪装成环境故障送进下游的统计,而且要替这一步编一条步记录——那条记录会被
恢复读成「上一步走完了」,把一个真正未知的状态抹成一个具体的值。异常抛出时日志停在「动作
意图有、步记录无」,恢复照实把它读成状态未知。
把可预期的环境异常(连接超时、会话已关闭)捕获并返回 `ENV_ERROR` 是适配器的正常工作,不算
`CLAUDE.md` §1.7 禁的那种吞错误——错误没有被吞,它变成了一个明确的状态值。
## 1.0.12026-08-11
首个发布版本。十个模块全部落地,四层测试都在跑。
+7 -6
View File
@@ -17,8 +17,8 @@
| 这类事实 | 权威处 |
|---|---|
| **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` |
| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。条依赖规则里有两条落不进契约(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),它们`tests/unit/` 里的测试 |
| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 |
| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。条依赖规则里有两条落不进契约、还有一条只有一半落得进(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),落不进的那些`tests/unit/` 里的测试 |
| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `src/polyloop/testing/` 的公共契约套件,随包发布。它同时是任何新适配器的准入标准 |
| 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 |
| 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 |
| 已定的决策及其理由 | `research-wiki/design/` 下相关编号最大的那份 |
@@ -53,7 +53,7 @@
6. **`asyncio.CancelledError` 永不捕获吞没。** 取消要能穿过模型调用与环境执行,in-flight 资源在 `finally` 释放。吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声不吭。
7. **禁止吞掉错误**`except Exception: pass` 及其跨行形态)。由 ruff `S110` / `E722` 断言。
8. **测试绑行为,不绑实现。** 不写「断言某个内部类有哪些方法」这类测试——它只会让重构连坐。
**公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。
**公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `src/polyloop/testing/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。
**断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。
9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。
10. **发布 = 合并 + push + tag + 构建 + 上传 registry + 验证已发布。只 bump 版本号不叫发布。** 教训来自 PolyGateway1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。**那次的补救只写了文档、没有回补上传,所以那两个版本到今天仍然不在 registry 上**,而 dissect 的依赖恰好钉在那个空区间里、装不上。这说明记下教训不等于修好问题。完整步骤与全部已知的坑见 `research-wiki/guides/releasing.md`
@@ -122,10 +122,11 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。**
★ research-wiki/reference/ 查得到的事实:日志字段契约、遥测口径
(公共类型和枚举取值不在这里,权威见 §0 表格)
★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删)
★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0),
也是任何新适配器的准入标准
★ tests/contract/ 把库自带的实现接到契约套件上的那几个子类。套件本身不在这里
★ tests/e2e/ 打真实模型网关,会产生真实费用。默认不跑,两道闸见 .env.example
★ src/polyloop/ 库本体,十个模块
★ src/polyloop/ 库本体,十个模块
★ src/polyloop/testing/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0),
也是任何新适配器的准入标准。它随包发布,下游装了就拿得到
```
常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`
+4 -4
View File
@@ -11,15 +11,15 @@ install:
# lint 带 --fix 会改工作区,check 只读。CI 用 check,人手修用 lint。
lint:
$(RUN) ruff check src/ tests/ --fix
$(RUN) ruff check src/ tests/ tools/ --fix
$(RUN) lint-imports
format:
$(RUN) ruff format src/ tests/
$(RUN) ruff format src/ tests/ tools/
check:
$(RUN) ruff format --check src/ tests/
$(RUN) ruff check src/ tests/
$(RUN) ruff format --check src/ tests/ tools/
$(RUN) ruff check src/ tests/ tools/
$(RUN) lint-imports
test:
+34 -9
View File
@@ -25,6 +25,18 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
"polyloop[gateway]==1.0.*"
```
契约套件随包发布,它要的 pytest 也是一个单独的 extra
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polyloop[testing]==1.0.*"
```
**只有自己实现了某个接缝、要拿库这边的契约套件验它的时候才装这个。** 存储、模型调用、决策
解释、动作执行、事件出口五处允许换实现,谁换了谁就得证明自己那份还满足接缝的行为契约,而
证明的方式就是继承 `polyloop.testing` 里对应的基类跑一遍。只调 `run` / `resume`、五个接缝
全用库自带或别人写好的实现的项目,不需要它。
写进 `requirements.txt` 的话,那一行 `--extra-index-url` 必须排在 `polyloop` 之前。
**这台开发机上的注意事项**:它设了 `http_proxy` 指向一个到不了外面的本地代理,走代理会失败,
@@ -32,7 +44,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
## 现状
十个模块全部落地,四层测试都在跑。**还没有任何下游项目真的用过它**——这是它现在最大的未验证项,
个模块全部落地,四层测试都在跑。**还没有任何下游项目真的用过它**——这是它现在最大的未验证项,
下面的阶段清单是唯一的进度权威。
## 消费者与验收标准
@@ -50,8 +62,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
## 阶段清单
**这份清单是「当前处在哪个阶段」的唯一权威。** `CLAUDE.md` 不重复这里的内容,只有一条常青规则
(§1.11)要求动手前先看这里——这样阶段推进时不需要改 `CLAUDE.md`开发结束后把本节删掉即可,
不会在别处留下过期条文。
(§1.11)要求动手前先看这里——这样阶段推进时不需要改 `CLAUDE.md`不会在别处留下过期条文。
- [x] ① 协作规范 —— 见 [CLAUDE.md](CLAUDE.md) 与 [research-wiki/README.md](research-wiki/README.md)(文档体系)
- [x] ② 需求对齐 —— 从 dissect 的 `harness/agent/` 与 GovDoc-SaaS 的 `packages/docagent-core/`
@@ -62,12 +73,26 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点
- [x] ④ 测试框架 —— 四层都在跑(划分判据是「依赖什么」,见 [CLAUDE.md](CLAUDE.md) §1.9)。
e2e 打真实模型网关、会产生真实费用,所以默认不跑:要 `POLYLOOP_E2E=1` 加显式
`pytest -m e2e`,配置见 [.env.example](.env.example)。`tests/contract/` 那套公共行为
一致性用例接上了自带的存储实现,解释器与执行器那几条仍等下游把实现接进来
- [x] ⑤ 实现 —— 十个模块全部落地,五个接缝都有调用点。**一处已知欠账**:`stores` 只有逐行
追加那一种形态,关系数据库那种由下游自己实现,契约套件是它的准入标准
- [ ] ⑥ 迁移验收 —— 真的把 dissect 迁过来,以它原有测试全绿为准。GovDoc-SaaS 那半不是迁移
而是设计级验收(理由见上面那张表),口径在 `research-wiki/migrations/govdoc-saas.md`
`pytest -m e2e`,配置见 [.env.example](.env.example)。那套公共行为一致性用例住在
`polyloop.testing` 里随包发出去,五个接缝在本仓库都接上了实现跑起来,接点是
`tests/contract/``tests/integration/` 下那几个继承契约基类的子类
- [x] ⑤ 实现 —— 十一个模块全部落地,五个接缝都有调用点。`stores` 有两种形态:逐行追加进
本地文件的那个,和只留在进程内存里、进程一退就没了的那个。关系数据库那种仍然由下游
自己实现,契约套件是它的准入标准,而套件随包发布在 `polyloop.testing`
- [x] ⑥ 验收 —— 两件事都做完了。**一是自己造负载压**:照三个消费者将来的用法造负载,用真实
数据真的打模型跑完,看这个内核在这个量级上扛不扛得住。**这一步之所以必须自己做,是因为
三个消费者一个都还没到能用它的时候**,而「从没被任何人用过」是它当时最大的未验证项,
等下游是等不来的。**二是把 dissect 的迁移方案交出去**:不在 dissect 仓库里写代码,
出一份方案提到它的 issue 上,由那边自己排期。GovDoc-SaaS 那半是设计级验收(理由见上面
那张表),口径在 `research-wiki/migrations/govdoc-saas.md`
压测这套东西住在 `tools/soak/`,它守什么、怎么重跑见
[research-wiki/explanation/soak-harness.md](research-wiki/explanation/soak-harness.md)。
2026-08-11 那一跑:193 次运行、1743 次真实模型调用,两个场景各自的十一条不变量全部通过;
九类故障注入 58 条判据零击穿。**库本身没有被压出 bug**,压出来的四个问题全在压测这一侧
(判据写错、场景缺完成通路、崩溃时机抢不到、环境客户端漏了「连不上」那一档),各自的
commit 里写了是怎么发现的。AppWorld 那一路的步数分布与 dissect 已有的 937 条真实轨迹
基本重合,这是「同一个 benchmark 换个内核驱动、轨迹形状没变」的证据
## 本地检查
+39 -4
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polyloop"
# 与 src/polyloop/__init__.py 的 __version__ 必须一致,由 tests/unit/test_package.py 断言。
version = "1.0.1"
version = "1.0.3"
description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入"
# registry 的包页面正文只认这一项:缺了它页面就是一片空白,而 twine 只会警告
# long_description missing,不阻塞上传——三步全绿、产物是坏的(PolyGateway 1.1.2 的教训)。
@@ -21,6 +21,11 @@ dependencies = []
# 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同
# 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。
gateway = ["polygateway>=1.1,<2"]
# 契约套件(polyloop.testing)跑起来要的两个包。**版本只写下界,不钉死**——这和下面 dev 那组
# 正好相反,理由也正好相反:dev 是本仓库自己的工具链,钉死是为了本地和 CI 跑的是同一套;
# testing 装进的是下游自己的环境,钉死会和下游已经在用的 pytest 打架,而下游没有第二个
# 虚拟环境可以放我们钉的那一版。下次想「顺手统一一下」这两组的写法时,先看这段。
testing = ["pytest>=8", "pytest-asyncio>=0.24"]
# dev 工具链的版本必须钉死,不用下界。教训来自 CHSAnalyzer:本地 conda 是 ruff 0.15.1、
# CI 装到刚发布的 0.16.0,新版本多了几条规则,于是「本地全绿、CI 全红」。不钉的话 CI 会在
# 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。
@@ -43,6 +48,13 @@ Homepage = "https://gitea.iomgaa.online/iomgaa/PolyLoop"
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyLoop/src/branch/main/CHANGELOG.md"
Issues = "https://gitea.iomgaa.online/iomgaa/PolyLoop/issues"
# pytest 插件入口。它换回来的是**断言重写**:契约模块不在下游的 `python_files` 匹配范围里,
# 默认不被重写,于是一条契约用例失败时下游只看到光秃秃的 AssertionError,没有等号两边的值。
# 声明了 pytest11 入口的发行包,它的每个 .py 文件都会被 pytest 标记为可重写。
# 不做的话没有任何东西会报错——纯静默退化,理由与边界写在 polyloop/testing/_plugin.py 里。
[project.entry-points.pytest11]
polyloop = "polyloop.testing._plugin"
[tool.setuptools.packages.find]
where = ["src"]
@@ -94,13 +106,14 @@ markers = [
addopts = "--strict-markers --import-mode=importlib -m 'not e2e'"
# ---------------------------------------------------------------------------
# 依赖规则的机器形式。条规则本身与它们各自的理由在
# 依赖规则的机器形式。条规则本身与它们各自的理由在
# research-wiki/design/0003-public-api-shape.md 决策八,当前形状在
# research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。
#
# 条里有两条写不成 import-linter 契约,它们是 tests/ 里的测试:
# 条里有两条写不成 import-linter 契约,还有一条只有前半写得成;写不成的那些是 tests/ 里的测试:
# 规则 6types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。
# 规则 9import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。
# 规则 10 的后半(import polyloop 之后 sys.modules 里没有 pytest)——同上。前半在下面。
# ---------------------------------------------------------------------------
[tool.importlinter]
root_packages = ["polyloop"]
@@ -115,7 +128,7 @@ include_external_packages = true
name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import"
type = "layers"
layers = [
"polyloop.session | polyloop.stores | polyloop.adapters",
"polyloop.session | polyloop.stores | polyloop.adapters | polyloop.testing",
"polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization",
"polyloop.ports",
"polyloop.types",
@@ -131,6 +144,7 @@ forbidden_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.adapters",
"polyloop.testing",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
@@ -153,6 +167,7 @@ type = "forbidden"
source_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.testing",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
@@ -163,6 +178,26 @@ source_modules = [
]
forbidden_modules = ["polygateway"]
# 规则 10 的前半。契约套件是 polyloop 里唯一允许碰 pytest 的地方,形状照着上面那条 polygateway 写。
# 别处 import pytest 的后果是每个装了本库的下游都被迫装上 pytest 才 import 得动 polyloop
# 而 pytest 只在 testing 这个 extra 里,核心的 dependencies 是空的。
[[tool.importlinter.contracts]]
name = "除 testing 外一切禁止 import pytest"
type = "forbidden"
source_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.adapters",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
"polyloop._recovery",
"polyloop.serialization",
"polyloop.ports",
"polyloop.types",
]
forbidden_modules = ["pytest"]
# 规则 7。这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3 都能绕过它,
# 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒
# (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。
@@ -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/` 里的时候缺口就在;变化的是它现在是对外的准入标准,而一条准入
标准没查的事,下游有理由认为不需要查。
补进契约要单独决定,不搭这批的车:那是往一份已经发出去的准入标准里加一条更严的用例,一个
原本合格的下游实现会在升级之后变红,而它自己什么都没改。「留给后续的」那一节记的版本协商
问题正是这个形态。
@@ -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.<name>`——单数,与已有的 `request.binding.<key>` 对齐。
**默认空映射时快照里一个键都不写**,不是写一个值为空串的键。这样今天已经在跑的配置算出来的
快照逐字节不变,只有真的传了指纹的运行才多出那几项。
### 为什么需要它
上下文的正文与注入条目的正文是刻意不进快照的,理由是它们属于这次运行的输入**数据**而不是
参数:进快照会让快照变成一份数据副本,而它们可能很大。
**注入这一侧不进快照的只有正文,条目的标识是进的。** 正文和标识是两样东西:正文是贴给模型
看的那段文本,标识是这条注入的名字。所以「这次贴了哪几条」事后查得到,查不到的只是那几条各自
写了什么。上下文那一侧没有对应的标识可以留,它是一段已经渲染好的消息序列,本身不带名字。
把正文挡在外面这条理由没错,但它顺手把**生成这些数据的东西**也挡在了外面。提示词模板不是
数据,是参数——它是一份跨运行复用的配方,每次运行拿它渲染出这一次的上下文。
提 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:<hex>`。理由是换算法的那天,不带前缀的旧记录和新记录会以
「两个不同的十六进制串」的形式参与比对,报出来的漂移看不出是换了算法还是内容真的变了;带前
缀则一眼看得出。
这是建议,不是校验。库不解释这个值,也就没有立场规定它长什么样——真去校验,等于替下游定了它
能用哪几种哈希。
### 业界怎么用 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.<key>``request.fingerprint.<name>`
`request.injected_entry_ids.<通道名>` 三处的后半截都是下游给的自由字符串,库不校验它们的形状。
现在三个前缀互不相同,所以撞不了;再往快照里加一个带自由后缀的前缀时,要重新检查这件事。
**`0003` 决策三的请求字段表有一笔没做的欠账,而它即将被盖掉。** 那张表里有「工具段渲染
样式」这一项,`architecture.md` 第十节跟着写请求持有十一样数据;代码里 `RunRequest` 只有十个
字段,搜不到任何对应物。这是那张表里唯一一笔有表无码的欠账。`0014` 处理的另外两笔不在这张
表里:承诺过的内存存储实现来自 `0003` 否决方案那一节,契约套件发不出去则和 `0003` 无关。
危险不在这处漂移本身,在于它即将被盖住:加上 `fingerprints` 之后请求的字段数恰好变成十一,
第十节那个数字会重新对上,而组成完全不同。所以回写第十节时要照代码把那一段的字段逐项重写,
不是把数字改对——数字对上的那天,这笔欠账就再也没人看得见了。
@@ -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`——那里现在还没有它,
是本文落地时要加的。
@@ -0,0 +1,345 @@
# Design 0017 · 网关适配器往下传哪些键
**日期** 2026-08-29 · **状态** 已接受(2026-08-29 项目负责人确认)
**回答** 实验室 Gitea 上 PolyLoop 仓库的 issue #6,标题是「转发白名单挡掉了 cache_namespace
而上游指定它做租户隔离」,提出者是下游项目 GovDoc-SaaS。
**取代** `0012-gateway-model-client.md` 决策四。那一条的结论(只传两个键)与它的理由(网关
只有两个槽位)都不再成立,理由见背景。`0012` 的其余五条决策不受影响。
**触及** `../../src/polyloop/adapters/__init__.py`
`../../tests/integration/test_gateway_model_client.py``../../CHANGELOG.md`,以及
`../../research-wiki/migrations/govdoc-saas.md`。逐处要改什么在文末的回写清单。
## 读本文需要的几个名字
**接缝** 是本库留给下游替换实现的接口点,一律是 `Protocol`。模型调用接缝
`polyloop.ports.ModelClient`)只有两个方法:发一次调用,以及上报自己的可复现参数。本文讲的
`GatewayModelClient` 是这个接缝的一个实现,作用是把本库的一次调用翻译成 PolyGateway 的一次
治理调用。
**装配分成两半。** 跨运行不变、可以并发复用的那一半是 `AgentDefinition`,四个接缝挂在它上面,
模型调用接缝是其中之一。随每次运行变的那一半是 `RunRequest`,预算、工具、上下文、绑定挂在
它上面。这条分界在决策三里是判据。
**绑定** 是下游项目自己的坐标,库不解释内容、原样透传给模型调用接缝。它在 `RunRequest` 上的
字段名是 `model_binding`,类型 `Mapping[str, str]`,到了模型调用接缝手上叫 `ModelCall.binding`
**它进参数快照时的键前缀是 `request.binding.`,不是字段名那个 `model_binding`**——同一样东西
在三处有三个写法。
**参数快照** 是一次运行开始时算出来、写进运行开始记录的一份字符串到字符串的映射,回答的是
「这次运行是什么设置」。它汇的是绑定与配方指纹这两个请求字段,加上向四个接缝各问一次
`parameters()` 得到的结果。**配方指纹**是挂在运行请求上的另一份字符串到字符串的映射,记的是
这次运行用的材料是哪一版——提示词模板的哈希、技能库的版本这类;它和绑定的分界是「坐标还是
配方版本」。**续跑**指的是一次运行崩了或被打断之后,用同一个运行标识接着往下跑;开工前会
重算一次快照并与日志里那份逐字段比对,对不上就抛 `ParameterDriftError` 中止,免得跑出一条
前后来自两套配置的轨迹。
**`parameters()`** 是接缝上那个上报可复现参数的方法。模型调用接缝这一侧,`GatewayModelClient`
报的是 scope 与源列表。**scope** 是网关里一组模型源的命名分组,也是网关的治理单位——限流、
熔断、缓存的账都按 scope 记。**源**是这个分组里的一个具体端点,一个源就是「供应商 + 地址 +
模型名」那一组,多个源可以指向同一个模型。这两样合起来回答的是**这次运行用的是哪个模型、
打到哪儿、带着哪些恒定采样参数**,这一整份叫「模型身份」。它进快照时的键前缀是
`model_client.`
网关 `chat()` 签名上的几个参数:
- **`cache_namespace`** 是缓存键的一段。缓存键由「模型指纹 + 消息摘要 + 命名空间」构成,
命名空间不同的两次调用读不到彼此的缓存。**模型指纹**是网关自己算给缓存键用的那一份,按
模型名去重;它和上面那个模型身份是两侧的两样东西,模型身份更严——改一个源名也要报出来。
**不传不等于不缓存**:网关取值的写法是「本次调用给了就用本次的,没给就用网关装配时配的
那个默认命名空间」,所以不传是「和所有人共用一格」。
- **`cache_salt`** 是同一命名空间内再分一层的字符串。给同一批消息换一个盐,本该命中的调用
就会打空、真的重新去问模型——需要同一批输入跑多轮且每轮都要真实调用的下游用得上它。
- **`tenant_id`** 与 **`meta`** 是网关 1.3.0 起新增的两个调用方自定义维度,只进它的遥测、
不进缓存键。前者是遥测表里的真实列(可挂行级安全、可进复合索引),后者是任意键值容器。
- **`overlay`** 是采样参数覆盖层(`temperature``seed``max_tokens` 这类),优先级高于配置
里那份恒定采样参数。**`structured`** 要求模型按一个给定的结构返回,网关会为此改写请求并在
返回前校验、必要时重试。
## 背景
issue #6 指出的事实逐条核过都成立。
**适配器只往 `chat()` 传两个键。** `adapters/__init__.py` 里那份模块级白名单是
`("session_id", "parent_call_id")`:绑定里键名与它们相同的那些被当作同名关键字参数传给
`chat()`,其余的键留在参数快照里、不往下传。
**给出的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立。**
`0012` 定于 2026-08-10`pyproject.toml` 声明的依赖下界是 `polygateway>=1.1,<2`,而 1.1.x 里
registry 上唯一存在的版本 1.1.1 发布于 2026-08-06——也就是说定 `0012` 的那天,装得到的最低
版本上就已经有四个 `str | None` 的槽位:`session_id``parent_call_id``cache_salt`
`cache_namespace`。(1.1.0 有过版本号但从未上传,装不到,这正是 `CLAUDE.md` §1.10 记的那个
教训。)所以这不是一条随上游演进而过期的判断,是当时就核错了。1.3.0 上又多了 `tenant_id`
`meta`1.1.1 上没有这两个。
**被挡掉的 `cache_namespace` 正是上游指定的租户隔离手段。** 网关自己把这件事写死了两处:
网关装配时启用了缓存却没配命名空间,它直接拒绝装配,报错原文是「缓存 key 靠它做租户
隔离」;`tenant_id` 的 docstring 明写它不进缓存键,租户隔离由 `cache_namespace` 负责。上游
指派了一个机制来守这条边界,而经过本库之后那个机制传不进去。
失败场景具体:两个租户提交了内容相同的一段文字,两次调用的模型指纹、消息摘要、命名空间三项
全部相同,于是第二个租户读到第一个租户那次的模型输出。这不是缓存效率变差,是跨租户读到别人
的内容。这一条是本次判断里权重最大的那个事实。
**下游能绕过去,代价是多养一个适配器。** `ModelClient` 只有两个方法,下游自己写一个实现不
难,而且不会因此掉出机器兜底:本库随包发布的公共契约套件里就有模型调用接缝那一份
`polyloop.testing.ModelClientContract`),自己写的实现继承它、覆盖 `model_client`
`failing_call` 两个 fixture 就能跑。它守的是接缝层面的五条:返回三个字段、调用标识绝不为
空串、失败以异常表达、取消要能穿过模型调用且 `CancelledError` 不许被吞、签名里不出现重试与
限流参数——取消传播恰恰是套件覆盖了的那一条。
**套件覆盖不到的是翻译那一段。** 把本库的一次调用变成这个网关的一次调用——消息怎么拼、网关
抛的异常怎么原样穿出、调用标识怎么从返回里取、模型身份怎么算——是这个适配器特有的行为,不是
接缝对所有实现的承诺。所以任何一个自己写的网关适配器,在这部分没有机器兜底。
**真正的代价是两份适配器各自漂移**,这一条是提出者自己写的:他们会长期维护第二个网关适配器,
和本库自带的那个唯一区别是多传几个参数;两份会各自往前走,而漂移的那天不会有任何东西提示。
issue 提了三条可能的做法:补白名单、把白名单做成构造参数、把这几个值做成构造参数。三条都不
采纳。
## 决策一:转发不再按名字撞,改成绑定里的保留前缀
绑定里键名以 `gateway.` 开头的,前缀之后那一整段当作 `chat()` 的关键字参数名,值原样传下去。
其余的键照旧不传、不报错。
```python
model_binding={
"book": "b7", # 项目自己的坐标,不传
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace="acme:v1:tenant:x7")
"gateway.tenant_id": "x7",
}
```
**前缀之后不再解析,点号也算参数名的一部分。** `gateway.meta.foo` 交给 `chat()` 的参数名是
`meta.foo`,网关签名上没有这个名字,于是抛 `TypeError`。绑定这一侧不为点号编一层嵌套,理由
在决策五。
**其余的键不报错,这是一条有意划的线。** 本库不知道下游的坐标该叫什么,所以对它不认识的键
一律保持沉默——一个叫 `book` 的坐标不是一次写错了的转发。唯一的例外是 `session_id`
`parent_call_id`:这两个名字今天真的会被转发,对它们保持沉默等于静默改变行为,所以它们要
报错,改法见决策四第四条。
**要换掉的是「哪些键往下传」由两个命名空间的名字偶然相同来决定这件事。** 绑定这一侧的名字由
下游取,`chat()` 那一侧的名字由网关取,白名单让两边撞名的那些自动接通。这个机制在两个方向都
会坏:下游随手起一个叫 `tenant_id` 的坐标就被静默传下去;上游加一个参数,本库就得改一次
常量,而漏改的表现是「这个参数传不进去」——issue #6 就是这么来的。**issue 的第一条路(把白
名单从两个键补到五个)只改了那份常量的取值,没有改这个机制**,等于把同一个陷阱重新上好膛,
下一个新参数出现时会再来一次。
前缀让转发变成下游显式声明的:不写前缀就不会往下传,写了就说明是有意的。它的直接后果是本库
不必列举**网关能接受哪些坐标维度**,代码里也不必出现 `cache_namespace` 这个名字。本库手上
仍然有一份网关参数名的清单,但那是另一份东西——决策四第一条那份「不许从绑定走的结构性参数」,
四个名字,在依赖下界那一版上就已经存在,不随上游新增维度而增长。会随上游长的是坐标维度那
份名单,而那份追不动,被拿掉的正是它。由此得到三件事:
**依赖下界不用抬,靠的正是「不列举坐标维度」这一件事。** `tenant_id` 只在 1.3.0 之后存在,
本库若把它写进白名单常量,声明的下界就得从 `>=1.1` 抬到 `>=1.3`,于是每个下游都得跟着升
网关——包括那些根本不需要这个维度的。用前缀的话本库一个坐标维度的名字都没提,下界照旧
`>=1.1,<2`:网关支持的那个下游传得进去,装着旧网关的那个会拿到 `TypeError`,而错误信息里
带着参数名。上游此后再加维度也是同一个待遇,本库不发版。这条免疫只覆盖取值是字符串的新
维度:绑定的类型是 `Mapping[str, str]`,装不下别的,网关哪天加一个取 `int``bool` 或者
嵌套映射的参数,前缀这条路传不了它。这个限制可以接受——调用方坐标这一类维度天然是字符串
标识,租户、会话、命名空间、盐写出来都是名字;真出现一个非字符串的新维度,那是一次新的设计
(放宽绑定的取值类型,或者另开一条通道),不是这个机制上的一个漏洞。
**转发的键照旧全程进参数快照。** 键名不变,`gateway.cache_namespace` 在快照里是
`request.binding.gateway.cache_namespace`。所以「这次运行用的是哪个命名空间」事后查得到,
而换一个命名空间续跑会撞 `ParameterDriftError`——那正是最该炸的一次,因为续跑读到的可能是
另一个租户的缓存。
**升级不改变现存运行的行为。** 前缀今天不被任何东西解释,带前缀的键在今天只是一个普通坐标,
所以装上新版之后往网关传的东西不变。补白名单则相反:一个绑定里本来就有 `tenant_id` 的下游,
升级当天行为就变了,而快照里那一项一个字没改,续跑守卫也不会响——一次静默的行为变更,正是
本库最怕的形态。
**残留风险照实认下:今天真有一个叫 `gateway.<某某>` 的坐标的下游,升级后它会被往下传。**
这个风险以「装上就炸」的形态出现而不是静默扩散——网关不认得那个参数名会当场抛 `TypeError`
除非那个名字恰好也是网关的参数名,而那种巧合要同时撞中两层。
**前缀取 `gateway.` 而不是别的写法**,因为它说的正是「这个键是给网关的」,而读到它的人手上
拿的就是网关适配器。更不容易撞的写法(加下划线、加更长的限定串)换来的是每次都要多打几个字
和一个记不住的拼写,而撞名的代价上一段已经认下了。
**代价:`gateway.` 从此是绑定里的保留前缀。** 一个真的想用这个前缀当自己坐标的下游没法这么
取名了。接受,因为绑定的键名本来就由下游自己定,改一个名字的成本是一行。
## 决策二:为什么不是「白名单做成构造参数」
issue 的第二条路是让调用方决定转发哪些键。它没有解决决策一说的那件事——转发仍然按名字撞,
只是撞的范围可配。而且它多出一个后果:读一份参数快照不再能回答「网关那次收到了什么」,因为
答案同时取决于绑定和构造时那份配置,而后者不在快照里。把它也塞进 `parameters()` 能补上,但
那是为一个不必要的旋钮再加一层机制。
## 决策三:为什么不是「做成 `GatewayModelClient` 的构造参数」
issue 的第三条路(提出者自己最倾向的那条)是把这几个值直接做成适配器的构造参数。它和装配的
两半相冲,而且冲的地方正是这次要传的那个值。
`GatewayModelClient` 挂在 `AgentDefinition` 上,也就是跨运行不变、可以并发复用的那一半。租户
标识与租户命名空间恰恰**每次运行都可能不同**——一个多租户服务里,一次运行属于一个租户。把它
放到构造参数上,等于要求每个租户各装配一份定义,而定义那一半存在的理由就是它不随运行变。
本库已经有一条专门的每次运行通道,就是绑定。租户坐标是坐标,走坐标那条路。
(提出者自己也写了「跨运行基本不变,`tenant_id` 除外」。那个例外不是边角,它是这次需求的主体。)
## 决策四:四条防御
前缀让下游能把任意参数名传给 `chat()`,所以适配器要挡住几类明显不该这么传的东西。挡不住的
那些原样交给网关,由它抛 `TypeError`——错误信息里有参数名,比本库自己编一条更有用。
### 一、会改变请求本身的参数名一律拒绝
`messages``stream``structured``overlay` 四个。
**判据是「这个参数有没有一个已经存在的权威」,不是「它重不重要」。** 模型身份那份快照
`parameters()` 报的 scope 与源列表,含配置里的恒定采样参数)是「这次运行的模型行为是什么」
的权威。`overlay` 改的是同一件事,从绑定走等于让同一件事有两处记录,而其中一处不是权威——
`0012` 决策二把采样参数算进模型身份,为的就是「temperature 从 0 改成 1 之后续跑,模型的行为
变了而轨迹上看不出来」,从绑定塞 `overlay` 会把那道守卫从旁边绕过去。`structured` 会让网关
改写请求并按结构校验、必要时重试,`stream``messages` 决定发出去什么,同理。
**缓存维度(`cache_namespace`、`cache_salt`)不落在这一类,因为没有第二个地方记它们。**
它们改变的是「这次调用会不会真的发出去、读到谁的那一份结果」,而这件事在本库这边只有绑定
一处记录,逐字段进快照、逐字段守。所以它们是坐标,不是结构。
这份拒绝清单也会过期,但它过期的后果比今天那份软一档:漏掉一个新的结构性参数,表现是「该拦
的没拦住」,而且要下游主动写出那个名字才会发生;今天那份漏掉一个新参数,表现是「该传的传不
了」,下游什么都不做就中招。
### 二、取值是空串或纯空白时拒绝,带前缀的键一律如此
网关那边取命名空间的写法是「本次调用给了就用本次的,没给就用默认的」,而空串在 Python 里是
假值——所以 `gateway.cache_namespace=""` 会静默落回默认命名空间,也就是悄悄关掉隔离。绑定
那一侧的校验只管键和值都得是字符串,管不到空串。
**纯空白(`" "`、`"\t"`)比空串更坏,所以一起拒。** 空白在网关那边是真值,会被原样当成一个
命名空间用——于是隔离看起来成立,实际是所有拿到这份坏配置的租户共用同一格,而这正是本文要
修的那个跨租户串读场景换了个入口。空串至少还会落回默认命名空间那条众所周知的路,空白连这条
路都不走。
**判据是这个取值带不带信息,不是这个命名空间格式对不对。** 空串与纯空白都不带,所以拒得掉;
`"acme:v1:tenant:"`(租户标识拼空了)带信息但内容是错的,本库拦不住,也不该拦——它不解释绑定
的取值,一旦开始判断「什么样的命名空间算合法」,就等于替下游定了编码规则。这条线划在这里是
有意的,它挡的是「一个看着配了、实际什么都没配的隔离」,不是「配错了的隔离」。
**这一条只管带前缀的键。** 不带前缀的键根本到不了网关,它们的取值是下游自己的坐标,空不空
由下游自己判——库对它们唯一的要求是「键和值都得是字符串」,那一条在别处已经有了。
### 三、前缀后面没有参数名时拒绝
键恰好是 `gateway.` 时,剥掉前缀之后是一个空的参数名。**这一条不补任何漏洞**:真交下去,
`chat()` 的签名是纯关键字、没有 `**kwargs`,网关照样会抛 `TypeError`。它换掉的只是错误信息
——`TypeError: chat() got an unexpected keyword argument ''` 说不出问题出在绑定的哪个键上,
而这是一个纯粹的手滑,报错该直接指着那个键说「前缀后面要跟一个参数名」。
交给网关是默认,本库只在自己能给出明显更有用的信息时才拦。
### 四、不带前缀的 `session_id` 与 `parent_call_id` 拒绝,并在错误信息里给出改法
这两个键今天会被转发,前缀落地之后不再转发。直接静默不传是又一次静默的行为变更——下游的
网关遥测会悄悄不再按会话分组,而没有任何东西会提示。报错则要求它把键名改成
`gateway.session_id`,一行的事。这份清单只有历史遗留的那两个,此后永不增长——只有这两个键
曾经被真的转发过,所以需要迁移提示的名字集合是封闭的,不会有第三个。下一个 major 可以整条
删掉。
**不设弃用期(先警告一版、下一版再报错)**,因为弃用期要求这一版继续按旧机制转发这两个键,
也就是把决策一要拆掉的那个撞名机制再留一个版本;而警告是可以被忽略的,日志里多一行
`DeprecationWarning` 在一个跑批量运行的进程里没人会看见。用一次响亮的失败换掉一段没人读的
警告,代价是撞上的人要改一行,收益是这一版之后再没有第二套转发机制活着。
### 这四条报错时会发生什么
绑定要到一次模型调用发生时才到适配器手上,所以这四条只能在 `call()` 里判,判不到构造那一刻。
`session`——本库里驱动一次运行的那个模块,和网关那个 `session_id` 只是撞名——捕获模型
调用接缝抛出的任何异常,写一条带失败说明的结果记录、记一步、以 `StopReason.LLM_ERROR` 收尾。
也就是说这四条报的 `ValueError` 不会穿出 `run()`,它表现为一次「第 0 步就以模型调用失败
结束」的运行,失败说明里带着 `ValueError` 这个类名和出问题的那个键名。
**判断发生在把请求交给网关之前**,所以出错的那次调用一次都没发出去:转发出来的那份关键字
参数是 `chat()` 的实参,Python 求值完全部实参才进函数体。
**这个形态可以接受,因为绑定一次运行内不变**:错了就是第一次调用就错,不存在跑到一半才炸,
也没有任何预算被花掉。代价是这次运行在下游的统计里落在「模型调用失败」那一格而不是「配置
写错了」,要读失败说明才分得开——这一点和 `0016` 决策二说的那件事同族。那一条定的是:动作
执行接缝抛异常时库不捕获,也不替它编一个结算结果,因为异常抛出的那一刻副作用发生没发生是
未知的,库编一个出来就是把「不知道」改写成「知道,而且是这个值」。区别是那里库有得选(可以
不接管,让异常穿出去),这里没得选——模型调用接缝的异常怎么处置早就定死了,而适配器没有
更早的位置可以判。
## 决策五:`meta` 这一维不做
`meta` 的类型是 `Mapping[str, Any]`,而绑定是 `Mapping[str, str]`,装不下。要装下得二选一:
给绑定加一层嵌套编码(`gateway.meta.<key>` 这类),或者放宽绑定的取值类型。后者动的是公共
类型,而绑定被定成字符串映射是为了让它能逐字段进参数快照——`0006` 里那句「不透明对象是公共
签名上一个永久的洞」说的就是这件事。前者是为一条需求引入第二套解析规则。
提出者自己把这一维标成最低优先级,说明的用途是带一个自己的 trace 标识。那个用
`gateway.session_id` 就能带:网关那边 `session_id` 本来就是一个用来分组的字符串,而本库不往
里面填任何东西——按 `0012` 那句「项目想让网关按运行分组,把它要的那个键放进绑定里」,这个
槽位一直是留给下游的。
不做 `meta` 是一次决定,不是漏掉的一维。提出者明确要求「如果你们认为这不该由库来做,请在
issue 里说一声」,所以 issue #6 的回复里要写上这一条。
## 公共契约套件不动
转发是这个适配器的行为,不是 `ModelClient` 这个接缝对所有实现的承诺——另一个下游写的实现接的
可能根本不是这个网关,对它断言 `gateway.` 前缀没有意义。
真正的缺口在别处:一个下游被逼着自己写网关适配器时,它重写的正是套件覆盖不到的那一段翻译
逻辑,而两份适配器此后会各自漂移。这一版之后那个前提没了——需要租户隔离的下游不必再自己写
实现,留在自带适配器上就能把命名空间传下去,而自带适配器的转发行为由 `tests/integration/`
里的用例钉着。**缺口是靠「消除下游离开这条路的理由」补上的,不是靠给套件加一条断言。**
**issue #6 末尾那个附带请求已经满足了。** 提出者问的是「能不能让准入契约套件也能套在自己写
`ModelClient` 实现上」——`polyloop.testing.ModelClientContract` 就是它,1.0.2 起随包发布,
继承它、覆盖两个 fixture 即可。
## 版本:1.0.3
`session_id` 从「静默转发」变成「报错」是一次破坏性的行为变更,按语义化版本的字面它不该
落在补丁号上。号是项目负责人拍的板,没有附理由;下面三条是这个选择为什么可以承受:
- 旧行为本身是缺陷。按名字撞的转发从来没有被任何人显式选择过,它是 `0012` 那个核错的前提留下
来的;
- 没有已发布的消费者依赖它。两个下游都在重建中,本库 1.0.2 发布于 2026-08-27,两天前;
- 失败是响亮的。撞上的人拿到一条带迁移写法的 `ValueError`,不是一次静默的行为改变。
**次版本号按同样这三条一样安全**,而且不和语义化版本的字面打架,所以 1.1.0 是一个真的选项。
取舍在别的地方:这次改动没有给库添任何新能力,它修的是一条本来就写错了的转发规则,用次版本
号会把一次修缺陷宣传成一次加特性;而读到次版本号的人更容易判断「这一版我可以先不升」,可
这一版恰恰是每个多租户消费者都该升的。落在补丁号是拍板的结果,上面那三条与这段取舍都是事后补的论证。
**残留风险照实认下**:万一有本次不知道的消费者正在依赖裸键转发,它会在一次补丁升级上撞到
`ValueError`。用报错而不是静默不传,就是为了让这个风险以「装上就炸」而不是「跑了三个月才
发现遥测一直没分组」的形态出现。
## 回写清单
**不回写下面这几处,本文就是死的**——写适配器的人读的是 docstring,而它现在还写着「网关只有
两个槽位」。
- `adapters/__init__.py` 的模块常量:白名单换成前缀常量、结构性参数拒绝清单、历史裸键清单;
- `_forwarded_binding` 的实现与 docstring:那句「网关只有两个槽位放得下这类东西」是本文推翻
的那条,改成决策一的说法,并指向本文;
- `GatewayModelClient` 的类 docstring:加一段说明保留前缀怎么用,含一个例子;
- `tests/integration/test_gateway_model_client.py` 里钉住旧行为那条用例:改写成前缀语义,另加
四条防御各一条;
- `CHANGELOG.md`:单开一节写迁移写法,因为它是破坏性变更落在补丁号上;
- `migrations/govdoc-saas.md`:那边要写的是租户命名空间怎么传;
- `tools/soak/run_soak.py` 里那份空绑定上方的注释:它把「绑定为什么留空」归给了转发规则,
而真正的理由是全部键值都进参数快照、每批不同的值会报假漂移,和转发哪些键无关。
## 留给后续的
**本库仍然不往网关的任何槽位里自动填东西。** 运行标识 `run_id`(续跑就是拿着它接着往下跑
的那个)不会被自动填进 `session_id`:那个槽位是下游的,本库填进去就会和下游自己的会话概念
撞,而撞了之后先写的那个赢。要按运行分组的下游自己写 `gateway.session_id`
**`cache_salt` 顺带通了,没有为它写一行代码。** 需要同一批消息跑多轮、每轮都真的重新调用的
下游写 `gateway.cache_salt` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单
方案得为它多列一个名字,还得先判断该不该列。
+75 -40
View File
@@ -5,15 +5,7 @@
> 第 3 档是定期复审。能用强的就不用弱的,完整说明见 `../README.md`。
>
> 本文件的第七、八、九节是**第 1 档**:那三节讲的分层、模块边界与抽象接缝由 `pyproject.toml`
> 的 import-linter 契约与 `tests/contract/` 断言。**其余章节是第 2 档**。
>
> **当前状态:本文件描述的是目标结构,`src/` 下一行代码都没有。** 常青层本该描述当前真实
> 情况,而这份在代码之前就存在。接受这个例外的理由与它的过期条件见
> `../../README.md` 的阶段清单第 ③ 条。`src/` 落地完成后删除本段。
>
> 由此带来一个读者必须知道的约定:**后文以现在时提到的 `polyloop/` 路径,指的是落地之后
> 该内容所在的位置**,不一定是现在就能打开的模块。这么写是为了让这份文档在代码落地那天
> 不需要逐句改时态。
> 的 import-linter 契约与 `polyloop.testing` 那套契约套件断言。**其余章节是第 2 档**。
>
> **本文件与 design doc 冲突时以本文件为准。** design doc 写完就冻结,它记录的是当时定了
> 什么;本文件描述的是现在是什么样。两者对同一件事都会提到,这是有意的——但理由只在
@@ -247,13 +239,14 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
`A ──▶ B` 读作「A 的代码里写了 `from polyloop.B import ...`」,也就是 A 依赖 B。
```
第 4 层 ┌───────────────┐ ┌───────────────┐ ┌────────────────┐
装配层 │ session stores adapters │
定义、请求 │ │ jsonl / 内存│ PolyGateway
run、resume │ │ 存储实现 │ │ 适配器
└───────────────┘ └───────────────┘ └────────────────┘
个互不 import:session 不认识任何具体实现,具体实现也不
认识 session。把它们装到一起的是调用方,不是库自己
第 4 层 ┌──────────── ┌────────────┐ ┌────────────┐ ┌────────────┐
装配层 │ session │ stores │ adapters │ │ testing
│ 定义、请求 │ │jsonl / 内存│ PolyGateway│ │ 契约套件
│ run、resume│ │ 存储实现 │ │ 适配器 │ │ 准入基类
└──────────── └────────────┘ └────────────┘ └────────────┘
个互不 import:session 不认识任何具体实现,具体实现也不
认识 session,而契约套件三个都不认识——它只认接缝定义。把它们
装到一起的是调用方,不是库自己
第 3 层 ┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐
@@ -277,8 +270,9 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
不必逐层往下传——分层禁止的是往上,不是要求一层一层往下
```
第 4 层里 `stores``adapters` 只够到第 2 层(它们 import `ports` 去实现那些 Protocol
再 import `types` 用那些数据类型),不需要碰第 3 层。
第 4 层里 `stores``adapters``testing` 只够到第 2 层(前两个 import `ports` 去实现那些
Protocol再 import `types` 用那些数据类型;契约套件 import 同样这两处,用来给下游的实现出
题),不需要碰第 3 层。
**存储实现在上面而不是下面,这一点最容易画反。** 直觉上存储是底层设施,该垫在最底下;
但按 import 方向,是存储实现去 import 接口定义,所以它在接口之上。库的核心不认识任何具体
@@ -287,19 +281,25 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:
判据只有一句:**`polyloop.types` 是依赖图的汇点**——所有箭头最终都指向它,没有一根从它出去。
它就是「字段只增不删不改名」保护的那份合同本身。
### 条依赖规则
### 条依赖规则
这些规则在代码里是**看不见的**——打开 `polyloop/ports/` 只会看到一堆正常的 Protocol,
看不到那里缺了什么。所以必须写下来,并且每一条都有对应的机器断言。
**一、分层,自高向低**:装配层(`session``stores``adapters`> 逻辑层(`tools`
**一、分层,自高向低**:装配层(`session``stores``adapters``testing`> 逻辑层(`tools`
`_assembly``_stopping``_recovery``serialization`> `ports` > `types`。低层不许
import 高层。
**二、装配层那个互相独立。** `session` 不许 import `stores``adapters`,反过来也不许
这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立一条独立性
契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 import 了某个存储实现,那个实现
就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。
**二、装配层那个互相独立。** `session``stores``adapters``testing` 两两之间都不许
import。这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立
一条独立性契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 import 了某个存储实现,
那个实现就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。
契约套件落在这一层、并且同样被这条独立性契约管住,有它自己的一条理由:本库声明了一个 pytest
插件入口,所以每一个装了本库的下游项目在 pytest 启动时都会加载 `polyloop.testing`。它一旦
import `adapters`,那次加载就会把网关连同它的 provider 目录一起拉起来——那正是规则九要防的
事,只不过触发路径不是 `import polyloop`,而是 pytest 自动加载插件
`../design/0014-contract-suite-distribution.md` 决策三)。
**三、`tools`、`_assembly`、`_stopping`、`_recovery`、`serialization` 五者互不 import**
不设豁免。它们之间的编织只能发生在 `session` 里。理由是这五个模块各自要能被单独测穷,
@@ -330,11 +330,18 @@ import 高层。
**八、`ports` 不许 import 其余任何 `polyloop` 模块。** 第一条已覆盖,单列是为了让违规信息
直接指向「接缝定义模块被污染了」,而不是一条泛泛的分层报错。
**九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由契约测试
断言,不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式
**九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由测试断言,
不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式
import。理由是一个「顺手提供的默认模型客户端」会让每个进程在 import 时把网关连同它的
provider 目录一起拉起来。
**十、除 `polyloop.testing` 外一切不许 import `pytest`,且 `import polyloop` 之后
`sys.modules` 里也不许出现 `pytest`。** 这条有两半,形状和规则五加规则九那一对相同:静态
那半是一条 import-linter 契约,运行时那半是一条测试。守的事很具体——pytest 只住在 `testing`
这个 extra 里,核心的运行时依赖是空的,所以别处 import 它,下游的生产环境一 `import polyloop`
`ModuleNotFoundError`,而生产环境通常根本没装 pytest。契约套件自己顶层就 import pytest
所以它和 `stores``adapters` 同一档,不进顶层的再导出,必须显式 import。
## 八、代码地图:每个模块装什么
| 位置 | 公开 | 装什么 |
@@ -349,11 +356,18 @@ provider 目录一起拉起来。
| `polyloop/_recovery/` | 否 | 恢复状态判定与运行身份校验 |
| `polyloop/stores/` | 是,须显式 import | 库自带的存储实现 |
| `polyloop/adapters/` | 是,须显式 import | PolyGateway 模型适配器 |
| `polyloop/testing/` | 是,须显式 import | 五个接缝的契约套件:每个接缝一个基类,下游继承它验自己的实现 |
`polyloop/types/` 的读者是所有人:dissect 的 runner 读步记录与停止原因把轨迹头拼回去,
GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任何适配器的人都要构造这里的结构体。
`polyloop/ports/` 的读者是写适配器的人和 `tests/contract/`。它用 `typing.Protocol` 而不是
`polyloop/testing/` 住在包里而不是 `tests/` 下,因为 `tests/` 不进发行包:`pip install polyloop`
之后 site-packages 里没有它,而这套用例正是下游实现接缝时的准入标准,拿不到的准入标准不成其为
标准。下游的接法是继承基类、在自己的子类里覆盖那几个必需 fixture;它不进 `polyloop/__init__.py`
因为它顶层就 import pytest,而 pytest 只在 `testing` 这个 extra 里,核心的运行时依赖是空的
`../design/0014-contract-suite-distribution.md` 决策一)。
`polyloop/ports/` 的读者是写适配器的人和 `polyloop/testing/`。它用 `typing.Protocol` 而不是
抽象基类,理由是下游的对象往往已经是它自己的类、还要同时满足项目自己更宽的接口;只有
结构化子类型能让同一个对象同时满足库的窄视图和项目的宽视图。
@@ -459,9 +473,21 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任
文本。它是不可变的,可以被并发复用。它还有一个只读方法,把四个接缝各自上报的参数聚合成
一份快照——那是方法不是字段,因为聚合要向接缝逐个发问,而构造定义之前定义还不存在。
**请求**每次运行构造一个,持有这次运行独有的十一样数据:运行标识、预算、动作执行、本次
可见的工具集、上下文各段、注入内容、模型绑定、模型调用的重放策略、观察包装模板、工具段
渲染样式、取消收尾时限。它构造廉价——无 I/O、无网络校验、无哈希计算。
**请求**每次运行构造一个,持有这次运行独有的十一样数据:运行标识、预算、动作执行接缝、本次
可见的工具集、上下文各段、按通道分组的注入内容、模型绑定、这次用的材料是哪一版(指纹)、
模型调用的重放策略、观察包装模板、取消收尾时限。它构造廉价——无 I/O、无网络校验、无哈希计算。
模型绑定和指纹形状相同:都是下游自己定键名的字符串映射,都整个进参数快照,库都不解释里面
装的是什么。含义不同。绑定记的是这次运行属于哪一格(哪个账本、第几轮、哪道题),库还把它
原样透传给每次模型调用;指纹记的是这次用的材料是哪一版(提示词模板的哈希、技能库的版本
这类),只进快照,不透传。两者混在一个字段里事后分不开:一组键值里既有「第 3 轮」又有一个
sha,要靠键名的命名约定去猜哪个是哪个,而命名约定不在任何一处被断言
`../design/0015-parameter-snapshot-contract.md` 决策一)。
**「工具段渲染样式」不在这十一样里,代码里也没有任何对应物。** `0003` 决策三的请求字段表
列了它,而它从第一版落地起就没有被实现,这笔欠账今天仍然欠着——它不是被哪次改动还掉的。
请求的字段数确实还是十一,但其中那一格现在是指纹:数字对得上,组成已经换过
`0015` 的「留给后续的」记着这件事)。
切点是「跨运行变不变」。预算只在请求这一处,不设「定义给默认值、请求可覆盖」——两处取值
意味着「这次到底跑的什么设置」要对照两个地方才答得出来。
@@ -511,18 +537,21 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
## 十三、这份文档靠什么不腐烂
第七、八、九节将来由机器断言,每一节各有对应的检查:
第七、八、九节各有对应的机器检查:
- **第七节**——条依赖规则,前八条各一条 import-linter 契约(第一条分层契约,第二、三条
是独立性契约,其余是禁止型契约),第九条一个契约测试。
- **第七节**——条依赖规则。规则一、二、三由同一条分层契约表达,规则八在那条里已经被覆盖
另单列一条只为让违规信息直接指向 `ports`,规则四、五、七各一条禁止型契约,规则十的静态那半
也是一条禁止型契约。剩下的落在 `tests/unit/` 的测试上:规则六(不许 import 任何第三方)
写不成契约,因为「任何第三方」不是一份可枚举的清单;规则九和规则十的运行时那半写不成契约,
因为 `sys.modules` 里有谁是运行时事实,不是静态图上的边。
- **第八节**——一个断言检查 `polyloop/` 下有哪些位置,和第八节那张表逐行对得上。表里有一行
在代码里找不到、或者代码里多出一个没写进表的位置,都算失败。没有这一条,新加的模块会
悄悄绕过第七节的分层规则——一个规则里没提到的模块,等于没有任何约束。
- **第九节**——一个断言检查接缝的数量和位置没有悄悄增长;另一个断言检查 `polyloop/`
不出现重试、限流、熔断的实现。
**写到哪一步了:一条都没** 这些断言要等 `src/` 落地才写得出来,在那之前这三节没有机器
兜底,只能靠人在实现时逐条对照。这是「架构文档先于代码存在」这个安排最实在的代价
**写到哪一步了:第七节那十条已经全部有断言,第八、九节还一条都没** 那两节现在没有机器
兜底,只能靠人在改代码时逐条对照——表里多一行少一行、接缝悄悄变成六个,CI 都不会响
其余章节是第 2 档:改相关代码时,改本文件是同一个提交的一部分。
@@ -530,8 +559,8 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
**公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在
`../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md`
落地之后权威转移到 `src/polyloop/` 的代码与 `tests/contract/`。本文件只给五个接缝的
Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。
落地之后权威转移到 `src/polyloop/` 的代码与 `polyloop/testing/` 那套契约套件。本文件只给五个
接缝的 Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。
**停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。**
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经
@@ -539,9 +568,6 @@ Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0
`../../CLAUDE.md` §0,公共类型的字段与枚举取值的权威是 `src/polyloop/`,不另写参考文档复述。
那份 design doc 记的是第一版为什么定成这样,不是查字段的地方。
**事件出口的事件类型还没定**,所以这个接缝的契约套件现在写不了。方向已经定了——观察走
事件流、干预走具名回调——但事件集与回调清单要独立成篇。
**多模态内容的规模度量没有答案。** 消息内容是块序列而不是裸字符串,第一版只定义文本块,
它的度量是准确的字符数。将来加图片块时必须同时给出它的度量定义,以及上下文上限在混合
内容下的语义。
@@ -562,6 +588,15 @@ Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0
| 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` |
| 公共类型与接缝叫什么、字段是什么形状、类型分到哪个模块 | `../design/0006-public-names-and-signatures.md` |
| 三个动作状态什么时候赋上、动作被拒绝时观察从哪来、解释器能不能抛异常 | `../design/0007-seam-behaviour.md` |
| 工具的实现挂在哪个字段上、它缺了什么时候报错、注册表派生的执行器怎么填动作结果 | `../design/0008-tool-handlers.md` |
| 字段顺序算不算一份对外承诺、公共数据类为什么一律只收关键字参数 | `../design/0009-keyword-only-public-types.md` |
| 上下文按什么顺序拼、注入槽为什么在那个位置、规模怎么量 | `../design/0010-context-assembly.md` |
| 逐行追加那个存储的文件布局、坏行怎么算、`fsync` 落在哪几处 | `../design/0011-jsonl-run-store.md` |
| 适配器为什么不自己装配网关客户端、消息怎么拼成一次网关调用、网关的异常为什么原样穿出 | `../design/0012-gateway-model-client.md` |
| 事件集为什么只有一个取值、事件带的是什么、具名回调清单为什么现在是空的 | `../design/0013-event-set-and-callbacks.md` |
| 契约套件怎么发给下游、下游怎么接上它、内存存储实现为什么叫易失 | `../design/0014-contract-suite-distribution.md` |
| 配方版本记进请求的哪个字段、注入的通道维度为什么保留、快照取值为什么必须是字符串 | `../design/0015-parameter-snapshot-contract.md` |
| 环境故障为什么走返回值、动作执行接缝抛异常时库为什么不接管、模型调用那侧为什么反而捕获 | `../design/0016-action-executor-failure.md` |
边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。
+388
View File
@@ -0,0 +1,388 @@
# 自造压测负载
> **更新触发点**:改 `tools/soak/` 下任何一块的职责、判据的构成、故障的类别、或者那四步流程的
> 顺序时,在同一个提交里改这份文档,不靠事后想起来。这是 `../README.md` 那三档里的第 2 档。
> 这套压测是一次性工具,验收阶段过去之后如果它被删掉,这份文档跟着删——一份描述已经不存在的
> 目录的常青文档,比没有更坏。
>
> **假设的背景知识**:PolyLoop 的治理单位是一次运行,一次运行有预算、停止原因、逐步轨迹与
> 崩溃续跑,这几个概念在 `architecture.md` 里有完整说明。另外要知道 AppWorld 是一个 agent
> benchmark(本机跑一个环境服务器,给它一道题、喂它 Python 代码、它回执行结果并能程序化判分)。
> 库里另外几个词——意图与结果、重放策略、合成观察、参数快照——在下面第一次用到的地方就地解释。
> 不需要读过 dissect 或 GovDoc 的代码。
## 1. 跑完一次压测,磁盘上多出什么
产物落在一个带时间戳的输出目录里,里面有:正常负载那一批的运行目录、故障注入那一批的运行目录、
GovDoc 三个阶段共用的工作区目录、以及四份 markdown 报告——干跑一份、正常负载一份、记分板两份
(正常负载判一次,故障注入判一次)。
运行目录里,每一次运行留下四个文件。**逐行日志由库自己写**——库的存储实现每走一步就往里追加一条
记录,压测碰都不碰它。**另外三个由压测入口写**:这次运行返回的结果、事件出口收到的事件流、以及
一份运行元信息(这次是哪个场景、哪个任务、花了多少次模型调用、如果是故障注入那一批的话是哪一类
故障)。这三个跟在日志旁边、文件名同前缀的附属文件,下文一律叫 **sidecar**
「压测入口」指的是跑负载的那两个脚本——正常负载一个、故障注入一个,两个都自己写 sidecar,共用同
一组后缀常量。四个文件的确切名字与字段由 `tools/soak/scoreboard.py` 的模块 docstring 定义,写的
两处和读的记分板都引它,没有第二份定义。
日志一定在——一批产物里有几次运行,就是按它枚举出来的。sidecar 可能缺:进程被杀在半路时来不及写。
**缺文件本身是一条要报告的观察,既不是崩溃理由,也不算通过。**
**故障注入那一批没有自己的 markdown 报告**,所以报告是四份不是五份。它跑的时候把每一类的逐条判定
打在标准输出上(脚本可以把这份输出重定向进日志目录),有击穿就以非零码退出;它留下的产物随后再交
给记分板判一次,那次才产出第四份 markdown。
这些产物不进版本库。日志里带着模型原文与真实公文片段,体量也大,忽略规则在仓库根的
`.gitignore` 里。
GovDoc 那个工作区目录里还有一份别处没有的东西:一份由环境侧自己追加的审计账,每一次有副作用的
工具调用留一行。它是第 7 节那条最硬的判据的唯一证据来源。
## 2. 为什么这套负载是自己造的
`README.md` 的阶段清单,⑥ 的验收有两件事,其中一件是自己造负载压。**三个下游一个都还没到
能用这个库的时候**——dissect 的循环还跑在它自己的实现上,GovDoc-SaaS 整体重建、实现清空,
CHSAnalyzer 还没写到 agent 那一步。而「从没被任何人用过」是这个库当时最大的未验证项,等下游把
它用起来再收集问题,是等不来的。
环境选 AppWorld,因为它自带评测端点做程序化判分。换成让另一个模型判对错,等于往验收里再塞一个
非确定源,验收本身就不再可复现。
这个环境跑在容器里,压测只用一个薄 HTTP 客户端连它,本进程一行 appworld 代码都不导入——那个包钉在
旧版 pydantic,和 PolyGateway 要求的版本装不进同一个解释器。**容器是池化的**:环境服务器用一个模块级
变量存「当前任务」,请求换题会被直接拒绝,也就是一个容器同一时刻只能跑一道题;要并发跑 N 道题就得
起 N 个容器,各占一个宿主端口,用完归还。取消那一类要验的「资源归还」,指的就是这个租约。
**一个场景不够,第二个是 GovDoc**,理由有三条。AppWorld 那一路只压得到一种形态:动作是代码、完成
由环境报告;而三个下游里 GovDoc-SaaS 将来是直接长在本库上的,它的形态完全不同——JSON 工具调用、
按阶段收窄工具集、由 agent 自己提交结论——这条路径在压测之前一次真实负载都没跑过。它的「环境」是
一个工作区目录加一份文件账,**活得过进程的死亡**,所以崩溃续跑那两类和最硬的那条判据只有它给得出
证据(第 7 节)。它的语料是一批一次运行里根本读不完的长文书,模型得自己检索着一点点读,正好用来压
提示词规模那一档。代价是那批语料是真实公文,得先脱敏(第 4 节)。
压测代码放在仓库根的 `tools/` 下:打包只收 `src/`,所以它不进 wheel、不进 sdist、不会随
`pip install` 装到下游手里;而测试的四层是按「依赖什么」分的(`CLAUDE.md` §1.9),压测不属于其中
任何一层。这个位置带来的自由是它可以依赖 `src/polyloop/` 明确拒绝的东西——HTTP 客户端、docker
命令行、某个具体 benchmark 的数据布局——因为它的失败只影响我们自己。反过来的方向是禁止的,
`src/polyloop/` 里任何一处都不许 import 它。
两个场景的动作协议都**照着下游的代码复刻,一行都不 import**:反向 import 下游是硬约束
`CLAUDE.md` §1.2,由 import-linter 断言)。复刻的代价是它会随下游漂移,收益有两层:压出来的
解析失败率与步数分布对 dissect 有参考价值;而且这反过来证明了一件更强的事——一个不认识 dissect
的第三方,只用公共 API 就能驱动一个真实环境跑完。
## 3. 两个场景压的不是同一件事
**AppWorld 那一路**:模型输出一段被代码围栏包住的 Python,解释器把它取出来当动作,执行器把它送
进容器里执行,观察是执行的输出。一次运行是否完成由**环境报告**——执行器在每次执行之后顺带问环境
一句「做完没有」。提示词直接用 AppWorld 官方 ReAct baseline 的那份,出处与许可写在
`tools/soak/prompts/appworld/PROVENANCE.md`。照抄而不自己写,是为了让压出来的步数分布与停止原因
构成能和 dissect 的历史轨迹对照:提示词换一份,这两样都会跟着变,就分不清是内核的行为变了还是
提示词变了。
**GovDoc 那一路**:一个任务是拿一条**审核点**(一条要判的合规条款,含判定标准和「合规 / 不合规 /
存疑」这三个允许的结论)去审一批招标文书。模型输出一个 JSON 对象,要么是一次工具调用,要么是一句
「这一阶段我做完了」。完成有**两条通路**——模型自报最终回答,或者调用一个带完成标记的提交工具。
一个任务分三个阶段:**plan** 在文书里检索并读上下文,把候选证据的位置写成一份计划文件;**execute**
照着那份计划逐条核实原文,把逐字摘录写成一份证据文件;**summarize** 只读证据文件,提交唯一一条结论。
工具集在最后一个阶段收窄——检索工具被收走,模型只剩阅读与提交。**收窄的作用是把「这一阶段只准依据
已经落盘的证据下结论」变成机器约束**:写在提示词里模型会违反,而工具清单里没有的工具它根本调不到,
调了会被库判成动作被拒。下游的编排里最后一个阶段的工具清单就是这么写的,压测照抄这个形态。
**「按阶段收窄工具集」在这个库里只能表达成「每个阶段一次运行」。** 治理单位是一次运行,而一次
运行只有一个工具注册表,中途换不了。所以一个审核任务被拆成三次独立的运行,三次共享同一个工作区
目录,阶段之间靠工作区里的那两个文件传状态。这不是压测为了凑数据量拆的,是这个约束的直接后果——
下游要收窄工具集就得这么写,所以这个形态值得压。
两个场景一起跑时,任务是**轮流排开**而不是拼接。整批共用一份模型调用预算,拼接的话排在前面的
场景会把预算吃光,后面那个一个任务都跑不到,而报告看起来只是「因预算停在第 N 个任务」——一次只
压了一半的跑,长得和一次正常的跑一模一样。
### 三条完成通路同时在场
加上 GovDoc 的两条,压测里同时存在三条完成通路:环境报告完成、agent 调用带完成标记的工具、模型
自报最终回答。它们的可信度依次下降——环境说完成了那就是完成了,模型说完成了只是它自己这么说。
压测正需要这个对照,因为库对三者的停止语义处理是不同的代码路径。
第三条通路是补上去的,理由值得记住:GovDoc 的前两个阶段原本没有带完成标记的工具,解释器也不产出
最终回答,于是模型在写完产出物之后还会继续读文档,直到撞上步数上限。那样整批的停止原因会全是
撞预算,别的停止路径一条都压不出来,而且白烧掉的调用比批准的额度还多。补上最终回答那一支之后,
预算也随之下调——上限定得高,只会让模型在活干完之后接着烧。各阶段的取值与它们的来历写在
`tools/soak/scenarios/govdoc.py` 的阶段一节。
## 4. 语料先脱敏,且一个字节不落盘
GovDoc 场景用的是真实政府采购文书,里面有工商全称、机关名称、固定电话、统一社会信用代码、邮箱、
联系人姓名。装配语料时,这些在内存里被替换成明显是假的稳定假名:**同一个原名在整份文档里换成同一个
假名**,否则模型会以为它们是不同主体,任务的难度就变了。金额、项目编号、日期原样保留——它们是审核
判断的依据,换掉任务就没得判了。
**原文与脱敏后的文本都只在内存里,一个字节不写盘、不入库。** 脱敏后必须再过一遍独立的检出校验,
检出到残留就抛异常拒绝启动。这条是硬闸不是告警,因为把未脱敏的第三方真实信息发给外部模型服务是
不可逆的:请求一旦出去就收不回来,而**漏掉一处的表现是「压测正常跑完」**,事后从任何一份产物里
都看不出来发生过泄漏。宁可拒绝启动。
异常消息里的样本一律掩码——拦截泄漏的那条日志本身成了泄漏,就白拦了。
**替换与校验共用同一份检出规则,不许各写一套**:各写一套的话,「替换漏了、校验也照样漏」会让这道闸
看起来一直是绿的。规则本身(哪几类、假名怎么起、为什么要给假名留一个固定前缀)在
`tools/soak/scenarios/govdoc.py` 的脱敏一节,改它的时候连校验一起改。
**已知缺口是门牌级地址。** 中文地址没有可靠的结尾标志,按形态认会把设备规格里的编号一起吃掉。机构名
与联系方式都换掉之后,剩下的地址指向的是一个已经改了名的主体——这个缺口是被接受的,不是没看见。
## 5. 判据全是结构不变量
**判据一条都不依赖模型的确定性。** 活模型下「两次运行产出同样的轨迹」本来就不成立,断言它只会随机
变红,而一个随机变红的判定器很快就没人看了。所有判据用的都是结构不变量:崩溃前那段字节续跑之后变没
变、步号连不连得上、每条意图有没有对应的结果、环境侧那份账去重前后条数一不一样、停止原因是哪个取值、
步数与上限的关系、环境自己数的执行次数。这些量与模型这次怎么选路无关。
记分板对每一次运行逐条判一批不变量,它们分三类:
- **日志自身的完整性**——每一条被换行终结的行都解得出记录;步号从头逐一递增,不重不跳;每条意图都
能找到它的结果。**意图与结果**是库的预写机制:调模型或执行动作之前先落一条「我要做这件事」的意图,
做完再落一条结果。所以一条没有结果的悬空意图,说明进程死在这两下之间;日志里至多允许有一条这样的
意图,而且必须是最后一条,那正是崩溃点。
- **库对自己的陈述与实际相符**——跨进程读回来的运行结果与日志里内嵌的那份逐字段相等;同一条步记录里
动作结果与步记录说的是同一件事;事件条数等于本进程真正走完的步数;投递失败计数对得上;并发跑的
几次运行之间不串台(每条记录的运行标识都属于它所在的那个文件)。
- **停止语义与轨迹自洽**——报「任务完成」就得有完成证据,报「撞步数上限」步数就得等于上限,报
「agent 自报做完」最终回答就不能是空的。
逐条的名字、说明和判法在 `tools/soak/scoreboard.py` 的不变量表里。每一条都配一个「构造出违反它的
日志、验它确实报击穿」的用例——**一个永远返回通过的判定器比没有判定器更糟**,它会让人以为验过了。
### 判据分两套,各管各的
记分板判的是**每一次运行都该成立**的那批不变量。正常负载的产物过它,故障注入留下的产物也过它,判法
一模一样。
故障注入另有一套判据,只对它自己造出来的那一类成立:崩溃前的字节没被改过、续跑之后环境的账一条不多、
取消之后容器租约还回来了——这些在一次正常跑完的运行上根本无从判起,因为那些事情没发生过。它们的权威
`tools/soak/faults.py`,和记分板那张表互不覆盖,而两套用的是同一套三档判定。
### 三档判定,第三档不许折算成通过
一条判据的判定有三个取值:**通过、击穿、无法判定**。第三档独立存在,不许折算成前两档中的任何一个。
缺文件、缺字段导致判不了,和判过了是两回事——压成两档的话,一次什么都没验成的跑会显示成全绿,
而那正是最需要被看见的情况。这一条在数据结构上是硬的:判定由证据算出来,没有一个可以手填的
「通过」。
击穿必须带具体证据(哪一次运行、哪一行或哪一步、期望什么实际什么),由构造期校验守住。通过也要给
证据,因为「通过」最常见的坏法是判据根本没跑到该判的东西上——比了零个字节、数了零条审计、看了一个
空列表,那些情况下证据文本会当场露馅。
记分板的退出码把「有击穿」和「只有无法判定」分开,还有一个开关决定后者放不放行。取值与形状见
`tools/soak/scoreboard.py``--help`
### 报告里不出现模型原文与文档片段
报告是要贴给人看的,而语料里有第三方的真实文档。所以报告只出现计数、枚举取值、运行标识、行号与
步号,别的一律只报长度不报内容。**工具名也算「别的」**:一次没通过校验的工具调用,会把模型编出来的
那串原样记进工具名字段,而那串可以是任何东西——包括一整段文档正文。
守这条的办法是在测试里往数据中塞一段独一无二的标记串(**哨兵**),跑完断言报告里找不到它。有一版
判定器打算按字符白名单放行工具名,那段哨兵恰好全是白名单里的字符,于是它出现在了报告里——那一次
测试变红,证明的正是「外来文本真的漏得出去」。
### 与历史轨迹的对照是对照,不是判定
记分板报告里有一张表,把本批次的停止原因构成与步数分位数和 dissect 的历史轨迹并排放。**它是对照
不是判定**:模型不同、任务子集不同,偏了不算击穿。它的用途是让人一眼看出「这批跑出来的形态是不是
离谱」,而那件事写不成判据。
## 6. 故障注入才是这套东西的价值所在
**一整批任务顺利跑完什么都证明不了。** 顺利那条路上,库只要不崩就算过。真正能证明东西的是那些
没有失败现场的路径:进程被杀在半路、调用方中途取消、目标根本完不成、模型的输出一句都解析不了、
提示词撑到装不下、环境跑到一半不能接着服务了。库给下游的承诺——崩溃前的轨迹逐字节不变、声明绝不
重放的动作不会执行两次、取消原样穿透且资源归还、撞上限时以正确的停止原因干净收尾、解析失败不碰
环境、提示词超限时终止而不是静默截断历史、环境坏掉那一步的观察换成合成观察——只有在这些路径上才
检验得到。
注入分九类,每一类的名字与取值形状见 `tools/soak/faults.py``--help`
- **崩溃续跑,时机 A**——一步完整落地之后杀掉进程。**续跑该真的接着往下跑**,而且崩溃前已经落盘的
那些字节一个不改。
- **崩溃续跑,时机 B**——一条声明**绝不重放**的意图已经落盘、结果还没落盘时杀掉进程。每个动作在注册
时都要声明能不能被重放:只读的检索声明成安全可重放,有副作用的写入声明成绝不重放。**库该判定状态
未知、干净停下**,不替它猜那次写到底做没做过,所以续跑之后环境的账应该一条都不多。
- **取消,落在模型调用中途**——那时在飞的是网关连接和一次已经计过费的请求。**取消该原样穿透出来**,
不被吞成一次普通的失败。
- **取消,落在动作执行中途**——那时在飞的是一个容器租约。**它该被还回池子**,而不是随着这次运行一起
烂在那儿。
- **撞步数上限** 与 **撞动作上限**——给一个根本完不成的目标,**验它以正确的那一维干净收尾**。两类的
预算刻意让不同的维先耗尽:不然先撞上的是另一维,这一条就什么都没验到。
- **解析失败连击**——包一层永远判无效的解释器。这一类只花几次调用,但它守的是**解析失败不碰环境**:
那条错了的表现是环境状态被一批根本没解释出来的动作改掉,而轨迹里每一步都写着解析失败。
- **撞提示词上限**(走 GovDoc 那一路)——把上限压到刚好装得下第一步、装不下第二步。库该终止运行并
记下这个停止原因,而**不是静默把历史截掉**:截断会改掉提示词的前缀,后面每一步都重新全价计费,
而在数据里看起来跟「模型不行」一模一样。
- **环境故障**(走 AppWorld 那一路)——正常走完一步之后**真的把容器杀掉**。库该把那一步的观察换成
**合成观察**(这一步没能拿到真实观察时库自己填的一段固定文本,由装配时传进来,所以同一份配置的两次
运行在模型看来是一样的)、以这个原因收尾,而它之前那些步一步都不受牵连。这一类自己起一个容器池,
不和别的几类共用:它会把容器打死,共用的话排在它后面的每一类都跑在一个已经没了的环境上,报出来是
一串「连不上」,看起来像 docker 挂了。
崩溃那两类天然会缺 sidecar 文件——被杀的子进程来不及写,记分板会报「无法判定」,**那是对的,不为了
让它变绿去补假数据**。所以故障注入那一批的记分板才开放行开关,正常负载那一批不开:正常负载不该缺
文件,判不了就是要人去看一眼。
跑一轮故障注入要花真实模型调用,所以它也有一道调用数护栏,但拦的位置和正常负载那道不同:**在每一类
开跑之前问一次「还够不够跑它最坏的情况」,不够就整类跳过并报无法判定**,跑完之后按日志里数出来的
调用数记账。**不能拦在模型客户端里**——在那里抛异常的话,库会把它记成一次模型调用失败、换上合成观察
接着跑,护栏本身就成了一次注入进来的故障,把这一类要验的停止原因搅乱了。
### 崩溃为什么改成子进程内部的确定性自杀
第一版靠父进程轮询日志尾部、看见形态对上就发 SIGKILL。实跑里时机 A 一次都没命中,每次都报
「发信号与子进程停笔之间又写进了记录」。原因是库写完一条步记录之后紧接着就写下一条模型调用意图,
中间只有内存里的装配计算,那个窗口窄到外面的信号挤不进去。
**这个观察本身是一条要记住的事实**:自然发生的崩溃几乎总是落在「有意图没结果」那一态上,而不是
落在两步之间的干净边界上。但要验那个干净边界就不能靠碰运气。
改法是在子进程里给存储包一层,在某一次写入落盘返回之后按条件调 `os._exit`。它不跑 `finally`
不跑 `atexit`、不 flush 任何缓冲,**对磁盘的效果与 SIGKILL 等价**;而库的逐行存储本来就写完即
fsync,没有未刷缓冲要指望进程退出时替它写下去,所以「有没有机会清理」在这里不影响磁盘上留下什么。
外部 SIGKILL 那条路降级成兜底,没有删。
包这一层有个约束:它转发内层存储上报的参数,不加自己的键。库把四个接缝各自上报的参数连同预算存成
一份**参数快照**写进日志,续跑时拿新装配的那份逐字段比对,对不上就报**参数漂移**并拒绝续跑——防的是
有人拿一套不同的配置接着往同一条轨迹上写。而父进程续跑时用的是一个没包过的存储,包装层要是往快照里
加一个自己的键,续跑就会报漂移,那是一次假故障。
**父子两侧的崩溃条件必须逐字对齐。** 有一版里父进程的兜底条件比子进程的自杀条件松一档,后果是这样
来的:条件松意味着它更早被满足,父进程一看见日志尾部的形态对上就发信号,而子进程那时还没走到自杀
那一行,于是每一次都是外部信号先命中,自杀那条路成了永远走不到的死代码,崩溃点又变回碰运气——而且
崩得太早,最硬的那条判据没有实料可判。对齐的那一项是「审计账已经非空」,即那个声明绝不重放的写入
真的执行过。它做成必传参数:给默认值等于允许某个调用点静默跳过这一条,而这正是当时出问题的方式。
时机 B 的命中条件是「最后一条意图的重放策略是绝不重放」,**这比「最后一条是意图」更严,而且必须
更严**:只读的检索工具声明的是安全可重放,悬在那种意图上续跑会重放动作接着走,停止原因就不是状态
未知。放宽这一条,判据会时对时错,而错的那几次看起来只像模型这次走了别的路。
### 最后添的那两类,补的是一次全量跑留下的空白
撞提示词上限与环境故障这两类是后添的,来历值得记住。一次全量跑完之后,库的停止原因取值里有两个从头
到尾一次都没出现过,正是这两个。**没出现不等于它们是对的,只等于没验过**——那两条路上的承诺到那一刻
为止一次都没有被检验过,而它们错了的形态恰好都不会当场炸:静默截断历史在数据里看起来跟「模型不行」
一模一样,环境故障那一步的观察没被替换掉,则表现成模型收到一段来路不明的文本。
这是「一批全绿要先怀疑负载」的一个实例。全绿有两种成因——库守住了承诺,或者负载根本没走到那条路上,
而报告本身分不开这两者。**分辨的办法是去数枚举取值**:每一个从来没出现过的停止原因,都是一块没被压
到的地方,把它造出来一次,那一档才算验过。
### 撞提示词上限那条判据不能照字面写
反直觉的地方在于:**规模判定发生在调模型之前**,命中时不产生步记录、也不写任何意图。所以落进日志的
每一条步记录都是通过了那一档的,它们的提示词长度必定都在上限之内——**真正超限的那一次装配没有在任何
地方留下记录**。
判据要是照字面写成「最后一步的提示词超过了上限」,它在一个正确的库上永远不成立,断言它等于断言契约
的反面。能从日志里判的是**再走一步会有多大**——最后一步之后进入历史的是它自己的输出和一段套过模板的
观察,把这些接在最后一步的提示词后面,就是下一次装配的规模(怎么算写在 `tools/soak/faults.py` 的那条
判据里)。这个数还没到上限,说明库在还装得下的时候就报了超限;而最后一步自己就超了上限,说明有一次
超限的装配被放行去调了模型。两头都是击穿。
同一次运行还判「至少完整走过一步」。一步都没走,说明撞上的是「上下文本身就比上限大」,那个长度是装配
决定的、不是循环一步步撑出来的,这一类什么都没验到。那种情况报无法判定而不是击穿——库做的事仍然是
对的,错的是这一路的上限选得太小。
上限**按这次运行的上下文现算**,不写死一个数字:上下文有多大取决于挑中的是哪个审核点、语料清单有多长,
写死的话它今天够用,换一份数据就要么第一步就撞上限(什么都没验到),要么宽到几步都撞不上(白烧调用)。
算法与余量的取法在 `tools/soak/faults.py` 里。
这一类走 GovDoc 而不是 AppWorld,因为它的观察是整段文档原文,一步就能把提示词撑过线;而它的初始上下文
比 AppWorld 那份长提示词小得多,上限压到那个量级之后第一步仍然过得去,撞上的是第二步。
### 真把容器杀掉,才发现「连不上」那一档没有实现
环境故障那一类杀的是真容器,不是一个返回环境故障的测试替身。替身验不到「环境真的不在了之后,在飞的
连接、会话的关闭、容器池的收尾这一串还能不能干净收场」,而那一串正是这一类跑完要留下的东西。
第一次真杀掉容器就撞出一件事:压测自己那个环境客户端抛的是传输层异常,而不是它 docstring 里承诺的那个
错误类型——**「连不上」那一档当时根本没有实现**。后果是容器一挂,整次运行会以一个未捕获的第三方异常
炸出去,而正确的行为是记成环境故障、由库换上合成观察、干净收尾。
**这是压测自己的缺陷,不是库的缺陷。** 库对动作执行接缝的约定是环境故障要以约定的方式报上来,而没把
传输层异常翻过去的是压测的环境层。缺口已经补上:那一层现在把 HTTP 客户端抛的异常翻成自己的错误类型
`tools/soak/appworld.py``_post_json`)。这件事值得记住的地方在于:一个只写在 docstring 里、
没有实现的错误档,除了真把环境弄坏一次之外没有别的办法发现——不弄坏它的时候,那条路一次都不会被走到。
## 7. 最硬的那条判据数的是环境侧的账
「声明绝不重放的动作没有被执行两次」是这套压测里最硬的一条判据。真正的重放长这样:库在状态未知时
把一次已经落过盘的写又执行了一遍,于是环境的账上多出一条一模一样的记录,**而轨迹里看不出任何异常**。
所以证据取自环境侧自己记的账——GovDoc 工作区里那份审计日志,每次有副作用的工具调用追加一行。
**不取库报的步数或动作数**:那是库对自己行为的陈述,拿它验库的行为就是我们和我们自己对账。轨迹里
的步记录记的是「库以为执行了几次」,两者只有在重放判定出错时才分叉,而分叉正是要验的东西。
判据是「按(工具、文件名、内容摘要)去重前后条数相等」。这三项就是账上记的全部,取这三项是因为它们
**恰好是一次重放会原样重现的东西**:重放执行的是同一个工具、写同一个文件、写同样的内容。而账上刻意
不记步号、不记时刻——那些每次都不一样,把任何一项随运行变化的东西放进键里,重放写下的那条记录就成了
「另一条不同的记录」,去重前后条数照样相等,判据静默失效。
代价是一种已知的假阳性:模型自己把同一份内容原样写了两次,账上也会出现两条一样的记录。分辨的办法是
回头看轨迹里那两步的步号——重放来自续跑,两条记录会分属崩溃前后。
**崩溃续跑这两类用 GovDoc 场景,不用 AppWorld**,理由就在这里。GovDoc 的「环境」是一个工作区目录加
一份审计日志,它天然活过进程的死亡,而且给得出环境侧自己数的执行次数。AppWorld 的环境活在容器里:
子进程被杀之后容器的归属与清理会变复杂,而它的执行计数存在会话对象里、会随进程一起消失,于是就只
剩「库自己报的数」可用了。
## 8. 重跑一遍
跑之前有三样东西必须已经就位,脚本一样都不会替人准备。**AppWorld 的镜像与数据**:镜像是官方发布的
现成镜像,不用自己 build,容器也由压测自己起自己停、不用事先起好;数据要先用 AppWorld 官方的下载
命令拉到本机一个目录里,那个目录就是脚本要的数据根目录,镜像名与目录结构的默认取值在
`tools/soak/appworld.py`。**GovDoc 的语料与审核点库**:那是一批真实公文和一份审核点数据,**不在这个
仓库里,也不对外**——手上没有它们的人跑不了 GovDoc 那一路,只能跑 AppWorld 那几类;路径的默认取值在
`tools/soak/scenarios/govdoc.py`,脚本不给就问它要。**模型凭据**:模型调用一律走 PolyGateway,凭据由
它的装配入口从环境里读,键的形状见仓库根的 `.env.example`(那份列的是 e2e 那层要的几个模型源键,
压测读的是同一套;`POLYLOOP_E2E` 那道开关只管测试,与压测无关)。
这台开发机上这三样都已经就位。换一台机器就得自己备齐:前两样按各自的来源取,第三样填一份 `.env`
四步:**干跑 → 正常负载 → 故障注入 → 记分板**。记分板实际上跑两次,正常负载之后一次、故障注入之后
一次,两次的「无法判定放不放行」开关不同,理由见第 6 节。`tools/soak/soak.sh` 把这几步按顺序串起来,
它需要的全部输入写在自己头部的注释里:两个模型调用预算上限(正常负载一份、故障注入一份)、并发上限、
每个场景最多跑几个任务、以及 AppWorld 的数据根目录。GovDoc 的两个数据路径不给就问场景模块要,脚本
里不写第二份。
**长跑放 tmux 里跑**`CLAUDE.md` §4):tmux 会话人和 AI 都能 attach,可以一起看同一份实时输出、
随时中断。脚本自己不建会话也不 attach,会话名的约定写在脚本头部。跑完不要急着 kill,留着给人复查。
脚本的每一步末尾都不接管道:管道的退出码来自最后一节,于是一次失败的跑会报成成功退出。要留日志就
用重定向,脚本有一个开关做这件事。
**预算与并发都没有默认值,缺了直接拒跑。** 一个能跑飞的压测入口迟早会跑飞:模型调用花真钱,容器占
的是和别人共用的机器,而这两个数字是唯一能拦住它的东西。达到调用上限时停止派发新任务,但**已经在跑
的让它跑完**——半路砍断会制造一批没有结束记录的日志,而那和崩溃留下的日志长得一模一样,会污染故障
注入那边的判定。计数在真正发起调用之前加:数的是尝试而不是成功,一次失败的调用同样占了时间、可能也
已经在网关那边计了费。
**干跑是全量之前的必经一步。** 它不打模型,但把每个任务的运行请求真的装配一遍:容器起不起得来、语料
读不读得进、脱敏闸过不过得了、提示词模板渲不渲染得出来,这几件事全在装配这一步暴露,而在全量里暴露
的代价是已经花掉的那部分调用费。它刻意不往运行目录写任何东西——写了的话,紧接着的全量会因为这次运行
的标识已经存在而被库拒绝。
环境层还有一个更靠前的入口:一个离线冒烟,起一个容器把整条会话链路走通,一次模型调用都不打。它执行
的几段代码是写死的,其中一段故意写错,验的是「代码报错不抛异常、错误文本原样回来」这条行为——那是
上层循环最依赖的一条,而正常路径验不出来。
每个脚本的参数形状以它自己的 `--help` 为准。
## 9. 这套压测没有 design doc
`research-wiki/design/` 下没有一份是关于它的,这是有意的。压测不是公共 API,它的取舍选错了改起来
也不贵,写一份冻结的决策记录换不回什么。它的理由分散在两处:**`tools/soak/` 各模块的 docstring**
(为什么这么设计、哪些坑是实测撞出来的),以及**提交正文**(每一次改动的处境与实测结果)。取值、
参数形状、判据的逐条定义,权威都在那两处。
代价是:提交正文会随着历史往后越来越难翻。这个代价是被接受的,因为替代方案——为一个一次性工具维护
一套冻结的决策记录——的成本更高,而它守的东西在验收阶段结束后就没有读者了。
+23 -2
View File
@@ -305,13 +305,34 @@ unzip -p polyloop-X.Y.Z-py3-none-any.whl polyloop/__init__.py | grep __version__
之间没有任何机器约束——构建时工作区不干净、`dist/` 没清、传错了文件,都会让一个「下载成功」
的包里装着旧代码。
sdist 要单独取,加 `--no-binary :all:`。它里面那份 `PKG-INFO` 的正文就是包页面要渲染的
README,可以在这里先看一眼1.0.1 那次是 6462 个字符):
sdist 要单独取。它里面那份 `PKG-INFO` 的正文就是包页面要渲染的 README,可以在这里先看一眼
1.0.1 那次是 6462 个字符1.0.2 是 9566 个):
```
NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \
pip download --no-deps --no-binary :all: --no-build-isolation \
--index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polyloop==X.Y.Z"
tar -xzf polyloop-X.Y.Z.tar.gz -O polyloop-X.Y.Z/PKG-INFO | head -40
```
**`--no-build-isolation` 不能省,而漏了它的失败形态会指向错误的方向。** `--no-binary :all:`
让 pip 取 sdist 而不是 wheel,而 pip 拿到 sdist 之后会去构建它的元数据,构建要先装
`pyproject.toml``build-system.requires` 那个 setuptools——`--index-url` 已经把索引整个换成
了这个 registry,那儿只有 polyloop,没有 setuptools。报出来的是:
```
ERROR: Failed to build 'polyloop' when installing build dependencies for polyloop
```
这句话读起来像刚传上去的那个包坏了,而实际上包好好的,坏的是这条命令的索引配置。
`--no-build-isolation` 让 pip 用当前 conda 环境里已经装着的 setuptools`dev` 那组的 `build`
带着它),不去索引找。
跳过构建隔离不影响这一步的判据:这里验的是 registry 上那份 sdist 的**内容**对不对,不是
「下游能不能从源码把它构建出来」。真要验后者,索引那一项得写成 `--extra-index-url`,让 PyPI
仍然在链上。
这一步单独占一个位置,而不是并进第七步说一句「传完了」,是因为 PolyGateway 那两个缺失版本
的形态就是「本地看起来全做完了」——只有一条真的去 registry 取一次的命令能区分开。
+10 -3
View File
@@ -207,9 +207,16 @@ PolyLoop 的日志按运行标识分文件(`../design/0011-jsonl-run-store.md`
的六维主键第二次调用循环。迁移后这条路要改成调「接着跑」而不是「再跑一次」——按
`../design/0003` 的前置条件,用同一个运行标识第二次调「跑一次」会直接报错。
**要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 如果暂时
不要恢复能力,要显式装配那个明确命名的、不提供恢复的内存实现——「我不要恢复」是一次
看得见的选择,不是一个可以忘记传的参数。
**要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 要恢复
能力,所以装 `JsonlRunStore`,给它一个目录。
**「不提供恢复的内存实现」叫 `VolatileRunStore`。** `../design/0003` 的否决方案那一节定过:
存储接缝必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的
选择而不是一个可以忘记传的参数。它现在和 `JsonlRunStore` 一起住在 `polyloop.stores`,两个
实现跑的是同一套契约套件。它不提供的是**跨进程恢复**:日志随进程一起消失,选它就是选「我
不要跨进程恢复」。名字为什么落在「易失」而不是「不提供恢复」上,见
`../design/0014-contract-suite-distribution.md` 决策五。这一条对 dissect 不构成阻塞——它本来
就要恢复。
**模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、
阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个
+55 -1
View File
@@ -5,7 +5,8 @@
> 来源与缺口清单。
>
> **当前状态:这不是一份迁移清单,是一份需求清单。** GovDoc-SaaS 的 agent 部分还没有可迁移
> 的东西,本文的作用是防止 PolyLoop 只按 dissect 一家的形状长。
> 的东西,本文的作用是防止 PolyLoop 只按 dissect 一家的形状长。唯一已经能逐条验的接入动作是
> 租户隔离参数那一节。
PolyLoop 只有 dissect 一个硬消费者(见 `dissect.md`)。只对着一个消费者做,做出来的库会长成
那个消费者的形状,而这件事在完成之前看不出来。GovDoc 这边不做迁移验收,改做**设计级验收**:
@@ -141,6 +142,59 @@ Codex 对抗审查抓出来了,修法见 `../design/0004-stopping-and-step-rec
显式注入,重试对它们就静默失效」——那是 PolyGateway 装配的问题,要在装配点解决,不是在
Agent 层补一层。
## 租户隔离参数怎么传
**绑定**是挂在一次运行请求上的一组字符串到字符串的映射,装的是下游项目自己的坐标。PolyLoop
不解释它的内容,原样交给模型调用接缝;自带的网关适配器再从里面挑出该往 PolyGateway 那次调用
上传的键。
GovDoc 在 PolyLoop 仓库提的 issue #6 说的是:适配器挑键的老办法把租户命名空间挡在外面,而那
是 PolyGateway 指定的租户隔离手段,挡掉之后两个租户提交相同的一段文本时,第二个会读到第一个
那次的模型输出。`../design/0017-gateway-forwarding.md` 决策一换掉了挑键的机制——**绑定里键名
`gateway.` 开头的才往下传,前缀之后那一段当作网关 `chat()` 的关键字参数名**。
### 要改的地方
租户命名空间与租户标识都写成带前缀的绑定键,每次运行按这次运行属于哪个租户填值:
```python
model_binding = {
"case": "...", # GovDoc 自己的坐标,不带前缀,不往下传
"gateway.cache_namespace": ..., # 这次运行所属租户的命名空间
"gateway.tenant_id": ..., # 同一个租户的标识
}
```
两个值的字符串怎么编码由 GovDoc 自己定(它的设计记录里已经定死),PolyLoop 不解释这两个字符串
的内容,也不替它们拼任何前后缀。这两个网关参数各自是什么语义、以及租户坐标为什么走绑定而不是
走适配器的构造参数,见 `0017` 的术语节与决策三。
**issue 里提的第三个维度 `meta` 本库不做**,理由在 `0017` 决策五。它原本要带的那个自己的 trace
标识改走 `gateway.session_id`:网关那边这个槽位本来就是一个用来分组的字符串,而 PolyLoop 不往
里面填任何东西,它一直留给下游自己写。
**绑定里现有的不带前缀的 `session_id` 与 `parent_call_id` 要改成带前缀的写法。** 这两个键在旧
机制下会被转发,`0017` 之后不再转发,而且会被适配器显式拒绝——用一次报错换掉一次静默的行为
变更,理由在 `0017` 决策四的第四条防御。拒绝发生在第一次模型调用时,形态是一次「第 0 步就以
模型调用失败收尾」的运行,失败说明里带着出问题的那个键名,没有预算被花掉。落地在哪个版本、
以及网关依赖下界要不要跟着动,见 `0017`
### 迁完算不算数
**绑定里再没有不带前缀的 `session_id` 或 `parent_call_id`。** 这条装上新版就自己验了:有残留
的话,用到那份绑定的第一次运行会在第一次模型调用上失败。
**每一次运行的绑定里都带着这次运行所属租户的命名空间键,值不是空串。** 空串会被适配器拒绝
`0017` 决策四的第二条防御)。查法是拿一次真实运行的运行开始记录看参数快照:带前缀的键照旧
逐字段进快照,所以「这次运行用的是哪个命名空间」事后查得到。
**同一段文本由两个租户各提交一次,各自拿到自己的那份模型输出。** issue #6 的失败场景不再复现
——这是最终判据,前面两条都是为它服务的。
**续跑一次运行时命名空间不变。** 命名空间随绑定进参数快照,而续跑开工前会把重算的快照与日志
里那份逐字段比对,换过命名空间就会被当成参数漂移中止。撞上这条说明续跑读到的缓存可能属于另一
个租户,中止是对的。
## 缺口登记
这些是 PolyLoop 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论——
+2 -2
View File
@@ -9,10 +9,10 @@ import 时把网关连同它的 provider 目录一起拉起来,而不传存储
没有恢复能力。
分层与依赖方向见 `research-wiki/explanation/architecture.md`,一次运行到底保证什么见
`tests/contract/`
`polyloop.testing` 那套契约套件
"""
#: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。
#: 两处双写是因为运行时读不到构建元数据(未安装的源码树里 `importlib.metadata` 查不到),
#: 而下游报 bug 时第一件事就是问版本号。
__version__ = "1.0.1"
__version__ = "1.0.3"
+17 -5
View File
@@ -86,13 +86,25 @@ def injection_messages(injections: Mapping[str, tuple[Injection, ...]]) -> tuple
)
def injected_entry_ids(injections: Mapping[str, tuple[Injection, ...]]) -> tuple[str, ...]:
"""这次贴了哪几条,按与 `injection_messages` 相同的顺序。
def injected_entry_ids(
injections: Mapping[str, tuple[Injection, ...]],
) -> Mapping[str, tuple[str, ...]]:
"""这次每个通道贴了哪几条,通道之间按通道名字典序,通道内与 `injection_messages` 同序。
**正文不进快照也不进轨迹,只有条目标识进。** 注入内容可能很大,进快照会让快照变成一份
数据副本;而「这次贴了哪几条」事后要查得到。
**正文不进参数快照,只有条目标识进。** 注入内容可能很大,进快照会让快照变成一份数据
副本;而「这次贴了哪几条」事后要查得到。它进的是运行开始记录里的参数快照,不是轨迹——
轨迹是步记录的序列,里面没有这一列。
**通道维度保留,不拍平成一个扁平序列。** 拍平之后「声明了这个通道但一条都没选中」和
「压根没有这个通道」得出同一个结果,而有下游要比较的正是这两种情形(`0015` 决策二)。
顺序必须与 `injection_messages` 一致,两个函数同住一个模块就是为了守住这一点:顺序对不上
的话,快照记的贴入顺序和模型真正看到的顺序是两回事,而续跑守卫照样全绿。
"""
return tuple(entry.entry_id for channel in sorted(injections) for entry in injections[channel])
return {
channel: tuple(entry.entry_id for entry in injections[channel])
for channel in sorted(injections)
}
def history_messages(steps: Sequence[StepRecord], observation_template: str) -> tuple[Message, ...]:
+57 -8
View File
@@ -19,8 +19,14 @@ from polygateway import GatewayClient, GatewaySettings, SourceConfig
from polyloop.ports import ModelCall
from polyloop.types import ContentBlock, Message, ModelReply, TextBlock
#: 绑定里能被网关认下的那几个键。其余的键留在参数快照里,不往下传
_FORWARDED_BINDING_KEYS = ("session_id", "parent_call_id")
#: 绑定里的保留前缀:带它的键才往下传给网关(`design/0017-gateway-forwarding.md` 决策一)
_GATEWAY_BINDING_PREFIX = "gateway."
#: 会改变请求本身、因而不接受从绑定走的网关参数名(同上决策四第一条)。
_STRUCTURAL_GATEWAY_PARAMETERS = frozenset({"messages", "stream", "structured", "overlay"})
#: 前缀落地之前按名字撞着转发的两个裸键,现在报错并给出改法(同上决策四第三条)。
_LEGACY_BARE_KEYS = ("session_id", "parent_call_id")
class GatewayModelClient:
@@ -36,6 +42,15 @@ class GatewayModelClient:
**调用方要保证这两个参数是它真的配对使用的那一对。** 传一个客户端加另一份配置,参数快照
会说谎,而续跑守卫就白设了——库验不了这件事,客户端不公开它是按哪份配置装的。
**绑定里 `gateway.` 是保留前缀。** 键名以它开头的,前缀之后那一段当作 `chat()` 的关键字
参数名往下传;其余的键是项目自己的坐标,只进参数快照,不往下传::
model_binding={"book": "b7", "gateway.cache_namespace": "acme:v1:tenant:x7"}
这一份传给网关的是 `cache_namespace="acme:v1:tenant:x7"`,`book` 不传。**本库不解释前缀
后面那个名字**,认不认得由网关决定——它不认得的会当场抛 `TypeError`,错误信息里带着那个
参数名。有几类名字本库自己就拒了,见 `_forwarded_binding`。
它满足 `polyloop.ports.ModelClient`,但不显式继承那个 Protocol:结构化子类型不需要继承。
"""
@@ -55,6 +70,9 @@ class GatewayModelClient:
(记一条带失败说明的结果记录、记一步、以模型调用失败收尾),而失败说明取的是异常的
类名与文本——网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它
盖掉。重试尤其不能做:网关内部已经有重试、退避、换源、熔断。
绑定里带 `gateway.` 前缀的键剥掉前缀之后一起发出去,那几条拒绝在这一步判,见
`_forwarded_binding`。
"""
response = await self._client.chat(
[_as_gateway_message(message) for message in call.messages],
@@ -108,14 +126,45 @@ def _block_text(block: ContentBlock) -> str:
def _forwarded_binding(binding: Mapping[str, str]) -> dict[str, str]:
"""绑定里网关认得的那几个键。
"""绑定里要往下传给网关的那些键。
**其余的键不往下传,也不报错。** 绑定是项目自己的坐标(某个下游有五维),而网关只有两个
槽位放得下这类东西。不报错是因为那些键**已经被记下来了**——绑定的全部键值都进运行开始
记录的参数快照(`0006` 决策三),续跑时逐字段比对。报错等于要求项目为了适配一个网关
裁剪自己的坐标系,而绑定同时是续跑守卫的输入,改它会让所有在跑的运行续不上。
键名以 `gateway.` 开头的,前缀之后那一段是 `chat()` 的关键字参数名,值原样传下去。
**不带前缀的键不往下传,也不报错**,因为那些键已经被记下来了——绑定的全部键值都进运行
开始记录的参数快照(`0006` 决策三),续跑时逐字段比对。为什么转发按前缀而不是按一份网关
参数名单,以及这里四条防御各自挡的是什么,见
`research-wiki/design/0017-gateway-forwarding.md`。
键排序后遍历,好让同时有多个键出错时报出来的总是同一个。
"""
return {key: binding[key] for key in _FORWARDED_BINDING_KEYS if key in binding}
forwarded: dict[str, str] = {}
for key in sorted(binding):
if key in _LEGACY_BARE_KEYS:
raise ValueError(
f"绑定里的 {key!r} 不再被转发给网关。要继续把它传下去,"
f"把这个键改名成 {_GATEWAY_BINDING_PREFIX + key!r}"
)
if not key.startswith(_GATEWAY_BINDING_PREFIX):
continue
parameter = key[len(_GATEWAY_BINDING_PREFIX) :]
if not parameter:
raise ValueError(
f"绑定里的 {key!r} 前缀后面是空的。{_GATEWAY_BINDING_PREFIX!r} 之后要跟一个网关的"
"关键字参数名,比如 'gateway.cache_namespace'"
)
if parameter in _STRUCTURAL_GATEWAY_PARAMETERS:
raise ValueError(
f"绑定里的 {key!r} 不能从绑定走:{parameter!r} 会改变请求本身,"
"而请求内容与采样、结构化设置另有权威(这次调用的消息,以及模型身份那份快照)。"
"要改这些就去改模型配置或这次调用本身,不要放进绑定"
)
if not binding[key].strip():
raise ValueError(
f"绑定里的 {key!r} 是空串或纯空白。判据是这个值带不带信息,不是它的格式对不对:"
"空串在网关那边和「没传」分不开,纯空白更糟——它是个真值,会被原样当成一个取值"
"用下去。要传就给一个带信息的值,不想传就把这个键去掉"
)
forwarded[parameter] = binding[key]
return forwarded
def _describe_sources(sources: Sequence[SourceConfig]) -> str:
+21 -2
View File
@@ -1,6 +1,6 @@
"""五个 Protocol,以及只在一次调用往返之间存在的入参 / 返回壳。
读者是写适配器的人和 `tests/contract/`。用 `typing.Protocol` 而不是抽象基类:下游的对象
读者是写适配器的人和 `polyloop.testing`。用 `typing.Protocol` 而不是抽象基类:下游的对象
往往已经是它自己的类、还要同时满足项目自己更宽的接口,只有结构化子类型能让同一个对象同时
满足库的窄视图和项目的宽视图。
@@ -14,7 +14,7 @@
`research-wiki/design/0006-public-names-and-signatures.md` 决策四。
每个方法的行为契约在 `research-wiki/design/0007-seam-behaviour.md`,机器形式在
`tests/contract/`。这里的 docstring 只写「读这段代码的人不知道就会写错什么」。
`polyloop.testing` 那套契约套件里。这里的 docstring 只写「读这段代码的人不知道就会写错什么」。
"""
from collections.abc import Mapping
@@ -211,6 +211,25 @@ class ActionExecutor(Protocol):
**动作本身报错算「已执行」**,不算环境故障:代码抛异常、命令返回非零都是正常观察,要
原样回喂让模型自己纠正。判成环境故障会让一次运行在模型本来能自我纠正的地方直接终止,
而轨迹上看不出它本可以继续。分界线是环境还能不能接着服务。
**环境自己坏了走返回值,不走异常**:连不上、协议不对、开好的会话没了,返回
`ActionStatus.ENV_ERROR`。把可预期的环境异常翻译成这个状态是适配器的正常工作,不是
catch-all——一个 HTTP 客户端的连接超时、一个容器会话的「会话已关闭」都是有名有姓的异常
类型,捕获它们并返回 `ENV_ERROR` 是在履行契约。
**真抛出来的异常库不捕获,原样穿出 `run()` 与 `resume()`。** 库替这一步编一个结算结果
就是在编造:异常抛出时副作用发生没发生是未知的,而 `StepCompleted` 要求有动作结果就得
连观察、完成信号、截断字符数一起造齐,而一条带着动作结果的完整步记录会被恢复读成「上一
步走完了」,那个未知状态就此被抹掉。不写反而是照实——日志停在「动作意图有、步记录无」,
恢复照四态表把它读成状态未知。这也是为什么这个接缝和 `DecisionParser` 一样,抛出的异常
不会被伪装成一个停止原因送进下游的统计。
模型调用接缝的异常反倒由库捕获,这个不对称的来由与被否掉的「库转成 `ENV_ERROR`」那条路
在 `research-wiki/design/0016-action-executor-failure.md`。
**`asyncio.CancelledError` 必须原样穿过,不许捕获吞没**`CLAUDE.md` §1.6),in-flight
资源在 `finally` 里放掉。上一条读快了容易读成「异常一概不用管」,而取消恰恰是那个必须
管的异常,管的方式是让它穿过去。
"""
async def execute(self, action: Action) -> ActionOutcome: ...
+91 -9
View File
@@ -18,7 +18,7 @@ import json
import logging
import time
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from polyloop._assembly import (
assemble,
@@ -48,7 +48,7 @@ from polyloop.ports import (
RunLog,
RunStore,
)
from polyloop.tools import RegistryExecutor, ToolRegistry
from polyloop.tools import RegistryExecutor, ToolRegistry, _frozen
from polyloop.types import (
ActionOutcome,
ActionStatus,
@@ -90,6 +90,29 @@ class ParameterDriftError(Exception):
"""
def _checked_str_mapping(mapping: Mapping[str, str], owner: str) -> Mapping[str, str]:
"""快照的每一个键和每一个值都必须是字符串,不是就当场拒绝。
取值类型已经是持久化契约的一部分:反序列化那一侧读到非字符串直接失败。只靠那一侧的话,
失败发生在续跑读日志的时候——这次运行已经完整跑过一遍、钱花完了、日志也写下去了,才发现
里面有一项读不回来;而且发现它的前提是真的有人来续跑,没人续跑那份存坏了的日志就一直躺着
直到有人拿它做统计。
**用异常不用 `assert`**`python -O` 会把断言整条移除,而这道校验守的正是一件静默出错的事。
`owner` 进错误信息,因为快照汇的是五个接缝加两个请求字段;只说「快照必须是字符串」的报错
在七个来源里指不出是谁。
"""
for key, value in mapping.items():
if not isinstance(key, str):
raise ValueError(f"{owner} 的键必须是字符串,收到 {type(key).__name__}{key!r}")
if not isinstance(value, str):
raise ValueError(
f"{owner}{key!r} 的取值必须是字符串,收到 {type(value).__name__}{value!r}"
)
return mapping
@dataclass(frozen=True, slots=True, kw_only=True)
class AgentDefinition:
"""跨运行不变的那一半装配,可以并发复用。
@@ -114,6 +137,11 @@ class AgentDefinition:
方法则每次现问,快照永远是从真实对象上读出来的**事实**而不是一份**声明**。
键带接缝名前缀,免得「哪一侧报的这个键」要靠约定记住。
**接缝上报的键值在这里校验类型,构造定义的时候不校验。** 构造时不向任何接缝发问,
发问发生在首次算快照的时候,那时 `run()` 已经读过一次存储日志了。所以这道校验保证的
不是零代价,是它发生在写运行开始记录之前,也就是在任何一次模型调用之前——不会跑完
一整次运行、把钱花光,才在续跑时发现快照里有一项存不下去。
"""
snapshot: dict[str, str] = {}
for prefix, seam in (
@@ -122,14 +150,29 @@ class AgentDefinition:
("store", self.store),
("event_sink", self.event_sink),
):
for key, value in seam.parameters().items():
for key, value in _checked_str_mapping(
seam.parameters(), f"{prefix}.parameters()"
).items():
snapshot[f"{prefix}.{key}"] = value
return snapshot
@dataclass(frozen=True, slots=True, kw_only=True)
class RunRequest:
"""一次运行独有的那一半装配。构造廉价:无 I/O、无网络校验、无哈希计算。"""
"""一次运行独有的那一半装配。构造廉价:无 I/O、无网络校验、无哈希计算。
三个映射字段(`injections`、`model_binding`、`fingerprints`)在构造时被逐层冻成只读的
形状存下来,用的是 `ToolSpec.parameters` 那同一个函数。`frozen=True` 只挡住「把字段重新
绑到另一个对象上」,挡不住「原地改那个字段里的 dict」,而这三个字段全都进参数快照:不冻
的话,`__post_init__` 那道「键和值都得是字符串」的校验可以被构造完之后往 dict 里塞一个
整数绕过去,那个整数一路进到运行开始记录,要到续跑读日志反序列化时才炸——那时这次运行
已经完整跑过一遍、钱也花完了。`injections` 更凶一档:构造后往里加一个通道,会同时改掉
参数快照和装配出来的消息序列,而两者都是「这次运行是什么设置」的证据。
**三个字段的类型注解仍然是 `Mapping`,签名的形状没有变。** `Mapping` 本来就是只读接口,
冻结没有对下游多要求什么:照旧传普通 dict 进来,变的只是「传进来之后再改那个 dict,这个
请求不跟着变」。
"""
#: 不透明字符串,库不解析。它同时是日志的主键。
run_id: str
@@ -140,10 +183,27 @@ class RunRequest:
#: 项目传一个空注册表加一个环境句柄,注册了工具的项目传 `tools.executor()`。
tools: ToolRegistry
context: Context
#: 本次要贴进上下文的条目,按通道分组。
#: 本次要贴进上下文的条目,按通道分组。构造时冻成只读映射(见类 docstring)。
injections: Mapping[str, tuple[Injection, ...]]
#: 项目自己的标识,库不解释,原样透传给每次模型调用。它的全部键值都进参数快照。
#: 构造时冻成只读映射(见类 docstring)。
model_binding: Mapping[str, str]
#: 这次运行用的材料是哪一版:提示词模板的哈希、技能库的版本这类。键名由项目自己定,库不
#: 解释内容,全部键值以 `request.fingerprint.<name>` 进参数快照,**不透传给模型调用**。
#:
#: **和 `model_binding` 的分界是「坐标还是配方版本」**:那个记的是这次运行属于哪一格
#: (哪个账本、第几轮、哪道题),这个记的是这次用的材料是哪一版。混在一个字段里事后分不
#: 开——一组键值里既有「第 3 轮」又有一个 sha,要靠键名的命名约定去猜哪个是哪个,而命名
#: 约定不在任何一处被断言。完整论证在
#: `research-wiki/design/0015-parameter-snapshot-contract.md` 决策一。
#:
#: 建议值里带上算法前缀,形如 `sha256:<hex>`。不带的话,换算法那天旧记录和新记录会以
#: 「两个不同的十六进制串」的形式参与比对,报出来的漂移看不出是换了算法还是内容真的变了。
#: 这是建议不是校验:库不解释这个值,也就没有立场规定下游能用哪几种哈希。
#:
#: 构造时冻成只读映射(见类 docstring)。默认值那个空 dict 同样会被冻——不冻的话,一个
#: 没传指纹的请求手上是一个改得动的空 dict,往里塞什么都不经过校验。
fingerprints: Mapping[str, str] = field(default_factory=dict)
#: 必填无默认。工具的重放策略能从注册表查到,模型调用的查不到——只有调用方知道这次调用
#: 能不能重来。
model_replay_policy: ReplayPolicy
@@ -155,6 +215,16 @@ class RunRequest:
def __post_init__(self) -> None:
check_observation_template(self.observation_template)
# 这两个字段整个进快照,取值不是字符串的话这次运行照常跑完,续跑读日志时才炸。这里
# 拒绝的代价是零:什么都还没发生,没有 I/O、没有日志、没有模型调用。两个一起校验是
# 有意的——它们语义同族、形状相同,只校验其中一个会让两个看起来一样的字段行为不一样。
_checked_str_mapping(self.model_binding, "RunRequest.model_binding")
_checked_str_mapping(self.fingerprints, "RunRequest.fingerprints")
# 校验完当场冻起来。校验和冻结是一对,缺了后半截前半截只在构造那一瞬间成立:
# `frozen=True` 拦不住 `request.fingerprints["x"] = 7`,而那之后快照里就有一个整数了。
object.__setattr__(self, "model_binding", _frozen(dict(self.model_binding)))
object.__setattr__(self, "fingerprints", _frozen(dict(self.fingerprints)))
object.__setattr__(self, "injections", _frozen(dict(self.injections)))
if self.cancel_grace_seconds < 0:
raise ValueError(f"取消宽限期不能为负:{self.cancel_grace_seconds}")
# 模型看见的 schema 来自一个注册表、实际分发走另一个,表现是「模型调了一个它看得见的
@@ -173,8 +243,9 @@ class RunRequest:
"""请求这一侧的快照,加上向动作执行接缝问的那一次。
**上下文与注入内容不进快照。** 它们是这次运行的输入数据不是参数,进快照会让快照变成
一份数据副本,而它们可能很大。注入的**条目标识**另行进轨迹,所以「这次贴了哪几条」
事后查得到,查不到的只是正文。
一份数据副本,而它们可能很大。注入的**条目标识**进快照,所以「这次贴了哪几条」事后
查得到,查不到的只是正文。生成上下文的东西(提示词模板这类)不是数据是参数,它的版本
走 `fingerprints`。
"""
snapshot: dict[str, str] = {
"request.max_steps": str(self.budget.max_steps),
@@ -187,14 +258,24 @@ class RunRequest:
"request.observation_template": self.observation_template,
"request.cancel_grace_seconds": str(self.cancel_grace_seconds),
"request.tools": ",".join(self.tools.names()),
"request.injected_entry_ids": ",".join(injected_entry_ids(self.injections)),
}
# 一个通道一个键,不把所有通道拍平成一个。拍平之后「声明了这个通道但一条都没选中」
# 与「压根没有这个通道」是同一个结果,而有下游要比较的正是这两种情形:前者是一个值为
# 空串的键,后者是这个键不存在。
for channel, entry_ids in injected_entry_ids(self.injections).items():
snapshot[f"request.injected_entry_ids.{channel}"] = ",".join(entry_ids)
# 模型绑定必须进快照,否则给它选字符串映射的那条理由就落空了。失败场景很具体:崩溃后
# 用同一个运行标识、换一组绑定续跑,后面每一次调用被记到另一套坐标上,而两段轨迹在
# 文件里看起来是同一次运行。
for key, value in self.model_binding.items():
snapshot[f"request.binding.{key}"] = value
for key, value in self.action_executor.parameters().items():
# 一条指纹都没有时一个键都不写,不写一个值为空串的键:今天已经在跑的配置算出来的快照
# 因此逐字节不变,只有真的传了指纹的运行才多出这几项。
for key, value in self.fingerprints.items():
snapshot[f"request.fingerprint.{key}"] = value
for key, value in _checked_str_mapping(
self.action_executor.parameters(), "action_executor.parameters()"
).items():
snapshot[f"action_executor.{key}"] = value
return snapshot
@@ -676,6 +757,7 @@ class _Driver:
replay_policy=ReplayPolicy.NEVER if spec is None else spec.replay_policy,
)
)
# 这里不包 try 是契约,不是漏了:执行器抛出的异常原样穿出去(`design/0016` 决策一)。
outcome = await self._request.action_executor.execute(action)
return outcome, bool(spec is not None and spec.completes_run)
+13 -304
View File
@@ -1,310 +1,19 @@
"""存储接缝的第一个实现:一次运行一个文件,一行一条记录,逐行追加
"""存储接缝的个实现:一个逐行追加进本地文件,一个只留在进程内存里
**个模块公开但不进 `polyloop/__init__.py`**必须显式 import`0003` 决策八第 9
它满足 `research-wiki/design/0003-public-api-shape.md` 决策四那张写入序列表与
`0005-storage-atomicity-and-record-fields.md` 决策五那条前缀持久性要求文件布局坏行怎么算
`fsync` 在哪几处定在 `research-wiki/design/0011-jsonl-run-store.md`
**`record` 是这一层的保留键** 每行是一个类型标签加那条记录的全部字段
`polyloop.serialization` 编出来的载荷只有记录类自己的字段标签是这里加的五个记录类现在
都没有叫 `record` 的字段将来也不许加加了的话编码出来的键会和标签撞而撞的表现是解码时
把一条记录读成另一种
**一层公开但不进 `polyloop/__init__.py`**必须显式 import`0003` 决策八第 9 存储
是必填的装配项库不给默认值顺手提供一个默认实现等于替所有下游选了日志落在哪儿而那是
每个项目自己的运维决定调用方要么从这里挑一个要么自己写一个
**关系数据库那一种形态本库不提供** 表结构事务边界连接管理都在项目那边库替它写一个
通用实现只会写出一个谁都不合用的存储接缝的意义就是让它自己实现 `tests/contract/`
它的准入标准
通用实现只会写出一个谁都不合用的存储接缝的意义就是让它自己实现契约套件
`polyloop.testing` `research-wiki/design/0014-contract-suite-distribution.md` 决策一
是它的准入标准那套用例只说行为不碰形态跑全绿就算合格
**这一层也不提供把日志转成别的格式按时间范围查这类操作** 存储接缝上多一个方法就是
给每一个下游实现多加一份永久要求而这些事拿 `read_log` 读回来的日志在库外面做即可
"""
import asyncio
import json
import os
import re
from collections.abc import Mapping
from pathlib import Path
from polyloop.stores._jsonl import RECORD_KEY, JsonlRunStore
from polyloop.stores._volatile import VolatileRunStore
from polyloop.ports import RunLog
from polyloop.serialization import (
DecodeError,
decode_intent,
decode_model_call_result,
decode_run_finished,
decode_run_started,
decode_step_completed,
encode,
)
from polyloop.types import Intent, ModelCallResult, RunFinished, RunStarted, StepCompleted
#: 每行那个类型标签的键名。见模块 docstring:它是保留键。
RECORD_KEY = "record"
#: 运行标识同时是文件名,所以它必须是一个安全的文件名。
#:
#: 不转义也不哈希:那样文件名就不再等于运行标识,而按运行标识去目录里找文件是最自然的用法。
#: 这条校验挡住的不只是可读性——运行标识是调用方给的不透明字符串,里面出现 `../` 的话,
#: 写文件会跑到目录外面去。
_SAFE_RUN_ID = re.compile(r"[A-Za-z0-9._-]+")
_TAGS: Mapping[type, str] = {
RunStarted: "run_started",
Intent: "intent",
ModelCallResult: "model_call_result",
StepCompleted: "step_completed",
RunFinished: "run_finished",
}
_DECODERS = {
"run_started": decode_run_started,
"intent": decode_intent,
"model_call_result": decode_model_call_result,
"step_completed": decode_step_completed,
"run_finished": decode_run_finished,
}
class JsonlRunStore:
"""把一次运行的日志逐行追加进 `<目录>/<运行标识>.jsonl`。
**一次运行一个文件不是一个大文件加一列运行标识** 大文件上读回某一次运行的整份日志
要扫全文而那件事在每次开工前都会做一遍更要命的是两次并发运行会往同一个文件追加
前缀持久性就从同一文件的追加序退化成两条交错的序
它满足 `polyloop.ports.RunStore`但不显式继承那个 Protocol结构化子类型不需要继承
"""
__slots__ = ("_directory", "_locks", "_write_all")
def __init__(self, *, directory: Path | str) -> None:
self._directory = Path(directory)
#: 每个运行标识一把锁,把同一个文件上的写串起来。
#:
#: 一条记录可能由不止一次 `os.write` 写完(`os.write` 允许短写),而 `O_APPEND` 只保证
#: 每一次 `os.write` 的追加位置原子,保证不了「一条逻辑行整体原子」。两个协程同时往同一
#: 个文件写时,一次短写会让两条记录交错成一段谁也解不开的字节。锁把这件事挡在进程内;
#: 跨进程那一半靠运行开始记录的独占创建挡(见 `write_run_started`)。
self._locks: dict[str, asyncio.Lock] = {}
#: **可注入的故障点**,见 `_write_all_bytes` 的 docstring。做成实例属性而不是方法,
#: 是因为 `__slots__` 让方法替换不掉,而替换它正是那条测试唯一的做法。
self._write_all = _write_all_bytes
def __repr__(self) -> str:
return f"JsonlRunStore(directory={str(self._directory)!r})"
def parameters(self) -> Mapping[str, str]:
"""上报可复现参数。
**目录不进快照** 它是这份日志本身所在的地方目录要是不一样根本读不到这份日志
也就走不到比对那一步把它记进去只会在换一台机器挂载点变了的时候报出一次假的漂移
而那次续跑其实完全正常
"""
return {"kind": "jsonl"}
# -- 写 ------------------------------------------------------------------
async def write_run_started(self, record: RunStarted) -> None:
"""写运行开始记录。**独占创建**:文件已存在就直接失败。
驱动入口在开工前会先读一次日志判断这个标识有没有用过但那是先读后写两个进程同时
读到空同时开始写的窗口它挡不住独占创建把那个窗口关掉代价是一个标志位
两个进程同时跑同一个运行标识的后果很具体交错的记录序会让恢复读到同一步的两条意图
判成日志被并发写过于是这次运行从此续不了而两边的模型调用都已经花过钱了
"""
await self._append(record, fsync=True, exclusive=True)
async def write_intent(self, record: Intent) -> None:
"""写一条意图。**耐久屏障**:必须落盘才能往下走。
意图必须在副作用之前就持久这是意图日志的全部意义
"""
await self._append(record, fsync=True)
async def write_model_call_result(self, record: ModelCallResult) -> None:
"""不做 `fsync`:后面紧跟的不是副作用。
它靠前缀持久性兜同一个文件的追加写下一次 `fsync`那必定是一条意图或者运行
结束会把它一起刷下去所以结果还没落盘动作意图落了盘这个状态在这份实现上
不可能出现而那正是 `0005` 决策五要防的
"""
await self._append(record)
async def write_step_completed(self, record: StepCompleted) -> None:
"""动作结果与步记录一次原子落地。
它们本来就是同一个记录类的两个字段所以一次原子写在这份实现上就是**一行**
一行要么完整地在文件里要么是被丢掉的撕裂尾行没有中间态
"""
await self._append(record)
async def write_run_finished(self, record: RunFinished) -> None:
"""写结束标记并 `fsync`。丢了的话这次运行看起来还能续,而它已经跑完了。"""
await self._append(record, fsync=True)
# -- 读 ------------------------------------------------------------------
async def read_log(self, run_id: str) -> RunLog:
"""读回整份日志。文件不存在时返回空日志,不抛异常。
驱动入口靠这条判断这个标识是不是已经有日志了抛异常的话那个判断就得写成捕获
异常而用捕获异常做流程控制会把真正的存储故障一起吞掉于是磁盘挂了会被读成
这是一次全新的运行然后覆盖式地重跑一遍
"""
path = self._path(run_id)
if not path.exists():
return RunLog()
raw = await asyncio.to_thread(path.read_bytes)
return _parse(raw, run_id)
# -- 内部 ----------------------------------------------------------------
def _path(self, run_id: str) -> Path:
if not _SAFE_RUN_ID.fullmatch(run_id) or run_id.startswith("."):
raise ValueError(
f"运行标识 {run_id!r} 不能直接当文件名。这份实现要求它只含字母、数字、点、"
"下划线与连字符,且不以点开头——它同时是文件名,而按标识去目录里找文件是最"
"自然的用法"
)
return self._directory / f"{run_id}.jsonl"
async def _append(
self,
record: RunStarted | Intent | ModelCallResult | StepCompleted | RunFinished,
*,
fsync: bool = False,
exclusive: bool = False,
) -> None:
tag = _TAGS[type(record)]
line = json.dumps({RECORD_KEY: tag, **encode(record)}, ensure_ascii=False) + "\n"
path = self._path(record.run_id)
lock = self._locks.setdefault(record.run_id, asyncio.Lock())
async with lock:
# 写入与 `fsync` 都是阻塞调用,而 `fsync` 在忙盘上可以到几十毫秒。直接在事件循环里
# 做会把同一个循环上所有并发运行一起卡住。锁按运行标识分,所以不同运行照样并行。
await asyncio.to_thread(
self._write_line, path, line.encode("utf-8"), fsync=fsync, exclusive=exclusive
)
def _write_line(self, path: Path, payload: bytes, *, fsync: bool, exclusive: bool) -> None:
"""打开、追加、按需 `fsync`、关闭。
**不长期持有文件句柄** 持有要为每个运行标识维护一份状态而那份状态在并发下就是共享
可变状态打开的成本相对一次 `fsync` 可以忽略一次 `fsync` 相对一次模型调用又可以忽略
"""
path.parent.mkdir(parents=True, exist_ok=True)
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
if exclusive:
flags |= os.O_EXCL
descriptor = os.open(path, flags, 0o644)
try:
self._write_all(descriptor, payload)
if fsync:
os.fsync(descriptor)
finally:
os.close(descriptor)
if exclusive:
_fsync_directory(path.parent)
def _fsync_directory(directory: Path) -> None:
"""把新建文件的目录项刷下去。
`os.fsync(fd)` 刷的是那个文件的内容刷不到这个目录里多了一个文件这条目录项掉电之后
内容可能在而文件根本不存在那时 `read_log` 文件不存在返回空日志驱动入口据此
判成一次全新的运行于是一次已经开始过可能已经花过钱的运行静默没了留痕
只在新建文件时做往已有文件追加不改目录项
"""
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _write_all_bytes(descriptor: int, payload: bytes) -> None:
"""把这些字节全部写进去。
**这是那个可注入的故障点** 契约套件验不了原子写的一起不可见那一半要在写入中途
杀进程而它跑在一个进程里`0011` 定的做法是在这一层留一个可替换的内部函数单元测试
把它换成写一半就抛异常它不出现在任何接缝签名上换一个存储实现就没有它
循环是因为 `os.write` 允许短写短写留下的半行正是撕裂尾行读那边会丢掉它
"""
written = 0
while written < len(payload):
written += os.write(descriptor, payload[written:])
def _parse(raw: bytes, run_id: str) -> RunLog:
"""把一份文件内容还原成日志。
**判据是这一行有没有被换行终结不是它能不能解析** 一次写入是先写整行再由调用方
等到它返回所以文件末尾那段没有换行的字节对应的那次写**从来没有被确认过**按契约它就是
没发生丢掉它正是要么都可见要么都不可见的落地方式
能不能解析判会漏掉一个很具体的场景短写正好写完了整个 JSON 对象只差最后那个换行
那段字节解得开于是一条从没被确认的动作意图被当成有效记录读回来恢复据此判成状态未知
并可能重放而那个动作其实一定没执行过因为调用方是在写意图返回之后才去执行的
**被换行终结的行必须解得开**解不开就是损坏直接报错追加写只在末尾产生撕裂一条完整
终结的行读不了说明别的东西动过这个文件那时跳过它接着读会拼出一份少了几条记录看起来
却完整的日志而恢复会照它做判断
"""
started: RunStarted | None = None
intents: list[Intent] = []
model_results: list[ModelCallResult] = []
steps: list[StepCompleted] = []
finished: RunFinished | None = None
chunks = raw.split(b"\n")
# 文件以换行结尾时最后一段是空的;不以换行结尾说明最后那次写没写完。
terminated = chunks[:-1] if chunks and chunks[-1].strip() else chunks
for number, chunk in enumerate(terminated, start=1):
if not chunk.strip():
# 空行不携带记录,也不是撕裂的证据。
continue
record = _decode_line(chunk, run_id=run_id, number=number)
if isinstance(record, RunStarted):
started = record
elif isinstance(record, Intent):
intents.append(record)
elif isinstance(record, ModelCallResult):
model_results.append(record)
elif isinstance(record, StepCompleted):
steps.append(record)
else:
finished = record
return RunLog(
started=started,
intents=tuple(intents),
model_results=tuple(model_results),
steps=tuple(steps),
finished=finished,
)
def _decode_line(chunk: bytes, *, run_id: str, number: int) -> object:
"""解一条被换行终结的行。解不开就是损坏,直接报错。
这里不再有解不开就当撕裂尾行那条路撕裂由有没有换行判定进不到这个函数
"""
where = f"运行 {run_id!r} 的日志第 {number}"
try:
payload = json.loads(chunk)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DecodeError(
f"{where}读不了({type(exc).__name__})。它是被换行终结的完整一行,"
"说明这个文件被别的东西动过——追加写只在末尾产生撕裂"
) from exc
if not isinstance(payload, dict) or RECORD_KEY not in payload:
raise DecodeError(f"{where}没有 {RECORD_KEY!r} 标签,这份文件不是本库写的")
tag = payload[RECORD_KEY]
decoder = _DECODERS.get(tag)
if decoder is None:
raise DecodeError(f"{where}的记录类型 {tag!r} 认不得,这份文件不是本库写的")
return decoder(payload)
__all__ = ["RECORD_KEY", "JsonlRunStore"]
__all__ = ["RECORD_KEY", "JsonlRunStore", "VolatileRunStore"]
+264
View File
@@ -0,0 +1,264 @@
"""存储接缝的第一个实现:一次运行一个文件,一行一条记录,逐行追加。
文件布局坏行怎么算`fsync` 在哪几处定在 `research-wiki/design/0011-jsonl-run-store.md`
它满足 `0003-public-api-shape.md` 决策四那张写入序列表与 `0005-storage-atomicity-and-record-fields.md`
决策五那条前缀持久性要求
**文件的字面约定**`<目录>/<运行标识>.jsonl`UTF-8每行以 `\\n` 结尾 ASCII 原样输出
这份日志的一个明确用途是让人和模型直接翻文件读所以它不转义成 `\\uXXXX`
**`record` 是这份文件布局的保留键** 每行是一个类型标签加那条记录的全部字段
`polyloop.serialization` 编出来的载荷只有记录类自己的字段标签是这里加的五个记录类现在都
没有叫 `record` 的字段将来也不许加加了的话编码出来的键会和标签撞而撞的表现是解码时把
一条记录读成另一种
**`fsync` 落在三处**运行开始每一条意图运行结束前两处是耐久屏障意图必须在副作用之前
就持久第三处防跑完了的运行看起来还能续模型调用结果与逐步结果不刷靠前缀持久性兜
运行开始那一次还要刷父目录 `_fsync_directory`
"""
import asyncio
import json
import os
import re
from collections.abc import Mapping
from pathlib import Path
from polyloop.ports import RunLog
from polyloop.serialization import DecodeError, encode
from polyloop.stores._records import DECODERS_BY_TAG, TAGS, Record, assemble_log
from polyloop.types import Intent, ModelCallResult, RunFinished, RunStarted, StepCompleted
#: 每行那个类型标签的键名。见模块 docstring:它是保留键。
RECORD_KEY = "record"
#: 运行标识同时是文件名,所以它必须是一个安全的文件名。
#:
#: 不转义也不哈希:那样文件名就不再等于运行标识,而按运行标识去目录里找文件是最自然的用法。
#: 这条校验挡住的不只是可读性——运行标识是调用方给的不透明字符串,里面出现 `../` 的话,
#: 写文件会跑到目录外面去。
_SAFE_RUN_ID = re.compile(r"[A-Za-z0-9._-]+")
class JsonlRunStore:
"""把一次运行的日志逐行追加进 `<目录>/<运行标识>.jsonl`。
**一次运行一个文件不是一个大文件加一列运行标识** 大文件上读回某一次运行的整份日志
要扫全文而那件事在每次开工前都会做一遍更要命的是两次并发运行会往同一个文件追加
前缀持久性就从同一文件的追加序退化成两条交错的序
它满足 `polyloop.ports.RunStore`但不显式继承那个 Protocol结构化子类型不需要继承
"""
__slots__ = ("_directory", "_locks", "_write_all")
def __init__(self, *, directory: Path | str) -> None:
self._directory = Path(directory)
#: 每个运行标识一把锁,把同一个文件上的写串起来。
#:
#: 一条记录可能由不止一次 `os.write` 写完(`os.write` 允许短写),而 `O_APPEND` 只保证
#: 每一次 `os.write` 的追加位置原子,保证不了「一条逻辑行整体原子」。两个协程同时往同一
#: 个文件写时,一次短写会让两条记录交错成一段谁也解不开的字节。锁把这件事挡在进程内;
#: 跨进程那一半靠运行开始记录的独占创建挡(见 `write_run_started`)。
self._locks: dict[str, asyncio.Lock] = {}
#: **可注入的故障点**,见 `_write_all_bytes` 的 docstring。做成实例属性而不是方法,
#: 是因为 `__slots__` 让方法替换不掉,而替换它正是那条测试唯一的做法。
self._write_all = _write_all_bytes
def __repr__(self) -> str:
return f"JsonlRunStore(directory={str(self._directory)!r})"
def parameters(self) -> Mapping[str, str]:
"""上报可复现参数。
**目录不进快照** 它是这份日志本身所在的地方目录要是不一样根本读不到这份日志
也就走不到比对那一步把它记进去只会在换一台机器挂载点变了的时候报出一次假的漂移
而那次续跑其实完全正常
"""
return {"kind": "jsonl"}
# -- 写 ------------------------------------------------------------------
async def write_run_started(self, record: RunStarted) -> None:
"""写运行开始记录。**独占创建**:文件已存在就直接失败。
驱动入口在开工前会先读一次日志判断这个标识有没有用过但那是先读后写两个进程同时
读到空同时开始写的窗口它挡不住独占创建把那个窗口关掉代价是一个标志位
两个进程同时跑同一个运行标识的后果很具体交错的记录序会让恢复读到同一步的两条意图
判成日志被并发写过于是这次运行从此续不了而两边的模型调用都已经花过钱了
"""
await self._append(record, fsync=True, exclusive=True)
async def write_intent(self, record: Intent) -> None:
"""写一条意图。**耐久屏障**:必须落盘才能往下走。
意图必须在副作用之前就持久这是意图日志的全部意义
"""
await self._append(record, fsync=True)
async def write_model_call_result(self, record: ModelCallResult) -> None:
"""不做 `fsync`:后面紧跟的不是副作用。
它靠前缀持久性兜同一个文件的追加写下一次 `fsync`那必定是一条意图或者运行
结束会把它一起刷下去所以结果还没落盘动作意图落了盘这个状态在这份实现上
不可能出现而那正是 `0005` 决策五要防的
"""
await self._append(record)
async def write_step_completed(self, record: StepCompleted) -> None:
"""动作结果与步记录一次原子落地。
它们本来就是同一个记录类的两个字段所以一次原子写在这份实现上就是**一行**
一行要么完整地在文件里要么是被丢掉的撕裂尾行没有中间态
"""
await self._append(record)
async def write_run_finished(self, record: RunFinished) -> None:
"""写结束标记并 `fsync`。丢了的话这次运行看起来还能续,而它已经跑完了。"""
await self._append(record, fsync=True)
# -- 读 ------------------------------------------------------------------
async def read_log(self, run_id: str) -> RunLog:
"""读回整份日志。文件不存在时返回空日志,不抛异常。
驱动入口靠这条判断这个标识是不是已经有日志了抛异常的话那个判断就得写成捕获
异常而用捕获异常做流程控制会把真正的存储故障一起吞掉于是磁盘挂了会被读成
这是一次全新的运行然后覆盖式地重跑一遍
"""
path = self._path(run_id)
if not path.exists():
return RunLog()
raw = await asyncio.to_thread(path.read_bytes)
return _parse(raw, run_id)
# -- 内部 ----------------------------------------------------------------
def _path(self, run_id: str) -> Path:
if not _SAFE_RUN_ID.fullmatch(run_id) or run_id.startswith("."):
raise ValueError(
f"运行标识 {run_id!r} 不能直接当文件名。这份实现要求它只含字母、数字、点、"
"下划线与连字符,且不以点开头——它同时是文件名,而按标识去目录里找文件是最"
"自然的用法"
)
return self._directory / f"{run_id}.jsonl"
async def _append(
self,
record: Record,
*,
fsync: bool = False,
exclusive: bool = False,
) -> None:
tag = TAGS[type(record)]
line = json.dumps({RECORD_KEY: tag, **encode(record)}, ensure_ascii=False) + "\n"
path = self._path(record.run_id)
lock = self._locks.setdefault(record.run_id, asyncio.Lock())
async with lock:
# 写入与 `fsync` 都是阻塞调用,而 `fsync` 在忙盘上可以到几十毫秒。直接在事件循环里
# 做会把同一个循环上所有并发运行一起卡住。锁按运行标识分,所以不同运行照样并行。
await asyncio.to_thread(
self._write_line, path, line.encode("utf-8"), fsync=fsync, exclusive=exclusive
)
def _write_line(self, path: Path, payload: bytes, *, fsync: bool, exclusive: bool) -> None:
"""打开、追加、按需 `fsync`、关闭。
**不长期持有文件句柄** 持有要为每个运行标识维护一份状态而那份状态在并发下就是共享
可变状态打开的成本相对一次 `fsync` 可以忽略一次 `fsync` 相对一次模型调用又可以忽略
"""
path.parent.mkdir(parents=True, exist_ok=True)
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
if exclusive:
flags |= os.O_EXCL
descriptor = os.open(path, flags, 0o644)
try:
self._write_all(descriptor, payload)
if fsync:
os.fsync(descriptor)
finally:
os.close(descriptor)
if exclusive:
_fsync_directory(path.parent)
def _fsync_directory(directory: Path) -> None:
"""把新建文件的目录项刷下去。
`os.fsync(fd)` 刷的是那个文件的内容刷不到这个目录里多了一个文件这条目录项掉电之后
内容可能在而文件根本不存在那时 `read_log` 文件不存在返回空日志驱动入口据此
判成一次全新的运行于是一次已经开始过可能已经花过钱的运行静默没了留痕
只在新建文件时做往已有文件追加不改目录项
"""
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _write_all_bytes(descriptor: int, payload: bytes) -> None:
"""把这些字节全部写进去。
**这是那个可注入的故障点** 契约套件验不了原子写的一起不可见那一半要在写入中途
杀进程而它跑在一个进程里`0011` 定的做法是在这一层留一个可替换的内部函数单元测试
把它换成写一半就抛异常它不出现在任何接缝签名上换一个存储实现就没有它
循环是因为 `os.write` 允许短写短写留下的半行正是撕裂尾行读那边会丢掉它
"""
written = 0
while written < len(payload):
written += os.write(descriptor, payload[written:])
def _parse(raw: bytes, run_id: str) -> RunLog:
"""把一份文件内容还原成日志。
**判据是这一行有没有被换行终结不是它能不能解析** 一次写入是先写整行再由调用方
等到它返回所以文件末尾那段没有换行的字节对应的那次写**从来没有被确认过**按契约它就是
没发生丢掉它正是要么都可见要么都不可见的落地方式
能不能解析判会漏掉一个很具体的场景短写正好写完了整个 JSON 对象只差最后那个换行
那段字节解得开于是一条从没被确认的动作意图被当成有效记录读回来恢复据此判成状态未知
并可能重放而那个动作其实一定没执行过因为调用方是在写意图返回之后才去执行的
**被换行终结的行必须解得开**解不开就是损坏直接报错追加写只在末尾产生撕裂一条完整
终结的行读不了说明别的东西动过这个文件那时跳过它接着读会拼出一份少了几条记录看起来
却完整的日志而恢复会照它做判断
"""
chunks = raw.split(b"\n")
# 文件以换行结尾时最后一段是空的;不以换行结尾说明最后那次写没写完。
terminated = chunks[:-1] if chunks and chunks[-1].strip() else chunks
records: list[Record] = []
for number, chunk in enumerate(terminated, start=1):
if not chunk.strip():
# 空行不携带记录,也不是撕裂的证据。
continue
records.append(_decode_line(chunk, run_id=run_id, number=number))
return assemble_log(records)
def _decode_line(chunk: bytes, *, run_id: str, number: int) -> Record:
"""解一条被换行终结的行。解不开就是损坏,直接报错。
这里不再有解不开就当撕裂尾行那条路撕裂由有没有换行判定进不到这个函数
"""
where = f"运行 {run_id!r} 的日志第 {number}"
try:
payload = json.loads(chunk)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DecodeError(
f"{where}读不了({type(exc).__name__})。它是被换行终结的完整一行,"
"说明这个文件被别的东西动过——追加写只在末尾产生撕裂"
) from exc
if not isinstance(payload, dict) or RECORD_KEY not in payload:
raise DecodeError(f"{where}没有 {RECORD_KEY!r} 标签,这份文件不是本库写的")
tag = payload[RECORD_KEY]
decoder = DECODERS_BY_TAG.get(tag)
if decoder is None:
raise DecodeError(f"{where}的记录类型 {tag!r} 认不得,这份文件不是本库写的")
return decoder(payload)
+94
View File
@@ -0,0 +1,94 @@
"""五个记录类在存储这一层的类型标签与解码器,以及把一串记录装配成一份日志。
**一张三元组表不是几张平行的表** 逐行追加那个实现按记录类找标签按标签找解码器易失
那个实现按记录类找解码器三张平行的表之间有一个谁也不检查的一致性要求必须覆盖同样那五个
记录类漏掉一处的表现是某种记录写得进去读不回来派生视图全部从同一张表算出来那个要求
就不可能被违反`research-wiki/design/0014-contract-suite-distribution.md` 决策五
**这个模块只认识记录不认识存储形态** 标签取值是文件里那一行的事但按记录类找标签这件事
两个实现都做不到自己一份还保持一致所以表在这里怎么用它由各自的实现定
"""
from collections.abc import Callable, Iterable, Mapping
from typing import NamedTuple
from polyloop.ports import RunLog
from polyloop.serialization import (
decode_intent,
decode_model_call_result,
decode_run_finished,
decode_run_started,
decode_step_completed,
)
from polyloop.types import Intent, ModelCallResult, RunFinished, RunStarted, StepCompleted
#: 存储接缝收得下的五种记录。
#:
#: 步记录与运行结果不在里面:它们是别的记录的字段,`polyloop.serialization` 单独给它们留了
#: 编解码入口是为了下游脱离运行时读轨迹,不是为了让它们自己成为一条日志行。
Record = RunStarted | Intent | ModelCallResult | StepCompleted | RunFinished
Decoder = Callable[[Mapping[str, object]], Record]
class RecordType(NamedTuple):
"""一个记录类在这一层的三件事:它自己、它的标签、把载荷变回它的那个函数。"""
record_cls: type[Record]
#: 记录类名的蛇形写法(`design/0011` 决策二)。它写进文件,改一个字就读不了旧日志。
tag: str
decode: Decoder
#: 顺序无意义,覆盖范围有意义:这五条就是存储这一层认得的全部记录。
RECORD_TYPES: tuple[RecordType, ...] = (
RecordType(RunStarted, "run_started", decode_run_started),
RecordType(Intent, "intent", decode_intent),
RecordType(ModelCallResult, "model_call_result", decode_model_call_result),
RecordType(StepCompleted, "step_completed", decode_step_completed),
RecordType(RunFinished, "run_finished", decode_run_finished),
)
TAGS: Mapping[type[Record], str] = {entry.record_cls: entry.tag for entry in RECORD_TYPES}
DECODERS_BY_TAG: Mapping[str, Decoder] = {entry.tag: entry.decode for entry in RECORD_TYPES}
DECODERS_BY_TYPE: Mapping[type[Record], Decoder] = {
entry.record_cls: entry.decode for entry in RECORD_TYPES
}
def assemble_log(records: Iterable[Record]) -> RunLog:
"""把一串按写入顺序排好的记录装配成一份日志。
运行开始与运行结束各只保留最后一条它们一次运行各写一回同一份日志里出现第二条说明这个
标识被重开过而挡这件事是写入那一侧的责任见两个实现的 `write_run_started`
**两个实现共用这一段** 各写一遍的话某一处漏掉一种记录比如只往步序列里放忘了模型
调用结果不会有任何检查发现只表现成恢复少看见一条记录而恢复据此判定的那一步会被重跑
"""
started: RunStarted | None = None
intents: list[Intent] = []
model_results: list[ModelCallResult] = []
steps: list[StepCompleted] = []
finished: RunFinished | None = None
for record in records:
if isinstance(record, RunStarted):
started = record
elif isinstance(record, Intent):
intents.append(record)
elif isinstance(record, ModelCallResult):
model_results.append(record)
elif isinstance(record, StepCompleted):
steps.append(record)
else:
finished = record
return RunLog(
started=started,
intents=tuple(intents),
model_results=tuple(model_results),
steps=tuple(steps),
finished=finished,
)
+176
View File
@@ -0,0 +1,176 @@
"""存储接缝的第二个实现:日志留在进程内存里,进程一退就没了。
`research-wiki/design/0014-contract-suite-distribution.md` 决策五定的那个实现
"""
from collections.abc import Mapping
from typing import NamedTuple
from polyloop.ports import RunLog
from polyloop.serialization import encode
from polyloop.stores._records import DECODERS_BY_TYPE, Decoder, Record, assemble_log
from polyloop.types import Intent, ModelCallResult, RunFinished, RunStarted, StepCompleted
class _Stored(NamedTuple):
"""桶里的一条记录:编出来的载荷,加上把它解回来要用的那个函数。
解码器在写入那一刻就查好存下来而不是读的时候按记录类现查查这一下同时是这一层认不认得
这个记录类的判据和逐行追加那个实现在写入时查类型标签是同一件事留到读的时候查一条
本层不认得的记录会先安静地进桶直到整份日志读不回来
"""
decode: Decoder
payload: Mapping[str, object]
class VolatileRunStore:
"""把一次运行的日志攒在进程内存里。
**它不提供的是跨进程恢复不是读不回来** 同一个进程里写进去的意图照样读得回来
这一层的准入标准是那套契约套件而套件第一条要的就是这件事一个过不了自家准入标准的实现
不该存在真正没有的是进程重启之后接着跑那份日志随进程一起消失续跑时读到的是一份
空日志于是那次运行会被判成从没开始过选它就是选我不要跨进程恢复
**运行标识在这里没有形状要求** 逐行追加那个实现要求标识是个安全的文件名因为标识就是
文件名这里什么都不是文件名`a/b` `../x` 都收得下拿它跑测试跑通了换成落盘那个
实现才撞上校验是这两个实现之间唯一一处不对等认下来是因为反过来更糟库替一个不落盘的
实现编一条文件名规矩等于把某一种存储形态的约束写进接缝
**并发隔离的承诺到同一个事件循环为止** 每个写方法从头到尾没有一个 await
asyncio 只在 await 处切协程所以同一个循环上并发跑的多次运行看到的每一次写都是原子的
多个线程各跑一个事件循环共享同一个实例不在这条承诺里两个线程可以都判出这个运行标识
还没有日志然后后写的那个把先写的整桶换掉而全程没有任何冲突报错逐行追加那个实现的
边界一样它那把按运行标识分的 `asyncio.Lock` 本身也不是线程安全的要跨线程用一个
线程一个实例
它满足 `polyloop.ports.RunStore`但不显式继承那个 Protocol结构化子类型不需要继承
"""
__slots__ = ("_runs",)
def __init__(self) -> None:
#: 运行标识 → 那次运行写下的记录载荷,按写入顺序。
#:
#: **存载荷不存记录对象**,理由和取值形态见 `_stored` 与 `read_log`。
#:
#: **按运行标识分桶,端口不持有「当前运行」的隐式状态**(见 `polyloop.ports.RunStore`):
#: 运行标识住在每一条记录里,写哪一桶每次现看。一个有隐式当前运行的实现会在并发下把 A
#: 的意图写进 B 的日志,而那种错在单线程测试里永远不出现。
self._runs: dict[str, list[_Stored]] = {}
def __repr__(self) -> str:
return f"VolatileRunStore(runs={len(self._runs)})"
def parameters(self) -> Mapping[str, str]:
"""上报可复现参数:只有形态,没有任何实例状态。
续跑时这份快照要和日志里存下来的那份逐键比对对不上就报参数漂移现在攒了几次
运行这类实例状态写进去的话同一个存储对象在两次比对之间会给出不同的答案于是一次
装配完全没变的续跑被判成漂移
"""
return {"kind": "volatile"}
# -- 写 ------------------------------------------------------------------
async def write_run_started(self, record: RunStarted) -> None:
"""写运行开始记录。**独占**:这个标识已经有日志了就直接失败。
和逐行追加那个实现的独占创建对齐包括判据那边失败的条件是文件已存在而任何一次写
都会把文件建出来所以这里的判据也是这个桶在不在不是有没有一条运行开始记录
只看运行开始记录的话一个先写过意图的标识还能再开一次而同一段代码换成落盘那个实现
会失败
驱动入口在开工前会先读一次日志判断这个标识有没有用过但那是先读后写独占把那个窗口
关掉同一个标识被重开的后果很具体交错的记录序会让恢复读到同一步的两条意图判成
日志被并发写过于是这次运行从此续不了而两边的模型调用都已经花过钱了
**失败抛 `FileExistsError`** 这里没有文件但调用方要处理的是同一件事这个运行
标识已经开过了而它多半接的是一个由配置决定的存储实现换一个自造的异常类型的话
那段处理代码得先判自己拿到的是哪一种实现或者干脆两种都捕获
"""
stored = _stored(record)
if record.run_id in self._runs:
raise FileExistsError(
f"运行标识 {record.run_id!r} 已经有日志了,不能再开一次。"
"要从这份日志接着跑用 resume"
)
self._runs[record.run_id] = [stored]
async def write_intent(self, record: Intent) -> None:
"""写一条意图。
**没有耐久屏障可言**这份日志比进程活不长必须落盘才能往下走在这里没有对应物
它仍然满足意图日志在进程内的那一半意图在副作用之前就读得到了
"""
self._append(record)
async def write_model_call_result(self, record: ModelCallResult) -> None:
self._append(record)
async def write_step_completed(self, record: StepCompleted) -> None:
"""动作结果与步记录一次原子落地。
它们本来就是同一个记录类的两个字段而这个方法从头到尾没有一个 await 所以在同一个
事件循环上没有任何别的协程能插进来读到有动作结果没有步记录这种中间态往列表里
追加的那一下要么发生了要么没发生编不出来的记录在追加之前就抛了那时这一条整条
不可见
"""
self._append(record)
async def write_run_finished(self, record: RunFinished) -> None:
self._append(record)
# -- 读 ------------------------------------------------------------------
async def read_log(self, run_id: str) -> RunLog:
"""读回整份日志。**从没写过的运行标识返回空日志,不抛异常。**
驱动入口靠这条判断这个标识是不是已经有日志了抛异常的话那个判断就得写成捕获
异常而用捕获异常做流程控制会把真正的存储故障一起吞掉于是存储连不上会被读成
这是一次全新的运行然后覆盖式地重跑一遍
**每次读现解一遍交出去的是一批新对象** 把桶里那份直接交出去的话读回来之后改它
就能倒着改掉存储里那一份`RunStarted` 带的参数快照是个 dict一次普通读取就足以把
日志改掉而续跑的参数漂移判断读的正是这份
**字段类型不对的记录在这里炸不在写的时候炸** `encode` 只把字段取出来装进 dict
不查类型所以坏记录写得进去逐行追加那个实现同理`json.dumps` 收得下一个本该是
整数的字符串它也要到读的时候才报 `DecodeError`两个实现的失败时机必须一样否则
同一段下游代码在易失存储上写就红换成落盘存储要到读才红
"""
return assemble_log(entry.decode(entry.payload) for entry in self._runs.get(run_id, ()))
# -- 内部 ----------------------------------------------------------------
def _append(self, record: Record) -> None:
"""把一条记录记进它自己那一桶。
**不加锁** 这几行里没有一个 await asyncio 只在 await 处切协程所以同一个事件
循环上的并发运行看到的这一段是原子的跨线程的边界见类 docstring逐行追加那个实现
要按运行标识加锁是因为它把写丢进了线程一条记录可能由不止一次 `os.write` 写完
这里没有那个窗口照抄一把锁只会让读代码的人以为这里有一个不存在的竞争
**先编码再建桶** 反过来的话一条编不出来的记录会留下一个空桶而那个标识从此开不了
新运行`write_run_started` 的独占会撞上它
"""
stored = _stored(record)
self._runs.setdefault(record.run_id, []).append(stored)
def _stored(record: Record) -> _Stored:
"""编成载荷再存,不存调用方手上那个对象的引用。
存引用会有别名 bug`RunStarted` 带着的参数快照是一个 `Mapping`调用方构造完之后还能改
自己手里那个 dict而读回来的快照会跟着变`design/0014` 决策五存载荷把这一半和读那
一半一起堵上形态也和逐行追加那个实现对齐那边存的是文本同样是读的时候才解
**只编不解** 解一遍再把结果丢掉能让字段类型不对的记录在写入时就炸但逐行追加那个实现
在写入时不炸于是同一条记录在两个实现上一个写得进去一个写不进去等价的对齐点是读
两边都放行两边都在 `read_log` 抛同一个 `DecodeError`
"""
# 本层不认得的记录类在这里抛 `KeyError`,和逐行追加那个实现查类型标签的位置对齐;
# `encode` 不认得的记录类型在下一行抛 `TypeError`。两者都发生在建桶之前,所以写失败
# 不留下半个桶。
decode = DECODERS_BY_TYPE[type(record)]
return _Stored(decode, encode(record))
+81
View File
@@ -0,0 +1,81 @@
"""五个接缝的契约套件,随包发布,给下游当准入标准用。
每个接缝一个基类这套用例不针对任何具体实现写的是不管你怎么实现都必须满足这些
行为所以它自己不造实现只声明你得提供什么写完自己的存储或适配器之后在自己的
测试文件里继承对应的基类覆盖那几个必需 fixture跑一遍全绿就算合格
# tests/test_my_store.py
import pytest
from polyloop.testing import RunStoreContract
from myproject.storage import MyPostgresStore
class TestMyPostgresStore(RunStoreContract):
@pytest.fixture
def store(self, pg_pool):
return MyPostgresStore(pg_pool)
子类的类名要以 `Test` 开头pytest 才收集它基类自己叫 `...Contract` 正是为了不被收集
pytest `Test` 前缀匹配测试类一个叫 `RunStoreContract` 的基类不会被当成测试类跑一遍
于是它那些 `raise NotImplementedError` 的默认 fixture 也就不会失败
**装它**`pip install "polyloop[testing]"`
**必需的 fixture 在基类里都有一个默认实现函数体是 `raise NotImplementedError`**报错消息
写着该覆盖什么该返回什么忘了覆盖时看到的是这条消息而不是 pytest 那句
`fixture 'store' not found` 加一整屏 available fixtures 列表后者指向的是 site-packages
的库文件第一反应会是库坏了
**样本输入由实现方提供** `DecisionParserContract.reply_samples`
`ActionExecutorContract.action_samples` `ModelClientContract.failing_call` docstring
套件不认识任何一家的动作语言也不知道一家实现要怎样才会失败拿一家的代码围栏去喂另一家的
JSON 解析器后者正确地返回无效决策而写死输入的套件会把这个正确行为判成失败
**实现之间的能力差异走运行期跳过不走失败** 取消那两条就是这么处理的一个在单个事件
循环 tick 之内就返回的实现根本没有机会吞掉取消那条契约对它无从谈起跳过的理由字符串会
说清楚这一点看到这种跳过不必去改自己的实现
**async 用例的事件循环归下游管** 套件里的用例照常写成 `async def`套件不做任何事件循环
安排所以要么把 `asyncio_mode` 设成 `"auto"``pyproject.toml`
`[tool.pytest.ini_options]` 要么自己给子类打上对应的标记没配对的失败是响亮的
pytest 会明说 async def 函数不被原生支持那条信息直接指向解法
库不替下游安排循环是因为下游的 fixture 很可能是异步的一个数据库存储实现的连接池就是
那个 fixture 在下游的循环里创建库自己开的循环是另一个跨循环使用 asyncio 对象会炸而且
炸得很难查
**`PYTEST_DISABLE_PLUGIN_AUTOLOAD=1` 的环境要多写一行** 本包声明了一个 pytest 插件入口
它唯一的作用是让 pytest 重写这些模块里的 `assert`失败时打印出等号两边的实际值那个环境
变量一设上入口就不加载了契约失败会退化成光秃秃的 `AssertionError`这时在自己的
`conftest.py` 顶上补一行
pytest.register_assert_rewrite("polyloop.testing")
**套件里一个自定义 marker 都不用** 开了 `--strict-markers` 而没注册那个 marker 的话炸掉
的是整个测试文件的收集不是一条失败而报错指向的同样是库的文件要给这些用例分组就在自己
的子类文件上打标记那个文件和那个 marker 都在你自己的仓库里
**这里的每个名字都是公共承诺**基类名基类上的 fixture 每一条用例的方法名下游的子类
按它们写改名的代价和改公共类型的字段一样用例方法名尤其要紧下游要豁免某一条时按名字
重绑它改名之后那个豁免会悄悄失效跑出来照样全绿
设计与取舍见 `research-wiki/design/0014-contract-suite-distribution.md`
"""
from polyloop.testing._action_executor import ActionExecutorContract
from polyloop.testing._decision_parser import DecisionParserContract
from polyloop.testing._event_sink import EventSinkContract
from polyloop.testing._model_client import ModelClientContract
from polyloop.testing._records import RecordFactory
from polyloop.testing._run_store import RunStoreContract
__all__ = [
"ActionExecutorContract",
"DecisionParserContract",
"EventSinkContract",
"ModelClientContract",
"RecordFactory",
"RunStoreContract",
]
+195
View File
@@ -0,0 +1,195 @@
"""动作执行接缝的行为契约。
两个已知形态差别很大一个把一段代码交给已经开好的容器会话状态恒为已执行一个查
工具注册表分发工具不存在或参数不合法时返回未执行下面每一条都要对两者同时成立
## 写这套用例时撞出来的两个问题,`design/0007` 决策一与决策二答了
三个状态取值各自在什么条件下被赋上返回未执行时那段观察由谁给两条的答案都落在
**库这一侧**所以它们的断言不在这个接缝的契约里见文末那两条说明
## 实现方不返回结果、直接抛出时会怎样,`design/0016` 决策一答了
环境自己坏了走返回值抛出来的异常库不接管这一条同样验不到这一层说明在文末第三条
"""
import asyncio
from typing import Any
import pytest
from polyloop.ports import ActionExecutor
from polyloop.testing._records import ContractBase
class ActionExecutorContract(ContractBase):
"""动作执行接缝的准入套件。下游继承它,覆盖 `action_executor` 与 `action_samples`。"""
@pytest.fixture
def action_executor(self) -> ActionExecutor:
"""被测的动作执行接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `action_executor` fixture,返回一个 ActionExecutor 实现的实例。"
)
@pytest.fixture
def action_samples(self) -> Any:
"""被测执行器认得的两个动作,由实现方提供。
**套件不许自己写死输入** 动作语言是实现方定的一段代码一次工具调用一条命令
拿一段文本形式的代码去喂一个按工具名分发的执行器它正确地返回未执行而写死输入
的套件会把这个正确行为判成失败
返回一个带两个属性的对象两个属性都是 `polyloop.ports.Action`
- `executes_cleanly`跑得完动作本身不报错
- `executes_but_errors`跑得完但动作本身报错代码抛异常命令返回非零
**两个动作执行完的状态都该是 `ActionStatus.EXECUTED`** 这正是契约要断言的动作本身
报错是正常观察不是环境故障给一个会被判成未执行的动作红的原因就与实现的对错
无关了
**两个动作都不许触碰真实的外部副作用**删文件发请求花钱套件会真的执行它们
而且会在执行到一半时取消其中一个那时副作用做了多少是未知的
套件会执行同一个动作不止一次两次的观察不必逐字相同但状态必须相同
**退化情况一个不存在动作本身会报错这种情形的执行器仍然要给出
`executes_but_errors`** 比如一个在执行之前就把不合法的动作全挡掉的实现它可以给一个
观察里带着错误信息状态仍是已执行的动作真的构造不出来就在子类里重写用到它的
那条用例并写明理由
"""
raise NotImplementedError(
"在你的子类里覆盖 `action_samples` fixture,返回一个带 `executes_cleanly` 与 "
"`executes_but_errors` 两个 Action 属性的对象。"
)
async def test_returns_all_five_fields(self, action_executor, action_samples):
"""返回值必须带齐五个字段,一个都不能省。
库拿这五个字段填步记录里对应的五列少一个那一列就只能填默认值而默认值与真实值在
轨迹里长得一模一样事后没有任何办法把执行器没给值确实是这个分开
"""
outcome = await action_executor.execute(action_samples.executes_cleanly)
assert outcome.status is not None
assert isinstance(outcome.observation, str)
assert isinstance(outcome.observation_is_synthetic, bool)
assert isinstance(outcome.env_reported_completion, bool)
assert isinstance(outcome.observation_truncated_chars, int)
async def test_completion_signal_is_a_plain_boolean(self, action_executor, action_samples):
"""完成信号是布尔,没有第三个取值,恒为「未完成」不是故障。
没有环境完成信号的环境就是这么返回的GovDoc 全部dissect 的几个环境都是初稿把它
定成可为空表示取不到并把空值判为环境故障照那个写法 GovDoc 的每一次运行都会在
第一步撞环境故障终止
"""
outcome = await action_executor.execute(action_samples.executes_cleanly)
assert outcome.env_reported_completion in (True, False)
async def test_action_error_is_a_normal_observation_not_an_env_error(
self, action_executor, action_samples, records
):
"""动作本身报错是正常观察,要原样回喂让模型自己纠正,不是环境故障。
代码抛异常命令返回非零都属于这一类判成环境故障会让一次运行在模型本来能自我纠正
的地方直接终止而轨迹上看不出它本可以继续只有环境自己坏了连不上协议不对
另算
"""
outcome = await action_executor.execute(action_samples.executes_but_errors)
assert outcome.status == records.action_status.EXECUTED
assert outcome.observation != ""
async def test_cancellation_propagates_and_is_not_swallowed(
self, action_executor, action_samples
):
"""取消要能穿过动作执行,`CancelledError` 不许被捕获吞没。
吞掉它的后果不是取消失败这么直白是容器租约连接和临时目录持续泄漏而且一声
不吭这条是 `CLAUDE.md` §1.6对每一个执行器实现都成立
**`cancel()` 返回 `False` 是能力判定不是失败** 一个在单个事件循环 tick 之内就返回
的执行器取消发出去时它已经跑完没有任何机会吞掉取消这条契约对它无从谈起把它
判成不合格会让这条用例的成败取决于实现跑得多快而一个偶发变红的准入标准会训练下游
忽略红那比少验一条糟
"""
task = asyncio.ensure_future(action_executor.execute(action_samples.executes_cleanly))
await asyncio.sleep(0)
if not task.cancel():
pytest.skip(
"承诺:取消要能穿过动作执行,CancelledError 不许被捕获吞没。"
"这一次验不了——被测实现在一个事件循环 tick 之内就返回了,取消发出去时它已经跑完,"
"根本没有机会吞掉取消。跑得太快不是违约,这条契约对它无从谈起,不必去改实现。"
"自己验:只有在实现真的有等待点(网络往返、子进程、容器会话)时这条才验得出来。"
)
with pytest.raises(asyncio.CancelledError):
await task
def test_status_trigger_conditions_are_asserted_against_the_library_not_here(self):
"""三个状态的触发条件(`design/0007` 决策一)验不到这一层,原因在这里。
触发条件是**执行器自己的判断**动作真的跑过了记 `EXECUTED`哪怕它报错没进执行
`NOT_EXECUTED`环境自己坏了记 `ENV_ERROR`这套件面对的是一个任意实现没有办法
逼它进入后两档拿一个几乎不可能存在的工具名去探会把 dissect 那种动作语言里
根本没有工具名状态恒为 `EXECUTED` 的合法实现判成不合格
库这一侧的连带后果是能验的也验了`ENV_ERROR` 必然导致 `StopReason.ENV_ERROR`
`NOT_EXECUTED` 不终止运行两条由库自己的测试守着不在这个接缝的契约里
这条留成一条跳过是为了让下一个想在这儿补断言的人先看到上面那段
"""
pytest.skip(
"承诺:动作跑过了记 EXECUTED(哪怕它报错),没进执行记 NOT_EXECUTED"
"环境自己坏了记 ENV_ERROR。"
"这一层验不了——套件面对的是一个任意实现,没有办法逼它进入后两档,"
"而拿一个不存在的工具名去探,会把状态恒为 EXECUTED 的合法实现判成不合格。"
"自己验:在你自己实现的测试里,用你那套动作语言各造一个走到后两档的动作。"
"后两档在库这一侧的连带后果由库自己的测试守着。"
)
def test_failure_is_expressed_as_a_return_value_asserted_in_the_library_not_here(self):
"""环境故障走返回值、抛出的异常库不接管(`design/0016` 决策一),验不到这一层。
环境自己坏了连不上协议不对开好的会话没了执行器返回 `ActionStatus.ENV_ERROR`
不要以异常表达实现方真的抛了异常库不捕获异常原样穿出 `run()` `resume()`调用方
拿到的是那个异常本身不是一个正常返回的运行结果
这套件面对的是一个任意实现没有办法逼它进入环境故障那一档真去把它的网络掐掉既不
可移植也会把那些根本没有网络的合法实现判成不合格这和三个状态的触发条件验不到那一层
是同一个原因
`asyncio.CancelledError` 是那个必须穿过去的异常它验得到断言在取消那条用例里库为什么
不像模型调用接缝那样把这个异常接住 `research-wiki/design/0016-action-executor-failure.md`
这条留成一条跳过是为了让下一个想在这儿补断言的人先看到上面那段
"""
pytest.skip(
"承诺:环境自己坏了走 ActionStatus.ENV_ERROR 返回值,不要以异常表达;"
"实现方真的抛了异常,库不捕获,异常原样穿出 run() 与 resume()。"
"这一层验不了——套件面对的是一个任意实现,没有办法逼它进入环境故障那一档,"
"而真去把它的网络掐掉既不可移植,也会把根本没有网络的合法实现判成不合格。"
"自己验:在你自己实现的测试里,用你那套动作语言造一个真的走到环境故障的动作。"
"抛出的异常穿出 run()、日志停在「动作意图有、结果无」、续跑判成状态未知,"
"这些连带后果由库自己的测试守着。"
)
def test_the_observation_substitution_is_asserted_in_the_library_not_here(self):
"""动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
执行器照常填自己的 `observation`它不该知道库会不会采用也不必知道那段文本仍然
一步走完记录原样落盘被拒绝那一档下它是日志里唯一的拒绝说明
`design/0013` 决策六库只是不让它进历史因为模型看得见的东西必须能进参数快照
库替换了它是整次运行的行为不在这个接缝的契约里
"""
pytest.skip(
"承诺:动作被拒绝时进历史的那段观察由库合成,执行器填的那段仍原样落盘。"
"这一层验不了——替换发生在库这一侧,要跑完一次完整运行再读日志才看得见,"
"而这个接缝只看得见一个执行器实现。"
"自己验:不用验。执行器照常填自己的 observation 就是合规的,"
"「库替换了它」由库自己的测试守着。"
)
+130
View File
@@ -0,0 +1,130 @@
"""决策解释接缝的行为契约。
两个已知形态一个从代码围栏里抽 Python 源码一个从 JSON 里抽工具名与参数库不带任何
默认实现带了就等于替某一家定了动作语言
## 写这套用例时撞出来的那个问题,`design/0007` 决策三答了
模型输出完全无法解释时解释器返回无效决策不抛异常下面最后一条断言它
"""
from typing import Any
import pytest
from polyloop.ports import DecisionParser, InvalidDecision
from polyloop.testing._records import ContractBase
class DecisionParserContract(ContractBase):
"""决策解释接缝的准入套件。下游继承它,覆盖 `decision_parser` 与 `reply_samples`。"""
@pytest.fixture
def decision_parser(self) -> DecisionParser:
"""被测的决策解释接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `decision_parser` fixture,返回一个 DecisionParser 实现的实例。"
)
@pytest.fixture
def reply_samples(self) -> Any:
"""被测解释器认得的两段模型回复,由实现方提供。
**套件不许自己写死输入** 库不带默认解释器实现也就不认识任何一家的动作语言拿一家的
代码围栏去喂另一家的 JSON 解析器后者正确地返回无效决策而写死输入的套件会把这个
正确行为判成失败这不是给套件开后门我这套语言里什么算合法动作本来就只有实现方
答得出套件断言的是**拿到之后的形状**不是输入长什么样
返回一个带两个属性的对象两个属性都是 `polyloop.types.ModelReply`
- `yields_an_action`喂给被测解释器会走到动作那一支的回复
- `yields_invalid`喂给被测解释器会走到无效决策那一支的回复
套件会对同一个样本调用 `parse` 不止一次两次的结果必须一样一个靠内部计数器换答案
的解释器会让用例的成败取决于它们的执行顺序 pytest 的执行顺序不是承诺
**`yields_an_action` 的解释过程不许有外部副作用**套件只是解释它不会执行解释出来
的动作但一个在 `parse` 里就发请求的实现会在跑准入时真的发出去
**退化情况一个什么都解释得出来的实现仍然要给出 `yields_invalid`** 找不到就
说明这个实现把无效决策那一支变成了死代码而契约要求那一支存在模型输出不合格式是
每天都在发生的事那一支迟早会被走到真的构造不出来就在子类里重写用到它的那两条
用例并写明理由不要拿一个其实解释得出动作的回复来充数那样套件照样绿而绿的含义
这一支对变成了这一支没验
"""
raise NotImplementedError(
"在你的子类里覆盖 `reply_samples` fixture,返回一个带 `yields_an_action` 与 "
"`yields_invalid` 两个 ModelReply 属性的对象。"
)
def test_parse_is_synchronous(self, decision_parser, reply_samples):
"""`parse` 是同步的,不是协程。
解释一次模型回复是纯计算没有等待点写成协程会让每个只想写测试替身的下游多套一层
`async def`也会诱导实现方在里面做 I/O而这个接缝一旦做起 I/O恢复时重新解释
被打断的那一步就不再是安全操作了
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert not hasattr(parsed, "__await__")
def test_history_text_is_what_goes_back_into_the_conversation(
self, decision_parser, reply_samples
):
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
解释器有权改写它dissect 的解析器把第一个代码围栏之后的内容整段丢掉因为模型常在
代码块后面编造执行结果库这边只有模型原文照它回填模型下一轮会看见自己编的
那段而迁移前它看不见
**不断言它不长于模型原文** 端口承诺的只有可以改写而改写既可能是截短也可能是
补写把一次 JSON 工具调用规范化成一段人读得懂的 assistant 文本给被 stop 序列截断的
代码围栏补回结尾的三反引号两种都没有违反端口的任何一条长度却都会超过原文断言
长度就是在准入标准里凭空多加一条端口没写过的约束那些实现会在这里变红而它们没有
任何地方需要改
**输入由被测实现自己提供**不由套件写死库不带默认实现也就不认识任何一家的动作
语言 dissect 的代码围栏去喂 GovDoc JSON 解析器它正确地返回无效决策
而套件会把这个正确行为判成失败
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert isinstance(parsed.history_text, str)
def test_invalid_decision_explanation_is_what_is_fed_back(self, decision_parser, reply_samples):
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
dissect 的解析器对五种解析失败各有一条对症说明没有代码块空的未闭合块闭合围栏后
跟了别的内容多块策略下第一块为空拼接策略下全空压成一句会改掉它的实验条件
模型收到的纠错信息变了它的纠错行为也就变了
"""
parsed = decision_parser.parse(reply_samples.yields_invalid)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form(self, decision_parser, reply_samples):
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
dissect 传那段 Python 源码GovDoc 传序列化后的参数没有这个字段dissect 轨迹里那一列
会被库改写而那个文件是它的反思模型的唯一输入界面
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert isinstance(parsed.decision.text, str)
def test_unparseable_output_returns_invalid_decision_rather_than_raising(
self, decision_parser, reply_samples
):
"""模型输出完全无法解释时返回「无效决策」,不抛异常(`design/0007` 决策三)。
两条路后果完全不同返回无效决策那一步照常留痕说明文本回喂给模型循环继续
异常库要么把它翻译成某个停止原因终止整次运行要么让它穿出去炸掉调用方
dissect 的解析器不抛异常所以它撞不到这个分歧但契约测试是**任何新适配器的准入
标准**所以这条要正面断言不能靠反正没人这么写
"""
parsed = decision_parser.parse(reply_samples.yields_invalid)
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation != ""
+86
View File
@@ -0,0 +1,86 @@
"""事件出口的行为契约。
两个已知形态差别在可靠性要求上一个把进度逐步回写业务数据库供前端轮询要求低延迟
可以丢一个把审计事件送进日志管道要求不丢可以慢
## 写这套用例时撞出来的问题,`design/0013` 答了
发出去的事件里有什么当时验不了因为 `Event` 只有一个名字没有字段现在事件集定下来了
而答案把这里的两条用例都挪走了它们要断言的行为都在库那一侧不在出口这一侧见文末
那两条说明
"""
import pytest
from polyloop.ports import EventSink
from polyloop.testing._records import ContractBase
class EventSinkContract(ContractBase):
"""事件出口的准入套件。下游继承它,覆盖 `event_sink`。"""
@pytest.fixture
def event_sink(self) -> EventSink:
"""被测的事件出口实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `event_sink` fixture,返回一个 EventSink 实现的实例。"
)
async def test_emit_accepts_an_event(self, event_sink, records):
"""能收下一个事件,正常路径不抛异常。"""
await event_sink.emit(records.event())
def test_a_raising_sink_is_compliant_so_this_layer_asserts_nothing(self):
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
契约写的是投递失败由****捕获记日志把失败计数加一然后继续跑所以要断言的
行为在库那一侧不在出口这一侧原来这里写了一条 `await emit(...)` 不抛的断言那会把
一个完全合法的审计 sink 判失败它在日志管道不可用时抛 `ConnectionError`而库本来就
该接住
库接住了失败并继续跑属于整次运行的行为不在这个接缝的契约里这条留成一条跳过
是为了让下一个想在这儿加断言的人先看到这段
"""
pytest.skip(
"承诺:投递失败由库捕获、记日志、计数加一,然后继续跑,所以一个会抛异常的出口"
"是合规实现。"
"这一层验不了——「库接住了失败还在跑」要跑完一次完整运行才看得见,"
"而这个接缝只看得见一个出口实现。"
"自己验:不用验,也不要在这里补一条「emit 不抛」的断言——那会把一个在后端不可用时"
"抛异常的合法出口判成不合格。"
)
def test_the_no_re_emission_guarantee_is_asserted_in_the_library_not_here(self):
"""投递失败不再转成一条事件从同一个出口发出去(`design/0013` 决策七)。
那会自我喂食一个持续失败的出口会让失败处理路径变成递归而递归的表现是进程卡住或
栈溢出不是一条错误日志
**要断言的是库有没有再发一次那是整次运行的行为**所以断言由库自己的测试守着
那边用一个恒抛异常的出口跑完一次运行验出口收到的条数恰好等于步数这个接缝自己
看不到库发了几次
"""
pytest.skip(
"承诺:投递失败不会被转成一条事件从同一个出口再发一次。"
"这一层验不了——「库发了几次」是整次运行的行为,一个出口实现自己数不出来。"
"自己验:不用验,这条约束的是库不是出口;库那边用一个恒抛异常的出口跑完一次运行,"
"验出口收到的条数恰好等于步数。"
)
def test_the_audit_trail_is_asserted_against_the_log_not_here(self):
"""审计纪律由存储承担,不由事件流承担(`design/0013` 决策二)。
GovDoc 有一条硬纪律agent 的原始输出修复后的输出恢复来源全程留痕禁止静默修复
这条测试原来断言事件要同时带原文与修复后的文本而那个前提是错的事件流可丢
一件只存在于可丢通道里的事实撑不起禁止静默修复
两份文本在意图日志里各有位置原文在模型调用结果记录的回复里修复后的那份是步记录的
`raw_output`断言落在库那边因为要跑完一次完整运行再把日志读回来而这个接缝的契约
只看得见一个出口实现
"""
pytest.skip(
"承诺:原始输出与修复后的输出全程留痕,而承担它的是意图日志,不是事件流——"
"事件流可丢,一件只存在于可丢通道里的事实撑不起「禁止静默修复」。"
"这一层验不了——要跑完一次完整运行再把日志读回来,而这个接缝只看得见一个出口实现。"
"自己验:靠存储接缝那套契约(RunStoreContract),不要指望事件里带着这两份文本。"
)
+121
View File
@@ -0,0 +1,121 @@
"""模型调用接缝的行为契约。
**这一层不打真实网关**那是 e2e 的事这里断言的是返回结构体的形状与失败的表达方式
用一个受控替身就能验
两个已知形态一个按三本账各记一条并自己按价格表算成本一个在调用外面套退避并累加本次
运行的 token
"""
import asyncio
import inspect
import pytest
from polyloop.ports import ModelCall, ModelClient
from polyloop.testing._records import ContractBase
class ModelClientContract(ContractBase):
"""模型调用接缝的准入套件。下游继承它,覆盖 `model_client` 与 `failing_call`。"""
@pytest.fixture
def model_client(self) -> ModelClient:
"""被测的模型调用接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `model_client` fixture,返回一个 ModelClient 实现的实例。"
)
@pytest.fixture
def failing_call(self) -> ModelCall:
"""一次拿去调用被测客户端会失败的调用,由实现方提供。
**套件不许自己写死它** 一个实现要怎样才会失败只有它自己知道可能是一个不存在的
模型名一条空得过不了校验的消息序列一个指向黑洞的端点套件这边随便定一个暗号
比如约定 `result_id` 等于某个特定串时就该抛等于替所有实现定了一份它们从没同意过
的协议一个真网关适配器不认识那个暗号于是它调用成功用例判它不合格而它其实是
对的
返回一个 `polyloop.ports.ModelCall`拿它调用被测客户端**必须抛异常**抛什么类型由
实现定契约只要求
**这次调用不许真的花钱**也不许在失败之前留下外部副作用失败要发生在调用真的打出去
之前或之中最省的做法是让它在入参校验那一关就挂掉
**它和别的用例共用同一个 `model_client` 实例**所以失败之后那个实例必须还能接着服务
一次失败的调用把客户端弄成不可用本身就不合格
**退化情况不存在无论如何都不会失败的合规实现** 契约规定失败以异常表达不以
返回一个内容为空的正常回复表达库靠这个区分基础设施故障与模型真的回了空字符
而这两者在分析里属于完全不同的类别所以一个给不出 `failing_call` 的实现要么
是把失败吞成了空回复那就是不合格正是这条用例要抓的要么是还没想过失败路径
真的构造不出来时最接近的合规做法是让实现在入参校验那一关抛而不是把这条用例重写掉
"""
raise NotImplementedError(
"在你的子类里覆盖 `failing_call` fixture,返回一个拿去调用被测客户端会抛异常的 "
"ModelCall。"
)
async def test_returns_three_fields(self, model_client, records):
"""返回三个字段:调用标识、可见回复、推理段。
可见回复与推理段的长度由库自己数字符不从任何用量对象取实测中转网关会用本地分词器
补算并整体替换用量对象把明细一起吃掉某次标定里 24 次调用的推理 token 全部没上报
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert isinstance(reply.content, str)
assert isinstance(reply.thinking, str)
assert reply.call_id is None or isinstance(reply.call_id, str)
async def test_call_id_is_never_an_empty_string(self, model_client, records):
"""调用标识可以是「没有」,但绝不能是空串。
它是轨迹与账目之间唯一的连接键空串是个看起来合法的键连表时静默匹配不上显式
没有至少能被筛出来它为空的合法含义只有一个调用在记账之前就失败了
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert reply.call_id != ""
async def test_failure_is_expressed_as_an_exception(self, model_client, failing_call):
"""调用失败以异常表达,不以「返回一个空回复」表达。
库接住它翻译成模型故障记一条调用标识为空的步如果失败被表达成一个内容为空串的
正常返回库没有任何办法把它和模型真的回了空字符串分开而后者是模型行为前者
是基础设施故障两者在分析里属于完全不同的类别
"""
with pytest.raises(Exception): # noqa: B017 具体异常类型归实现,契约只要求「抛」
await model_client.call(failing_call)
async def test_cancellation_propagates_and_is_not_swallowed(self, model_client, records):
"""取消要能穿过模型调用,`CancelledError` 不许被捕获吞没。
**`cancel()` 返回 `False` 是能力判定不是失败** 一个在单个事件循环 tick 之内就返回
的客户端一个受控替身就是取消发出去时它已经跑完没有任何机会吞掉取消把它判成
不合格会让这条用例的成败取决于实现跑得多快而一个偶发变红的准入标准会训练下游忽略红
"""
task = asyncio.ensure_future(
model_client.call(records.model_call(call_index=0, result_id="m0"))
)
await asyncio.sleep(0)
if not task.cancel():
pytest.skip(
"承诺:取消要能穿过模型调用,CancelledError 不许被捕获吞没。"
"这一次验不了——被测实现在一个事件循环 tick 之内就返回了,取消发出去时它已经跑完,"
"根本没有机会吞掉取消。跑得太快不是违约,这条契约对它无从谈起,不必去改实现。"
"自己验:只有在实现真的有等待点(网络往返、子进程、容器会话)时这条才验得出来。"
)
with pytest.raises(asyncio.CancelledError):
await task
def test_signature_carries_no_retry_or_rate_limit_parameters(self, model_client):
"""签名里不出现重试次数、退避时长、限流配额。
出现即意味着库在治理一次模型调用而那归 PolyGateway`CLAUDE.md` §1.5这条断言的是
名字不是行为 §1.8公共 Protocol 的签名本身就是对下游的承诺断言它是应该的
"""
names = set(inspect.signature(model_client.call).parameters)
assert not (names & {"retries", "max_retries", "backoff", "timeout", "rate_limit"})
+26
View File
@@ -0,0 +1,26 @@
"""pytest 插件入口。它什么都不做,存在的理由全在这段话里。
`pyproject.toml` `[project.entry-points.pytest11]` 指向这个模块pytest 启动时会先扫一遍
所有声明了 `pytest11` 入口的发行包把它们的每一个 `.py` 文件标记为**断言可重写**然后才
加载插件本身换回来的就是这个标记契约模块不在下游的 `python_files` 匹配范围里默认不被
重写于是一条契约用例失败时下游看到的是光秃秃的 `AssertionError`没有等号两边的值没有
差异摘要
**删掉这个模块不会有任何东西报错**下游只会觉得这套契约的报错难读而且永远不会知道自己
少了什么这是一次纯静默的退化所以它值得一个只有 docstring 的模块
**这个模块只 import pytest 与标准库** import 任何存储实现 import `polyloop.session`
import `polyloop.adapters`最后一条尤其硬`adapters` 会把网关连同它的 provider 目录一起
拉起来 pytest 自动加载插件是那条依赖规则的一条新触发路径这条边界不靠自觉
import-linter 的分层契约与 testing 外一切禁止 import pytest两条契约把它钉住
**这里不接管事件循环**理由见 `research-wiki/design/0014-contract-suite-distribution.md`
决策三末尾下游的异步 fixture 在它自己的循环里创建库另开一个循环会让那些 asyncio 对象
跨循环而跨循环的对象炸得很难查
"""
import pytest
def pytest_configure(config: pytest.Config) -> None:
"""空钩子。断言重写在 pytest 加载插件之前就按发行包做完了,这里没有要补的事。"""
@@ -1,46 +1,31 @@
"""契约套件的装配点。
这套测试**不针对任何具体实现**它写的是不管你怎么实现都必须满足这些行为所以它
自己不造实现只声明你得提供什么任何一个下游写完自己的存储或适配器把它接到这里
fixture 上跑一遍全绿就算合格这是 `CLAUDE.md` §0 说的任何新适配器的准入标准
**接法**下游在自己的 `conftest.py` 里覆盖同名 fixture返回自己的实现
**为什么在实现之前就写它**写一条契约测试要求把每一次调用逐字写出来方法叫什么参数
填什么返回值怎么取散文里读着通顺的地方落到这一步就会露出来前三轮文档评审抓不到的
几乎全是这么冒出来的
"""
"""构造各类记录的工厂,以及把它递给用例的那个共同父类。"""
from collections.abc import Mapping
import pytest
from polyloop.ports import Action, Event, EventKind, ToolCall
from polyloop.stores import JsonlRunStore
from polyloop.ports import Action, Event, EventKind, ModelCall, ToolCall
from polyloop.types import (
ActionOutcome,
ActionStatus,
Intent,
IntentKind,
Message,
ModelCallResult,
ModelReply,
ReplayPolicy,
Role,
RunFinished,
RunResult,
RunStarted,
StepCompleted,
StepRecord,
StopReason,
)
#: 还没有默认实现的那几个接缝,套件里对应的测试全部跳过。
_NO_IMPLEMENTATION = (
"库不带这个接缝的默认实现——带了就等于替某一家定了它的协议。"
"下游在自己的 conftest.py 里覆盖这个 fixture,把自己的实现接进来跑。"
TextBlock,
)
class _Records:
class RecordFactory:
"""构造各类记录的工厂。
它不是被测对象是让测试正文读得懂的一层薄封装`records.model_call_intent(...)` 比直接
@@ -54,6 +39,36 @@ class _Records:
def reply(self, *, call_id: str | None = "call-1", content: str = "hi") -> ModelReply:
return ModelReply(call_id=call_id, content=content, thinking="")
def model_call(
self,
*,
call_index: int,
result_id: str,
run_id: str = "r1",
messages: tuple[Message, ...] | None = None,
binding: Mapping[str, str] | None = None,
) -> ModelCall:
"""造一次交给模型调用接缝的调用。
`run_id` 有默认值而记录类的那几个工厂方法一律要求显式传区别在于这个壳不进任何
一份日志没有哪条用例靠它区分两个运行的记录记录类那边的默认值会让
两个运行互相看不见对方那条用例悄悄退化成同一个运行写了两次
`messages` 默认给一条用户消息而不是空序列一个真实的实现拿到空消息序列多半直接
拒绝于是这条用例验的就变成了它的入参校验不是它的返回结构
"""
return ModelCall(
messages=(
messages
if messages is not None
else (Message(role=Role.USER, content=(TextBlock(text="你好"),)),)
),
call_index=call_index,
run_id=run_id,
result_id=result_id,
binding=dict(binding) if binding is not None else {},
)
def model_call_intent(self, *, run_id: str, call_index: int, result_id: str) -> Intent:
return Intent(
run_id=run_id,
@@ -164,65 +179,15 @@ class _Records:
)
@pytest.fixture
def records() -> _Records:
return _Records()
class ContractBase:
"""五个契约基类的共同父类,只为把记录工厂递给用例。
@pytest.fixture
def store(tmp_path) -> JsonlRunStore:
"""被测的存储接缝实现。
默认接的是库自带的那个逐行追加实现下游覆盖这个 fixture返回自己的实例
每次调用返回一个**空的**存储套件里每条测试都假设自己面对一份干净的日志共用状态会让
测试之间的顺序变成隐式依赖`tmp_path` 每条测试一个新目录这一条自动成立
它不是 `research-wiki/design/0014-contract-suite-distribution.md` 决策二第七条否掉的那种
fixture mixin那条否的是让下游在自己的子类上多继承一个 fixture 这个父类下游既看不见
也用不着`records` 由套件自己提供没有哪个实现需要覆盖它
"""
return JsonlRunStore(directory=tmp_path)
@pytest.fixture
def samples():
"""被测解释器认得的几段模型输出,由实现方提供。
**套件不许自己写死输入** 库不带默认解释器实现也就不认识任何一家的动作语言拿一家的
代码围栏去喂另一家的 JSON 解析器后者正确地返回无效决策而写死输入的套件会把这个
正确行为判成失败
实现方要提供两段`yields_an_action`一段能被解释成动作的模型回复 `yields_invalid`
一段解释不出动作的这不是给套件开后门我这套语言里什么算合法动作本来就只有
实现方答得出套件断言的是**拿到之后的形状**不是输入长什么样
"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def action_executor():
"""被测的动作执行接缝实现。
库自带一个由工具注册表派生的分发器`polyloop.tools.ToolRegistry.executor`但它只覆盖
工具调用那一种动作语言把它接在这里会让套件只验得了那一种所以默认仍然留空
"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def decision_parser():
"""被测的决策解释接缝实现。"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def model_client():
"""被测的模型调用接缝实现。
注意这一层的契约测试**不打真实网关**那是 e2e 的事这里断言的是返回结构体的形状与
失败时的表达方式用一个受控的替身就能验
"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def event_sink():
"""被测的事件出口实现。"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def records(self) -> RecordFactory:
"""记录工厂。套件自己提供,不需要在子类里覆盖。"""
return RecordFactory()
+247
View File
@@ -0,0 +1,247 @@
"""存储接缝的行为契约。
这套用例是一次运行的日志到底保证什么的权威`CLAUDE.md` §0两个已知实现形态差别
很大一个逐行追加本地文件一个写关系数据库所以下面每一条都只说行为不碰形态
**行为的理由不在这里** 崩溃恢复为什么这么设计见 `design/0002`写入粒度与前缀持久性见
`design/0005`这里只断言结果
## 无条件跳过的那两条
它们是**已知没有机器兜底的承诺**不是还没写的测试写成跳过而不是一句注释是为了让它们
在每次跑套件时都被看见`pytest -rs` 会把跳过的理由列出来
不写成 `xfail`因为一个做得比库预期更好的实现会把这种测试跑通于是拿到 XPASS 判失败
而下游取消不掉子类上加一个类级 xfail 覆盖不了从函数级继承下来的那个唯一的出路是整条
重写方法语义上跳过也更准这两条说的不是这个功能预期会失败而是套件所在的这一层
没有能力验证它
剩下那条曾经答不上的没有动作的步 `StepCompleted.result_id` 填什么已经由 `design/0006`
决策七答掉可为空且为空当且仅当动作结果也为空对应的测试已经改写成真断言
"""
import pytest
from polyloop.ports import RunStore
from polyloop.testing._records import ContractBase
class RunStoreContract(ContractBase):
"""存储接缝的准入套件。下游继承它,覆盖 `store`。"""
@pytest.fixture
def store(self) -> RunStore:
"""被测的存储接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。
**每次调用要返回一个空的存储** 套件里每条用例都假设自己面对一份干净的日志共用
状态会让用例之间变成隐式的顺序依赖那种依赖只在换个顺序跑的那天才暴露而那天
通常是加了一条新用例之后于是错误看起来来自那条新用例
"""
raise NotImplementedError(
"在你的子类里覆盖 `store` fixture,返回一个 RunStore 实现的实例;"
"每次调用都要返回一个空的存储。"
)
# ----------------------------------------------------------------------
# 一、写进去的读得回来
# ----------------------------------------------------------------------
async def test_written_intent_is_readable(self, store, records):
"""写一条意图,读回整份日志时它必须在里面。
这是全套最基本的一条意图日志的全部意义是比进程活得久写了读不回来后面每一条
恢复语义都建立在空气上
"""
intent = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
async def test_log_of_unknown_run_is_empty_not_an_error(self, store):
"""读一个从没写过的运行标识,得到一份空日志,而不是异常。
`run` 在开工前要判断这个标识是不是已经有日志了靠的就是这一条如果读不存在的
运行会抛异常那个判断就得写成捕获异常而捕获异常来做流程控制会把真正的存储故障
一起吞掉
"""
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_two_runs_do_not_leak_into_each_other(self, store, records):
"""两个运行标识各写各的,互相看不见对方的记录。
端口不持有当前运行的隐式状态这条测试是那个要求的外部可观测形式一个有隐式当前
运行的实现会在并发下把 A 的意图写进 B 的日志而那种错在单线程测试里永远不出现
"""
a = records.model_call_intent(run_id="run-a", call_index=0, result_id="m0")
b = records.model_call_intent(run_id="run-b", call_index=0, result_id="m0")
await store.write_intent(a)
await store.write_intent(b)
assert (await store.read_log("run-a")).intents == (a,)
assert (await store.read_log("run-b")).intents == (b,)
# ----------------------------------------------------------------------
# 二、四态:恢复靠「意图有没有 / 结果有没有」判定
# ----------------------------------------------------------------------
async def test_intent_without_result_is_readable_as_such(self, store, records):
"""写了意图、没写结果,读回来必须能看出「这个 ID 没有结果」。
这是四态表里状态未知那一档的输入存储不负责判定但它必须让判定问得出口恢复
要按预分配的 ID 精确地问而不是模糊匹配去猜哪条结果对应哪次执行
"""
intent = records.action_intent(run_id="r1", call_index=0, result_id="a0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
assert all(step.result_id != "a0" for step in log.steps)
async def test_result_without_intent_is_visible_to_the_reader(self, store, records):
"""只写结果不写意图,读回来必须原样可见,存储自己不许修复也不许拒收。
有结果没意图是日志损坏处置是拒绝续跑但那个判断归恢复逻辑不归存储存储在
这里悄悄补一条意图或者拒绝这次写入都会让损坏变得不可见而不可见的损坏会被当成
正常数据继续用下去
"""
result = records.model_call_result(run_id="r1", result_id="orphan", reply=records.reply())
await store.write_model_call_result(result)
log = await store.read_log("r1")
assert log.model_results == (result,)
assert log.intents == ()
async def test_failed_model_call_is_recorded_as_a_result_not_as_nothing(self, store, records):
"""模型调用失败也要落一条结果记录,否则恢复会把它读成「状态未知」。
失败这件事是确定的调用发出去了失败了库记了一条步如果这时不写结果条目恢复
只看见意图有结果无走重放策略而这次调用的状态一点都不未知下游按停止原因
做的统计会照单收下这个错误
"""
result = records.model_call_result(
run_id="r1", result_id="m0", reply=None, failure="连接超时"
)
await store.write_model_call_result(result)
(readback,) = (await store.read_log("r1")).model_results
assert readback.reply is None
assert readback.failure == "连接超时"
# ----------------------------------------------------------------------
# 三、原子写
# ----------------------------------------------------------------------
async def test_action_result_and_step_land_together(self, store, records):
"""动作结果与步记录一次原子落地:读回来要么两者都在,要么都不在。
不原子的话崩在两者之间会让那一步的历史文本永远丢失而恢复判定会把它读成执行完了
跳过恢复出来的消息序列比不中断跑完时少一轮后面每一步都跟着偏
**这条测试只能验一起可见验不了一起不可见** 见本文件末尾那条
"""
step = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(),
step=records.step(step_idx=0),
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].action_outcome is not None
async def test_step_without_an_action_is_still_recorded(self, store, records):
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
预算对等要求它们计入步数它们确实消耗了一次模型调用丢掉那一步还会丢掉模型在出故障时
说了什么而那正是排查环境坏了还是模型写了危险代码最需要的
这条曾经写不出来那时 `StepCompleted.result_id` 是必填字符串而这一步没写过动作意图
没有预分配的 ID随便编一个会让恢复读到一条对不上任何意图的记录按四态表最后一行判成
日志损坏`design/0006` 决策七把它改成可为空并要求**它为空当且仅当动作结果也为空**
这个洞才补上存储要能原样存下这个形状
"""
step = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].result_id is None
assert log.steps[0].action_outcome is None
# ----------------------------------------------------------------------
# 四、运行的开始与结束
# ----------------------------------------------------------------------
async def test_run_finished_is_visible_before_the_result_is_returned(self, store, records):
"""「这次运行结束了」这个标记由库写下,而且写在把结果交给调用方之前。
另一条路有个具体的失败场景结果由项目落盘的话跑完了库返回了项目存的时候崩了
这种情况下重启后日志显示最后一步有结果没有结束标记而项目那边什么都没有续跑会
重复执行最后一步的副作用不续跑就丢掉一次已经花完钱的运行歧义来自结果跨了两个存储
"""
finished = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
await store.write_run_finished(finished)
assert (await store.read_log("r1")).finished == finished
async def test_run_started_carries_the_parameter_snapshot(self, store, records):
"""运行开始记录带着这次的参数快照,续跑时拿它与当前装配比对。
没有它用同一个运行标识换一份定义续跑前几步与后几步会来自两个不同的配置而全程零
报错那正是要到统计阶段才分不清哪些行是真的那类损坏
"""
started = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
await store.write_run_started(started)
assert (await store.read_log("r1")).started.parameter_snapshot == {"model": "m-1"}
# ----------------------------------------------------------------------
# 五、这套测试**验不了**的两条承诺
# ----------------------------------------------------------------------
def test_atomicity_under_crash_is_not_checkable_here(self):
"""原子性的另一半——「崩在中间时两者都不可见」——这一层验不了。
要验它得在写入过程中把进程杀掉而契约测试跑在一个进程里面对的是一个已经装配好的
实现没有位置插入那次崩溃给端口加一个故意在这里失败的钩子能验但那个钩子会
变成公共 API 的一部分而它只为测试存在
结论是这条承诺**没有机器兜底**只能靠 `CLAUDE.md` §3 那轮对抗审查看实现把这件事
写成一条跳过而不是一句注释是为了让它在每次跑套件时都被看见
"""
pytest.skip(
"承诺:动作结果与步记录一起落地,崩在中间时两者都不可见。"
"这一层验不了——套件跑在一个进程里、面对一个装配好的实现,没有位置插入那次崩溃,"
"而为它加一个「故意在这里失败」的钩子会把测试用的东西变成公共 API。"
"自己验:在你自己实现的单元测试里用可注入的故障点覆盖它,"
"或者按 CLAUDE.md §3 第四类走一轮对抗审查看实现。"
)
def test_prefix_durability_is_not_checkable_here(self):
"""前缀持久性同样验不了,理由更硬一层。
它说的是 k 次写入被确认持久时 k-1 次也已经持久已经持久是掉电之后
才看得出来的性质在一个进程里读得回来不等于它落了盘
"""
pytest.skip(
"承诺:第 k 次写入被确认持久时,第 1 到 k-1 次也已经持久。"
"这一层验不了——「已经持久」是掉电之后才看得出来的性质,"
"在一个进程里读得回来不等于它落了盘。"
"自己验:它实际是对实现形态的约束(同一文件的追加写、同一连接上顺序提交的事务"
"天然满足它),靠评审看你的实现属不属于这种形态。"
)
+4 -1
View File
@@ -69,7 +69,10 @@ class ToolHandler(Protocol):
def _frozen(value: object) -> object:
"""把一份 JSON Schema 逐层变成改不动的形状:映射变只读视图,列表变元组。
"""把一份嵌套结构逐层变成改不动的形状:映射变只读视图,列表变元组。
工具的参数 schema 是第一个用它的地方`polyloop.session` 的请求也用它冻自己那几个映射
字段两处要防的是同一件事所以共用这一个函数而不是各写一份
只冻最外面一层不够真正会发生的改法是从注册表里把规格取出来往里伸一层去改
`spec_for("read").parameters["properties"]["path"]["type"] = ...`那一下同时改掉了
+6 -3
View File
@@ -88,8 +88,11 @@ class Injection:
库只负责贴和记录贴了什么不负责生成评测挑选
"""
#: 这条条目的标识,原样进轨迹。正文不进快照,只有它进——所以「这次贴了哪几条」事后
#: 查得到,查不到的只是正文。
#: 这条条目的标识,原样进运行开始记录里的参数快照。正文不进快照,只有它进——所以
#: 「这次贴了哪几条」事后查得到,查不到的只是正文。**它不进轨迹**:轨迹是步记录的
#: 序列,里面没有这一列,照着「进轨迹」去找会在步记录里翻一个不存在的东西。
#: **标识里不要放逗号。** 快照把一个通道里的标识拼成逗号串,标识里有逗号的话两组不同的
#: 注入可能拼出同一个串,于是一次该报的漂移没报(`design/0015` 决策二)。
entry_id: str
content: str
@@ -348,7 +351,7 @@ class Intent:
**两种意图合成一个类型 `kind` 区分**而不是两个类它们字段完全相同分成两个类
之后恢复逻辑要把同一段有意图没结果的判定写两遍而那段判定是高危代码同一个判断
写在两处改的时候必然有一处漏掉代价是类型检查器不再帮忙区分两种意图这个补在
`tests/contract/`
`polyloop.testing` 那套契约套件
"""
run_id: str
-97
View File
@@ -1,97 +0,0 @@
"""动作执行接缝的行为契约。
两个已知形态差别很大一个把一段代码交给已经开好的容器会话状态恒为已执行一个查
工具注册表分发工具不存在或参数不合法时返回未执行下面每一条都要对两者同时成立
## 写这份文件时撞出来的两个问题,`design/0007` 决策一与决策二答了
三个状态取值各自在什么条件下被赋上返回未执行时那段观察由谁给两条的答案都落在
**库这一侧**所以它们的断言不在这份文件里见文末那两条说明
"""
import pytest
pytestmark = pytest.mark.contract
async def test_returns_all_five_fields(action_executor, records):
"""返回值必须带齐五个字段,一个都不能省。
库拿这五个字段填步记录里对应的五列少一个那一列就只能填默认值而默认值与真实值在
轨迹里长得一模一样事后没有任何办法把执行器没给值确实是这个分开
"""
outcome = await action_executor.execute(records.action(text="noop"))
assert outcome.status is not None
assert isinstance(outcome.observation, str)
assert isinstance(outcome.observation_is_synthetic, bool)
assert isinstance(outcome.env_reported_completion, bool)
assert isinstance(outcome.observation_truncated_chars, int)
async def test_completion_signal_is_a_plain_boolean(action_executor, records):
"""完成信号是布尔,没有第三个取值,恒为「未完成」不是故障。
没有环境完成信号的环境就是这么返回的GovDoc 全部dissect 的两个非 AppWorld
benchmark 都是初稿把它定成可为空表示取不到并把空值判为环境故障照那个写法
GovDoc 的每一次运行都会在第一步撞环境故障终止
"""
outcome = await action_executor.execute(records.action(text="noop"))
assert outcome.env_reported_completion in (True, False)
async def test_action_error_is_a_normal_observation_not_an_env_error(action_executor, records):
"""动作本身报错是正常观察,要原样回喂让模型自己纠正,不是环境故障。
代码抛异常命令返回非零都属于这一类判成环境故障会让一次运行在模型本来能自我纠正
的地方直接终止而轨迹上看不出它本可以继续只有环境自己坏了连不上协议不对
另算
"""
outcome = await action_executor.execute(records.action(text="raise RuntimeError()"))
assert outcome.status == records.action_status.EXECUTED
assert outcome.observation != ""
async def test_cancellation_propagates_and_is_not_swallowed(action_executor, records):
"""取消要能穿过动作执行,`CancelledError` 不许被捕获吞没。
吞掉它的后果不是取消失败这么直白是容器租约连接和临时目录持续泄漏而且一声
不吭这条是 `CLAUDE.md` §1.6对每一个执行器实现都成立
"""
import asyncio
task = asyncio.ensure_future(action_executor.execute(records.action(text="sleep")))
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
def test_status_trigger_conditions_are_asserted_against_the_library_not_here():
"""三个状态的触发条件(`design/0007` 决策一)验不到这一层,原因在这里。
触发条件是**执行器自己的判断**动作真的跑过了记 `EXECUTED`哪怕它报错没进执行
`NOT_EXECUTED`环境自己坏了记 `ENV_ERROR`这套件面对的是一个任意实现没有办法
逼它进入后两档拿一个几乎不可能存在的工具名去探会把 dissect 那种动作语言里
根本没有工具名状态恒为 `EXECUTED` 的合法实现判成不合格
库这一侧的连带后果是能验的也验了`ENV_ERROR` 必然导致 `StopReason.ENV_ERROR`
`NOT_EXECUTED` 不终止运行两条在 `tests/unit/test_session.py`
这条留成一个不断言的说明是为了让下一个想在这儿补断言的人先看到上面那段
"""
def test_the_observation_substitution_is_asserted_in_the_library_not_here():
"""动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
执行器照常填自己的 `observation`它不该知道库会不会采用也不必知道那段文本仍然
一步走完记录原样落盘被拒绝那一档下它是日志里唯一的拒绝说明
`design/0013` 决策六库只是不让它进历史因为模型看得见的东西必须能进参数快照
库替换了它是整次运行的行为断言在 `tests/unit/test_session.py`不在这个接缝的
契约里
"""
-84
View File
@@ -1,84 +0,0 @@
"""决策解释接缝的行为契约。
两个已知形态一个从代码围栏里抽 Python 源码一个从 JSON 里抽工具名与参数库不带任何
默认实现带了就等于替某一家定了动作语言
## 写这份文件时撞出来的那个问题,`design/0007` 决策三答了
模型输出完全无法解释时解释器返回无效决策不抛异常下面最后一条断言它
"""
import pytest
from polyloop.ports import InvalidDecision
pytestmark = pytest.mark.contract
def test_parse_is_synchronous(decision_parser, samples):
"""`parse` 是同步的,不是协程。
解释一次模型回复是纯计算没有等待点写成协程会让每个只想写测试替身的下游多套一层
`async def`也会诱导实现方在里面做 I/O而这个接缝一旦做起 I/O恢复时重新解释
被打断的那一步就不再是安全操作了
"""
parsed = decision_parser.parse(samples.yields_an_action)
assert not hasattr(parsed, "__await__")
def test_history_text_is_what_goes_back_into_the_conversation(decision_parser, samples):
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
解释器有权改写它dissect 的解析器把第一个代码围栏之后的内容整段丢掉因为模型常在
代码块后面编造执行结果库这边只有模型原文照它回填模型下一轮会看见自己编的
那段而迁移前它看不见
**输入由被测实现自己提供**不由套件写死库不带默认实现也就不认识任何一家的动作
语言 dissect 的代码围栏去喂 GovDoc JSON 解析器它正确地返回无效决策
而套件会把这个正确行为判成失败
"""
reply = samples.yields_an_action
parsed = decision_parser.parse(reply)
assert isinstance(parsed.history_text, str)
assert len(parsed.history_text) <= len(reply.content)
def test_invalid_decision_explanation_is_what_is_fed_back(decision_parser, samples):
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
dissect 的解析器对五种解析失败各有一条对症说明没有代码块空的未闭合块闭合围栏后
跟了别的内容多块策略下第一块为空拼接策略下全空压成一句会改掉它的实验条件
模型收到的纠错信息变了它的纠错行为也就变了
"""
parsed = decision_parser.parse(samples.yields_invalid)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form(decision_parser, samples):
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
dissect 传那段 Python 源码GovDoc 传序列化后的参数没有这个字段dissect 轨迹里那一列
会被库改写而那个文件是它的反思模型的唯一输入界面
"""
parsed = decision_parser.parse(samples.yields_an_action)
assert isinstance(parsed.decision.text, str)
def test_unparseable_output_returns_invalid_decision_rather_than_raising(decision_parser, samples):
"""模型输出完全无法解释时返回「无效决策」,不抛异常(`design/0007` 决策三)。
两条路后果完全不同返回无效决策那一步照常留痕说明文本回喂给模型循环继续
异常库要么把它翻译成某个停止原因终止整次运行要么让它穿出去炸掉调用方
dissect 的解析器不抛异常所以它撞不到这个分歧但契约测试是**任何新适配器的准入
标准**所以这条要正面断言不能靠反正没人这么写
"""
parsed = decision_parser.parse(samples.yields_invalid)
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation != ""
@@ -0,0 +1,72 @@
"""把决策解释契约接到一个测试替身上。
库不带这个接缝的实现带了就等于替某一家定了动作语言所以这里造一个最小的替身它存在的
唯一目的是让套件的每一条用例至少被真的求值一次一条引用了记录工厂里不存在的方法的用例
只有在被执行的时候才会红
**这个替身住在 `tests/` 不进 wheel任何下游都拿不到它** 库不带默认实现那条禁的是
`src/` 下出现一个能用的实现下游装了包就拿得到就会有人直接用于是动作语言被库替它定了
判据是下游拿不拿得到不是代码库里有没有一个能跑的实现
`research-wiki/design/0014-contract-suite-distribution.md` 决策六
`contract` 这个标记打在本文件上不打在套件里理由见 `test_run_stores.py`
"""
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
from polyloop.ports import Action, InvalidDecision, ParsedReply
from polyloop.testing import DecisionParserContract, RecordFactory
from polyloop.types import ModelReply
#: 这个替身认得的全部动作语言:正文以它开头就是一个动作,剩下的部分是动作本身。
_ACTION_PREFIX = "DO "
pytestmark = pytest.mark.contract
class _PrefixDecisionParser:
"""认一种一行前缀的动作语言,别的一律解释不出动作。
**两支都要走得到**这是契约对样本的要求在替身这一侧的对应一个什么都解释得出来
实现会让无效决策那一支变成死代码而套件照样绿绿的含义从这一支对变成这一支没验
它满足 `polyloop.ports.DecisionParser`但不显式继承那个 Protocol结构化子类型不需要继承
"""
def parse(self, reply: ModelReply) -> ParsedReply:
if reply.content.startswith(_ACTION_PREFIX):
return ParsedReply(
history_text=reply.content,
decision=Action(text=reply.content[len(_ACTION_PREFIX) :], tool_call=None),
)
return ParsedReply(
history_text=reply.content,
decision=InvalidDecision(
explanation=f"这段回复没有以 {_ACTION_PREFIX!r} 开头,解释不出动作"
),
)
def parameters(self) -> Mapping[str, str]:
return {"prefix": _ACTION_PREFIX}
@dataclass(frozen=True, slots=True, kw_only=True)
class _ReplySamples:
yields_an_action: ModelReply
yields_invalid: ModelReply
class TestPrefixDecisionParser(DecisionParserContract):
@pytest.fixture
def decision_parser(self) -> _PrefixDecisionParser:
return _PrefixDecisionParser()
@pytest.fixture
def reply_samples(self, records: RecordFactory) -> _ReplySamples:
return _ReplySamples(
yields_an_action=records.reply(content=f"{_ACTION_PREFIX}做点事"),
yields_invalid=records.reply(content="我先想想。"),
)
-58
View File
@@ -1,58 +0,0 @@
"""事件出口的行为契约。
两个已知形态差别在可靠性要求上一个把进度逐步回写业务数据库供前端轮询要求低延迟
可以丢一个把审计事件送进日志管道要求不丢可以慢
## 写这份文件时撞出来的问题,`design/0013` 答了
发出去的事件里有什么当时验不了因为 `Event` 只有一个名字没有字段现在事件集定下来了
而答案把这份文件里的两条测试都挪走了它们要断言的行为都在库那一侧不在出口这一侧见文末
那两条说明
"""
import pytest
pytestmark = pytest.mark.contract
async def test_emit_accepts_an_event(event_sink, records):
"""能收下一个事件,正常路径不抛异常。"""
await event_sink.emit(records.event())
def test_a_raising_sink_is_compliant_so_this_layer_asserts_nothing():
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
契约写的是投递失败由****捕获记日志把失败计数加一然后继续跑所以要断言的
行为在库那一侧不在出口这一侧原来这里写了一条 `await emit(...)` 不抛的断言那会把
一个完全合法的审计 sink 判失败它在日志管道不可用时抛 `ConnectionError`而库本来就
该接住
库接住了失败并继续跑属于整次运行的行为落在驱动入口那一层的测试里不在这个接缝的
契约里这条留成一个说明是为了让下一个想在这儿加断言的人先看到这段
"""
def test_the_no_re_emission_guarantee_is_asserted_in_the_library_not_here():
"""投递失败不再转成一条事件从同一个出口发出去(`design/0013` 决策七)。
那会自我喂食一个持续失败的出口会让失败处理路径变成递归而递归的表现是进程卡住或
栈溢出不是一条错误日志
**要断言的是库有没有再发一次那是整次运行的行为**所以断言在
`tests/unit/test_session.py` 那边用一个恒抛异常的出口跑完一次运行验出口收到的
条数恰好等于步数这个接缝自己看不到库发了几次
"""
def test_the_audit_trail_is_asserted_against_the_log_not_here():
"""审计纪律由存储承担,不由事件流承担(`design/0013` 决策二)。
GovDoc 有一条硬纪律agent 的原始输出修复后的输出恢复来源全程留痕禁止静默修复
这条测试原来断言事件要同时带原文与修复后的文本而那个前提是错的事件流可丢
一件只存在于可丢通道里的事实撑不起禁止静默修复
两份文本在意图日志里各有位置原文在模型调用结果记录的回复里修复后的那份是步记录的
`raw_output`断言落在 `tests/unit/test_session.py`因为要跑完一次完整运行再把日志读
回来而这个接缝的契约只看得见一个出口实现
"""
+47
View File
@@ -0,0 +1,47 @@
"""把事件出口契约接到一个测试替身上。
库不带这个接缝的实现带了就等于替某一家定了投递协议这里造一个最小的替身它存在的唯一
目的是让套件的每一条用例至少被真的求值一次
**这个替身住在 `tests/` 不进 wheel任何下游都拿不到它** 库不带默认实现那条禁的是
`src/` 下出现一个能用的实现下游装了包就拿得到就会有人直接用判据是下游拿不拿得到
不是代码库里有没有一个能跑的实现
`research-wiki/design/0014-contract-suite-distribution.md` 决策六
`contract` 这个标记打在本文件上不打在套件里理由见 `test_run_stores.py`
"""
from collections.abc import Mapping
import pytest
from polyloop.ports import Event
from polyloop.testing import EventSinkContract
pytestmark = pytest.mark.contract
class _CollectingEventSink:
"""收下事件,攒进一个列表。
**不做别的**这套契约只断言收得下一个事件投递失败之后库还在跑库没有把失败
再发一次都是整次运行的行为由库自己的测试守着不由一个出口实现验往这里加重试
过滤加计数验的就变成这个替身自己了
它满足 `polyloop.ports.EventSink`但不显式继承那个 Protocol结构化子类型不需要继承
"""
def __init__(self) -> None:
self.events: list[Event] = []
async def emit(self, event: Event) -> None:
self.events.append(event)
def parameters(self) -> Mapping[str, str]:
return {"kind": "collecting"}
class TestCollectingEventSink(EventSinkContract):
@pytest.fixture
def event_sink(self) -> _CollectingEventSink:
return _CollectingEventSink()
-74
View File
@@ -1,74 +0,0 @@
"""模型调用接缝的行为契约。
**这一层不打真实网关**那是 e2e 的事这里断言的是返回结构体的形状与失败的表达方式
用一个受控替身就能验
两个已知形态一个按三本账各记一条并自己按价格表算成本一个在调用外面套退避并累加本次
运行的 token
"""
import pytest
pytestmark = pytest.mark.contract
async def test_returns_three_fields(model_client, records):
"""返回三个字段:调用标识、可见回复、推理段。
可见回复与推理段的长度由库自己数字符不从任何用量对象取实测中转网关会用本地分词器
补算并整体替换用量对象把明细一起吃掉某次标定里 24 次调用的推理 token 全部没上报
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert isinstance(reply.content, str)
assert isinstance(reply.thinking, str)
assert reply.call_id is None or isinstance(reply.call_id, str)
async def test_call_id_is_never_an_empty_string(model_client, records):
"""调用标识可以是「没有」,但绝不能是空串。
它是轨迹与账目之间唯一的连接键空串是个看起来合法的键连表时静默匹配不上显式
没有至少能被筛出来它为空的合法含义只有一个调用在记账之前就失败了
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert reply.call_id != ""
async def test_failure_is_expressed_as_an_exception(model_client, records):
"""调用失败以异常表达,不以「返回一个空回复」表达。
库接住它翻译成模型故障记一条调用标识为空的步如果失败被表达成一个内容为空串的
正常返回库没有任何办法把它和模型真的回了空字符串分开而后者是模型行为前者
是基础设施故障两者在分析里属于完全不同的类别
"""
with pytest.raises(Exception): # noqa: B017 具体异常类型归实现,契约只要求「抛」
await model_client.call(records.model_call(call_index=0, result_id="fail"))
async def test_cancellation_propagates_and_is_not_swallowed(model_client, records):
"""取消要能穿过模型调用,`CancelledError` 不许被捕获吞没。"""
import asyncio
task = asyncio.ensure_future(
model_client.call(records.model_call(call_index=0, result_id="m0"))
)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
def test_signature_carries_no_retry_or_rate_limit_parameters(model_client):
"""签名里不出现重试次数、退避时长、限流配额。
出现即意味着库在治理一次模型调用而那归 PolyGateway`CLAUDE.md` §1.5这条断言的是
名字不是行为 §1.8公共 Protocol 的签名本身就是对下游的承诺断言它是应该的
"""
import inspect
names = set(inspect.signature(model_client.call).parameters)
assert not (names & {"retries", "max_retries", "backoff", "timeout", "rate_limit"})
+81
View File
@@ -0,0 +1,81 @@
"""把动作执行契约接到库自带的分发器上。
`RegistryExecutor` 是库里唯一一个动作执行器实现它按工具名查注册表校验参数调那个工具的
实现套件不认识任何一家的动作语言所以两个样本动作由这里提供都是工具调用因为那是这个
执行器唯一认得的形状
`contract` 这个标记打在本文件上不打在套件里理由见 `test_run_stores.py`
"""
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
from polyloop.ports import Action
from polyloop.testing import ActionExecutorContract, RecordFactory
from polyloop.tools import RegistryExecutor, ToolRegistry, ToolSpec
pytestmark = pytest.mark.contract
#: 两个工具都不收参数,校验那一段因此不参与这套用例的成败。
_NO_ARGUMENTS: Mapping[str, object] = {
"type": "object",
"properties": {},
"additionalProperties": False,
}
async def _returns_text(arguments: Mapping[str, object]) -> str:
"""跑得完、不报错的那个工具。
**它里面没有等待点这是刻意的** 一个纯计算的工具本来就没有可挂起的地方而取消那条
用例会因此判定这个实现快到没有可取消的窗口并跳过跑得太快不是违约往这里塞一句
`await asyncio.sleep(0)` 能把那条跳过换成通过但换来的通过验的是这句人为的等待不是
分发器有没有吞掉取消
"""
return "工具的输出"
async def _raises(arguments: Mapping[str, object]) -> str:
"""跑得完、但动作本身报错的那个工具。
抛一个普通异常而不是 `ToolEnvironmentError`后者是环境坏了那一档会被分发器记成
环境故障而这套用例要的恰恰是动作报错仍然算已执行
"""
raise ValueError("这个工具自己报错了")
@dataclass(frozen=True, slots=True, kw_only=True)
class _ActionSamples:
executes_cleanly: Action
executes_but_errors: Action
class TestRegistryExecutor(ActionExecutorContract):
@pytest.fixture
def action_executor(self) -> RegistryExecutor:
registry = ToolRegistry(
[
ToolSpec(
name="echo",
description="回一段固定文本",
parameters=_NO_ARGUMENTS,
handler=_returns_text,
),
ToolSpec(
name="boom",
description="抛一个普通异常",
parameters=_NO_ARGUMENTS,
handler=_raises,
),
]
)
return registry.executor()
@pytest.fixture
def action_samples(self, records: RecordFactory) -> _ActionSamples:
return _ActionSamples(
executes_cleanly=records.action(text="echo", tool_name="echo"),
executes_but_errors=records.action(text="boom", tool_name="boom"),
)
-236
View File
@@ -1,236 +0,0 @@
"""存储接缝的行为契约。
这份文件是一次运行的日志到底保证什么的权威`CLAUDE.md` §0两个已知实现形态差别
很大一个逐行追加本地文件一个写关系数据库所以下面每一条都只说行为不碰形态
**行为的理由不在这里** 崩溃恢复为什么这么设计见 `design/0002`写入粒度与前缀持久性见
`design/0005`这里只断言结果
## 标成 `xfail` 的那两条
它们是**已知没有机器兜底的承诺**不是还没写的测试标成会失败的测试而不是写一句注释是为了
让它们在每次跑套件时都被看见`strict=True` 是配套的哪天真的验得了测试过了它会以 XPASS
报错逼人回来把标记连同说明一起删掉它们不带 fixture否则会被实现还没有那个跳过挡住
于是验不了就伪装成了还没轮到
剩下那条曾经答不上的没有动作的步 `StepCompleted.result_id` 填什么已经由 `design/0006`
决策七答掉可为空且为空当且仅当动作结果也为空对应的测试已经改写成真断言
"""
import pytest
pytestmark = pytest.mark.contract
# --------------------------------------------------------------------------
# 一、写进去的读得回来
# --------------------------------------------------------------------------
async def test_written_intent_is_readable(store, records):
"""写一条意图,读回整份日志时它必须在里面。
这是全套最基本的一条意图日志的全部意义是比进程活得久写了读不回来后面每一条
恢复语义都建立在空气上
"""
intent = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
async def test_log_of_unknown_run_is_empty_not_an_error(store):
"""读一个从没写过的运行标识,得到一份空日志,而不是异常。
`run` 在开工前要判断这个标识是不是已经有日志了靠的就是这一条如果读不存在的
运行会抛异常那个判断就得写成捕获异常而捕获异常来做流程控制会把真正的存储故障
一起吞掉
"""
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_two_runs_do_not_leak_into_each_other(store, records):
"""两个运行标识各写各的,互相看不见对方的记录。
端口不持有当前运行的隐式状态这条测试是那个要求的外部可观测形式一个有隐式当前
运行的实现会在并发下把 A 的意图写进 B 的日志而那种错在单线程测试里永远不出现
"""
a = records.model_call_intent(run_id="run-a", call_index=0, result_id="m0")
b = records.model_call_intent(run_id="run-b", call_index=0, result_id="m0")
await store.write_intent(a)
await store.write_intent(b)
assert (await store.read_log("run-a")).intents == (a,)
assert (await store.read_log("run-b")).intents == (b,)
# --------------------------------------------------------------------------
# 二、四态:恢复靠「意图有没有 / 结果有没有」判定
# --------------------------------------------------------------------------
async def test_intent_without_result_is_readable_as_such(store, records):
"""写了意图、没写结果,读回来必须能看出「这个 ID 没有结果」。
这是四态表里状态未知那一档的输入存储不负责判定但它必须让判定问得出口恢复
要按预分配的 ID 精确地问而不是模糊匹配去猜哪条结果对应哪次执行
"""
intent = records.action_intent(run_id="r1", call_index=0, result_id="a0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
assert all(step.result_id != "a0" for step in log.steps)
async def test_result_without_intent_is_visible_to_the_reader(store, records):
"""只写结果不写意图,读回来必须原样可见,存储自己不许修复也不许拒收。
有结果没意图是日志损坏处置是拒绝续跑但那个判断归恢复逻辑不归存储存储在
这里悄悄补一条意图或者拒绝这次写入都会让损坏变得不可见而不可见的损坏会被当成
正常数据继续用下去
"""
result = records.model_call_result(run_id="r1", result_id="orphan", reply=records.reply())
await store.write_model_call_result(result)
log = await store.read_log("r1")
assert log.model_results == (result,)
assert log.intents == ()
async def test_failed_model_call_is_recorded_as_a_result_not_as_nothing(store, records):
"""模型调用失败也要落一条结果记录,否则恢复会把它读成「状态未知」。
失败这件事是确定的调用发出去了失败了库记了一条步如果这时不写结果条目恢复
只看见意图有结果无走重放策略而这次调用的状态一点都不未知下游按停止原因
做的统计会照单收下这个错误
"""
result = records.model_call_result(run_id="r1", result_id="m0", reply=None, failure="连接超时")
await store.write_model_call_result(result)
(readback,) = (await store.read_log("r1")).model_results
assert readback.reply is None
assert readback.failure == "连接超时"
# --------------------------------------------------------------------------
# 三、原子写
# --------------------------------------------------------------------------
async def test_action_result_and_step_land_together(store, records):
"""动作结果与步记录一次原子落地:读回来要么两者都在,要么都不在。
不原子的话崩在两者之间会让那一步的历史文本永远丢失而恢复判定会把它读成执行完了
跳过恢复出来的消息序列比不中断跑完时少一轮后面每一步都跟着偏
**这条测试只能验一起可见验不了一起不可见** 见本文件末尾那条
"""
step = records.step_completed(
run_id="r1", result_id="a0", action_outcome=records.outcome(), step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].action_outcome is not None
async def test_step_without_an_action_is_still_recorded(store, records):
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
预算对等要求它们计入步数它们确实消耗了一次模型调用丢掉那一步还会丢掉模型在出故障时
说了什么而那正是排查环境坏了还是模型写了危险代码最需要的
这条曾经写不出来那时 `StepCompleted.result_id` 是必填字符串而这一步没写过动作意图
没有预分配的 ID随便编一个会让恢复读到一条对不上任何意图的记录按四态表最后一行判成
日志损坏`design/0006` 决策七把它改成可为空并要求**它为空当且仅当动作结果也为空**
这个洞才补上存储要能原样存下这个形状
"""
step = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].result_id is None
assert log.steps[0].action_outcome is None
# --------------------------------------------------------------------------
# 四、运行的开始与结束
# --------------------------------------------------------------------------
async def test_run_finished_is_visible_before_the_result_is_returned(store, records):
"""「这次运行结束了」这个标记由库写下,而且写在把结果交给调用方之前。
另一条路有个具体的失败场景结果由项目落盘的话跑完了库返回了项目存的时候崩了
这种情况下重启后日志显示最后一步有结果没有结束标记而项目那边什么都没有续跑会
重复执行最后一步的副作用不续跑就丢掉一次已经花完钱的运行歧义来自结果跨了两个存储
"""
finished = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
await store.write_run_finished(finished)
assert (await store.read_log("r1")).finished == finished
async def test_run_started_carries_the_parameter_snapshot(store, records):
"""运行开始记录带着这次的参数快照,续跑时拿它与当前装配比对。
没有它用同一个运行标识换一份定义续跑前几步与后几步会来自两个不同的配置而全程零
报错那正是要到统计阶段才分不清哪些行是真的那类损坏
"""
started = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
await store.write_run_started(started)
assert (await store.read_log("r1")).started.parameter_snapshot == {"model": "m-1"}
# --------------------------------------------------------------------------
# 五、这套测试**验不了**的两条承诺
# --------------------------------------------------------------------------
@pytest.mark.xfail(reason="已知缺口:这条承诺没有机器兜底", strict=True)
def test_atomicity_under_crash_is_not_checkable_here():
"""原子性的另一半——「崩在中间时两者都不可见」——这一层验不了。
要验它得在写入过程中把进程杀掉而契约测试跑在一个进程里面对的是一个已经装配好的
实现没有位置插入那次崩溃给端口加一个故意在这里失败的钩子能验但那个钩子会
变成公共 API 的一部分而它只为测试存在
结论是这条承诺**没有机器兜底**只能靠 `CLAUDE.md` §3 那轮对抗审查看实现把这件事
写成一条会失败的测试而不是一句注释是为了让它在每次跑套件时都被看见
"""
pytest.fail(
"已知缺口:原子写的「一起不可见」这一半没有机器检查。"
"落地时要在 stores 的 unit 测试里用可注入的故障点覆盖,"
"并在 design/0005 决策二登记这条契约测试覆盖不到。"
)
@pytest.mark.xfail(reason="已知缺口:这条承诺没有机器兜底", strict=True)
def test_prefix_durability_is_not_checkable_here():
"""前缀持久性同样验不了,理由更硬一层。
它说的是 k 次写入被确认持久时 k-1 次也已经持久已经持久是掉电之后
才看得出来的性质在一个进程里读得回来不等于它落了盘
"""
pytest.fail(
"已知缺口:前缀持久性没有机器检查。两个已知形态天然满足它"
"(同一文件的追加写、同一连接上顺序提交的事务),"
"所以它实际是对实现形态的约束,落地时靠评审看,不靠这套测试。"
)
+36
View File
@@ -0,0 +1,36 @@
"""把存储契约接到库自带的两个实现上。
同一套用例在两种形态上各跑一遍一个逐行追加进本地文件一个只留在进程内存里一条其实
只在其中一种形态下成立的断言在这里当场红同一条断言写进某一个实现自己的单元测试里另一个
实现漏掉它不会有任何东西发现这正是 `research-wiki/design/0014-contract-suite-distribution.md`
决策一把接法从覆盖同名 fixture换成继承基类换来的一份契约同时验多个实现
`contract` 这个标记打在本文件上不打在套件里套件随包发到下游而一个下游开着
`--strict-markers` 又没注册这个 marker 的话炸掉的是整份文件的收集决策二第五条
"""
from pathlib import Path
import pytest
from polyloop.stores import JsonlRunStore, VolatileRunStore
from polyloop.testing import RunStoreContract
pytestmark = pytest.mark.contract
class TestJsonlRunStore(RunStoreContract):
"""逐行追加进本地文件的那个实现。"""
@pytest.fixture
def store(self, tmp_path: Path) -> JsonlRunStore:
"""`tmp_path` 每条用例一个新目录,套件要的「每次返回一个空存储」自动成立。"""
return JsonlRunStore(directory=tmp_path)
class TestVolatileRunStore(RunStoreContract):
"""只留在进程内存里的那个实现。"""
@pytest.fixture
def store(self) -> VolatileRunStore:
return VolatileRunStore()
@@ -0,0 +1,94 @@
"""把动作执行契约接到一个有挂起点的测试替身上。
同一套用例在两种形态上各跑一遍`test_registry_executor.py` 接的分发器是纯计算的取消那条
用例在它身上永远走跳过分支取消发出去时它已经跑完没有机会吞掉取消那条用例的断言半边
因此在本仓库一次都没被执行过而契约套件的目标是每一条用例都至少被真的执行一次
`research-wiki/design/0014-contract-suite-distribution.md` 决策六这个替身补的就是有挂起
那一档
**不是给分发器塞一句人为的等待** 那样换来的通过验的是那句等待不是分发器有没有吞掉取消
`test_registry_executor.py` 里那个工具的 docstring 说的就是这件事补一个另外的实现验的
是真有等待点时取消穿不穿得过去
**这个替身住在 `tests/` 不进 wheel任何下游都拿不到它** 库不带默认实现那条禁的是
`src/` 下出现一个能用的实现下游装了包就拿得到就会有人直接用于是动作语言被库替它定了
判据是下游拿不拿得到不是代码库里有没有一个能跑的实现同上决策六
`contract` 这个标记打在本文件上不打在套件里理由见 `test_run_stores.py`
"""
import asyncio
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
from polyloop.ports import Action
from polyloop.testing import ActionExecutorContract, RecordFactory
from polyloop.types import ActionOutcome, ActionStatus
pytestmark = pytest.mark.contract
#: 这个替身认得的全部动作语言:正文以它开头的那个动作,跑完之后自己报错。
_FAILS_PREFIX = "FAIL "
#: 挂起窗口的长度。**不能用 `asyncio.sleep(0)`**:那只是让出一次,取消赶不赶得上就取决于事件
#: 循环就绪队列里两个回调的先后,而那个顺序不是承诺。给一个真的定时器,套件让出一次之后这个
#: 替身一定还没跑完,`cancel()` 一定返回 `True`,取消那条用例才稳定地走到断言那一半。
_PAUSE_SECONDS = 0.001
class _SuspendingActionExecutor:
"""每个动作都先真的挂起一小段,再返回一个「已执行」的结果。
那次挂起模拟的是真实实现里的等待点网络往返子进程容器会话**它不捕获任何异常**
所以挂起期间收到的 `CancelledError` 原样穿出去这正是契约要断言的行为
`CLAUDE.md` §1.6没有 in-flight 资源要放所以也没有 `finally`
它满足 `polyloop.ports.ActionExecutor`但不显式继承那个 Protocol结构化子类型不需要继承
"""
async def execute(self, action: Action) -> ActionOutcome:
await asyncio.sleep(_PAUSE_SECONDS)
if action.text.startswith(_FAILS_PREFIX):
return ActionOutcome(
status=ActionStatus.EXECUTED,
observation=f"动作自己报错了:{action.text[len(_FAILS_PREFIX) :]}",
observation_is_synthetic=False,
env_reported_completion=False,
observation_truncated_chars=0,
)
return ActionOutcome(
status=ActionStatus.EXECUTED,
observation=f"动作的输出:{action.text}",
observation_is_synthetic=False,
env_reported_completion=False,
observation_truncated_chars=0,
)
def parameters(self) -> Mapping[str, str]:
return {"kind": "suspending", "pause_seconds": str(_PAUSE_SECONDS)}
@dataclass(frozen=True, slots=True, kw_only=True)
class _ActionSamples:
executes_cleanly: Action
executes_but_errors: Action
class TestSuspendingActionExecutor(ActionExecutorContract):
@pytest.fixture
def action_executor(self) -> _SuspendingActionExecutor:
return _SuspendingActionExecutor()
@pytest.fixture
def action_samples(self, records: RecordFactory) -> _ActionSamples:
"""两个样本的状态都是「已执行」,动作本身报错的那个也是。
这个替身的动作语言里没有没进执行环境坏了这两档报错的动作照样跑完了只是
观察里多一句错误说明套件对样本的要求就是这个`action_samples` 的退化情况那一段
"""
return _ActionSamples(
executes_cleanly=records.action(text="做点事"),
executes_but_errors=records.action(text=f"{_FAILS_PREFIX}除以零"),
)
+134 -4
View File
@@ -8,7 +8,9 @@
`make ci` 一个因为可选依赖没装而常年红的套件会训练所有人忽略红
"""
import re
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
@@ -21,6 +23,7 @@ from polygateway.errors import AllSourcesExhausted # noqa: E402
from polyloop.adapters import GatewayModelClient # noqa: E402
from polyloop.ports import ModelCall # noqa: E402
from polyloop.testing import ModelClientContract # noqa: E402
from polyloop.types import Message, Role, TextBlock # noqa: E402
pytestmark = pytest.mark.integration
@@ -139,20 +142,108 @@ async def test_an_empty_call_id_becomes_no_call_id() -> None:
assert (await client.call(_call())).call_id is None
async def test_only_the_binding_keys_the_gateway_has_slots_for_are_forwarded() -> None:
"""其余的键不往下传也不报错——它们已经进了参数快照,网关那边只是没有格子放
async def test_prefixed_binding_keys_are_forwarded_with_the_prefix_stripped() -> None:
"""带 `gateway.` 前缀的键剥掉前缀之后当关键字参数传下去,不带前缀的坐标一个都不传
报错等于要求项目为了适配一个网关而裁剪自己的坐标系而绑定同时是续跑守卫的输入
本库不认识网关的参数表认不认得 `cache_namespace` 这种名字是网关的事所以替身照单全收
"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(_call(binding={"session_id": "s1", "book": "b7", "task": "t3"}))
await client.call(
_call(
binding={
"book": "b7",
"task": "t3",
"gateway.cache_namespace": "acme:v1:tenant:x7",
"gateway.tenant_id": "x7",
}
)
)
((_, kwargs),) = stub.calls
assert kwargs == {"cache_namespace": "acme:v1:tenant:x7", "tenant_id": "x7"}
async def test_a_historical_name_still_works_once_it_carries_the_prefix() -> None:
"""`session_id` 这两个名字没有被禁掉,被禁掉的是不带前缀那种写法。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(_call(binding={"gateway.session_id": "s1"}))
((_, kwargs),) = stub.calls
assert kwargs == {"session_id": "s1"}
@pytest.mark.parametrize("key", ["session_id", "parent_call_id"])
async def test_a_bare_historical_key_is_rejected_and_the_error_gives_the_new_spelling(
key: str,
) -> None:
"""这两个键从前被静默转发,现在报错——静默不传的话下游的遥测会悄悄不再分组。
错误信息里必须出现改法撞上的人才知道下一步写什么
"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape(f"gateway.{key}")):
await client.call(_call(binding={key: "v"}))
assert stub.calls == []
@pytest.mark.parametrize("parameter", ["messages", "stream", "structured", "overlay"])
async def test_a_structural_gateway_parameter_is_rejected(parameter: str) -> None:
"""这四个参数改变的是请求本身,而它们的取值另有权威,从绑定走等于让同一件事有两处记录。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape(f"gateway.{parameter}")):
await client.call(_call(binding={f"gateway.{parameter}": "v"}))
assert stub.calls == []
@pytest.mark.parametrize("parameter", ["cache_namespace", "cache_salt"])
@pytest.mark.parametrize("value", ["", " ", "\t"])
async def test_a_blank_forwarded_value_is_rejected(parameter: str, value: str) -> None:
"""这条防御对所有带前缀的键一视同仁,不认某个具体的参数名。
空串在网关那边和没传分不开`gateway.cache_namespace=""` 会静默落回默认命名空间
纯空白更糟它是个真值会被当成一个真的命名空间用下去于是所有配错的租户共用同一格
"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape(f"gateway.{parameter}")):
await client.call(_call(binding={f"gateway.{parameter}": value}))
assert stub.calls == []
async def test_the_bare_prefix_is_rejected() -> None:
"""前缀后面没有名字就没有参数名可传,静默跳过会让人以为自己传出去了。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
with pytest.raises(ValueError, match=re.escape("gateway.")):
await client.call(_call(binding={"gateway.": "v"}))
assert stub.calls == []
async def test_a_binding_of_plain_coordinates_forwards_nothing_and_raises_nothing() -> None:
"""不带前缀的键已经进了参数快照,报错等于要求项目为了适配网关而裁剪自己的坐标系。"""
stub = _StubClient(_response())
client = GatewayModelClient(client=stub, settings=_settings())
await client.call(_call(binding={"book": "b7", "task": "t3"}))
((_, kwargs),) = stub.calls
assert kwargs == {}
async def test_gateway_errors_propagate_untranslated() -> None:
"""网关的异常类名本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
@@ -207,3 +298,42 @@ def test_changing_a_sampling_parameter_changes_the_parameters() -> None:
).parameters()
assert before["sources"] != after["sources"]
# ---------------------------------------------------------------------------
# 模型调用契约(`polyloop.testing.ModelClientContract`)接在这一层,不在契约层。
#
# 分层判据是「依赖什么」(`CLAUDE.md` §1.9):这里的配置、装配守卫、响应类型全是网关真的
# 那套,所以它是 integration。`research-wiki/design/0014-contract-suite-distribution.md`
# 决策六那张表里,五个接缝只有这一行落在契约层之外,就是这个原因。
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class _UnsupportedBlock:
"""一种适配器不认得的内容块。
它是 `failing_call` 用来让适配器在入参这一关就挂掉的东西适配器把每个块翻译成文本
碰到不认得的类型直接抛那是它自己的守卫不是替身编出来的失败
"""
class TestGatewayModelClient(ModelClientContract):
"""网关适配器要满足模型调用接缝的全部契约。"""
@pytest.fixture
def model_client(self) -> GatewayModelClient:
return GatewayModelClient(client=_StubClient(_response()), settings=_settings())
@pytest.fixture
def failing_call(self) -> ModelCall:
"""一次带着适配器不认得的内容块的调用。
**失败发生在请求打出去之前**适配器逐块翻译消息碰到不是文本块的东西直接抛所以这
条路径不碰替身客户端不产生任何副作用客户端实例失败之后照样能接着服务这三件事
正是 `failing_call` 那份 docstring 要求的
另一条路是让替身客户端认一个暗号见到就抛那等于在实现这一侧重新造出套件刚刚扔掉的
那个约定而它验的会变成替身的分支写对没有
"""
return _call(messages=(Message(role=Role.USER, content=(_UnsupportedBlock(),)),))
+35 -4
View File
@@ -154,14 +154,45 @@ def test_channels_are_ordered_by_name_not_by_mapping_order() -> None:
assert [block.text for m in injection_messages(entries) for block in m.content] == ["A", "Z"]
def test_entry_ids_come_back_in_the_same_order_as_the_messages() -> None:
"""轨迹里记的是条目标识,正文不进——正文可能很大,而「这次贴了哪几条」事后要查得到。"""
def test_entry_ids_keep_the_channel_they_came_from() -> None:
"""参数快照里记的是条目标识,正文不进——正文可能很大,而「这次贴了哪几条」事后要查得到。
通道维度保留拍平之后声明了这个通道但一条都没选中压根没有这个通道是同一个
结果而有下游要比较的正是这两种情形
"""
entries = {
"zeta": (Injection(entry_id="z", content="Z"),),
"alpha": (Injection(entry_id="a", content="A"),),
"alpha": (Injection(entry_id="a", content="A"), Injection(entry_id="b", content="B")),
}
assert injected_entry_ids(entries) == ("a", "z")
assert injected_entry_ids(entries) == {"alpha": ("a", "b"), "zeta": ("z",)}
def test_a_declared_but_empty_channel_is_not_the_same_as_a_missing_one() -> None:
"""一个是值为空元组的项,另一个是这个项不存在。两者拼进快照才分得开。"""
assert injected_entry_ids({"skill": ()}) == {"skill": ()}
assert injected_entry_ids({}) == {}
def test_entry_ids_come_back_in_the_same_order_as_the_messages() -> None:
"""两个函数的顺序必须一致,它们同住一个模块就是为了守住这一点。
对不上的话快照记的贴入顺序和模型真正看到的顺序是两回事而续跑守卫照样全绿
"""
entries = {
"zeta": (Injection(entry_id="z", content="Z"),),
"alpha": (Injection(entry_id="a", content="A"), Injection(entry_id="b", content="B")),
}
by_content = {"A": "a", "B": "b", "Z": "z"}
flattened = [
entry_id for entry_ids in injected_entry_ids(entries).values() for entry_id in entry_ids
]
from_messages = [
by_content[block.text] for m in injection_messages(entries) for block in m.content
]
assert flattened == from_messages
# ---------------------------------------------------------------------------
+21
View File
@@ -49,6 +49,27 @@ def test_importing_polyloop_does_not_import_polygateway() -> None:
assert result.stdout.strip() == "False", result.stdout
def test_importing_polyloop_does_not_import_pytest() -> None:
"""`import polyloop` 之后 `sys.modules` 里不许出现 `pytest`。
契约套件 `polyloop.testing` 顶层就 import pytest而顶层包不 re-export 它和
`stores``adapters` 同一档必须显式 import少了这条断言哪天有人顺手把 `testing`
加进 `polyloop/__init__.py`每个下游的运行时就都被拽上一个 pytest 依赖 pytest
生产环境里通常根本没装表现是下游一 import 本库就 `ModuleNotFoundError`
和上面那条 polygateway 同构也同样在子进程里跑本进程早就 import pytest
"""
code = "import polyloop, sys; print('pytest' in sys.modules)"
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
cwd=REPO_ROOT,
)
assert result.stdout.strip() == "False", result.stdout
def test_py_typed_marker_ships_with_the_package() -> None:
"""`py.typed` 必须在包根里。
+177
View File
@@ -0,0 +1,177 @@
"""记录工厂造出来的东西必须合法:构造得出,而且能过一遍编解码往返。
**它守的是工厂造的记录合不合法不是契约用例引用的方法存不存在** 后者只有真的执行那条
用例才查得出一条用例调了工厂上不存在的方法工厂自己的单元测试怎么写都看不见它因为那
条引用根本不在这个文件里所以这份测试全绿不代表契约套件接上了实现把五套契约都接到实现上
是另一件事做在 `tests/contract/` `tests/integration/`
`research-wiki/design/0014-contract-suite-distribution.md` 决策六
往返用的是 `polyloop.serialization`因为那是记录进日志的唯一通道一条编不出来或者解回来
不等于自己的记录在契约套件里表现成某个存储实现的用例红而红的原因其实在工厂这一侧
"""
import pytest
from polyloop.serialization import (
decode_intent,
decode_model_call_result,
decode_run_finished,
decode_run_result,
decode_run_started,
decode_step_completed,
decode_step_record,
encode,
)
from polyloop.testing import RecordFactory
from polyloop.types import ActionStatus, ReplayPolicy, StopReason
pytestmark = pytest.mark.unit
@pytest.fixture
def records() -> RecordFactory:
return RecordFactory()
def test_run_started_round_trips(records: RecordFactory) -> None:
record = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
assert decode_run_started(encode(record)) == record
def test_model_call_intent_round_trips(records: RecordFactory) -> None:
record = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
assert decode_intent(encode(record)) == record
def test_action_intent_round_trips_with_a_non_default_replay_policy(
records: RecordFactory,
) -> None:
"""重放策略跟着走一遍。它是恢复时「这一步要不要重跑」的输入,编码里丢了就会静默降级。"""
record = records.action_intent(
run_id="r1", call_index=1, result_id="a0", replay_policy=ReplayPolicy.SAFE
)
assert decode_intent(encode(record)) == record
assert record.replay_policy is ReplayPolicy.SAFE
def test_successful_model_call_result_round_trips(records: RecordFactory) -> None:
record = records.model_call_result(run_id="r1", result_id="m0", reply=records.reply())
assert decode_model_call_result(encode(record)) == record
def test_failed_model_call_result_round_trips(records: RecordFactory) -> None:
"""失败那一档单独走一遍:回复为空、失败说明有值,两个可空字段的组合和成功那档相反。"""
record = records.model_call_result(run_id="r1", result_id="m0", failure="连接超时")
decoded = decode_model_call_result(encode(record))
assert decoded == record
assert decoded.reply is None
assert decoded.failure == "连接超时"
def test_step_record_round_trips(records: RecordFactory) -> None:
record = records.step(step_idx=3)
assert decode_step_record(encode(record)) == record
def test_unparsed_step_record_round_trips(records: RecordFactory) -> None:
"""解析失败那一档:动作为空、解析说明有值。"""
record = records.step(parse_ok=False)
decoded = decode_step_record(encode(record))
assert decoded == record
assert decoded.action is None
assert decoded.parse_error is not None
def test_step_completed_round_trips(records: RecordFactory) -> None:
record = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(),
step=records.step(),
)
assert decode_step_completed(encode(record)) == record
def test_step_completed_without_an_action_round_trips(records: RecordFactory) -> None:
"""没有动作的那一步:结果标识与动作结果同时为空,这个组合存储要能原样存下来。"""
record = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step()
)
decoded = decode_step_completed(encode(record))
assert decoded == record
assert decoded.result_id is None
assert decoded.action_outcome is None
def test_outcome_carries_a_non_default_status(records: RecordFactory) -> None:
"""状态取值跟着记录走一遍。默认那档和显式传的那档在编码里长得一样,各验一次。"""
record = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(status=ActionStatus.ENV_ERROR),
step=records.step(),
)
decoded = decode_step_completed(encode(record))
assert decoded == record
assert decoded.action_outcome is not None
assert decoded.action_outcome.status is ActionStatus.ENV_ERROR
def test_run_result_round_trips(records: RecordFactory) -> None:
record = records.result(run_id="r1", stop_reason=StopReason.STEP_BUDGET)
assert decode_run_result(encode(record)) == record
def test_run_finished_round_trips(records: RecordFactory) -> None:
record = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
assert decode_run_finished(encode(record)) == record
def test_model_call_defaults_to_one_user_message(records: RecordFactory) -> None:
"""一次模型调用不是记录,编解码不管它,但它的默认消息序列是契约用例的隐式输入。
默认给一条用户消息而不是空序列一个真实的实现拿到空消息序列多半直接拒绝于是那几条
用例验的就变成了它的入参校验不是它的返回结构
"""
call = records.model_call(call_index=0, result_id="m0")
assert len(call.messages) == 1
assert call.messages[0].content[0].text != ""
def test_action_with_a_tool_name_carries_a_tool_call(records: RecordFactory) -> None:
"""带工具名的动作要真的带上工具调用,按工具名分发的执行器靠它才走得进分发。"""
action = records.action(text="echo", tool_name="echo")
assert action.tool_call is not None
assert action.tool_call.name == "echo"
def test_action_without_a_tool_name_carries_no_tool_call(records: RecordFactory) -> None:
"""不带工具名的那种是代码执行型动作,工具调用必须为空,否则会被分发器当成工具调用收下。"""
assert records.action().tool_call is None
def test_event_carries_the_step_it_reports(records: RecordFactory) -> None:
"""事件不是记录,但它带着的那条步记录要和工厂造的其他步记录同形。"""
event = records.event(step_idx=2)
assert event.step is not None
assert event.step.step_idx == 2
assert decode_step_record(encode(event.step)) == event.step
+292 -1
View File
@@ -661,7 +661,204 @@ def test_the_context_never_enters_the_snapshot() -> None:
snapshot = request.parameter_snapshot()
assert "很长的一段正文" not in "".join(snapshot.values())
assert snapshot["request.injected_entry_ids"] == "e1"
assert snapshot["request.injected_entry_ids.skill"] == "e1"
def test_a_declared_but_empty_channel_still_gets_a_key() -> None:
"""声明了通道却一条都没选中,是一个值为空串的键。
有下游要比较声明了通道而选中零条连通道都不声明这两组运行拍平之后两者在记录
里长得一样那一档就测不了
"""
request = dataclasses.replace(_request(FakeExecutor([])), injections={"skill": ()})
assert request.parameter_snapshot()["request.injected_entry_ids.skill"] == ""
def test_a_channel_that_was_never_declared_has_no_key_at_all() -> None:
request = dataclasses.replace(_request(FakeExecutor([])), injections={})
snapshot = request.parameter_snapshot()
assert not [key for key in snapshot if key.startswith("request.injected_entry_ids")]
def test_two_channels_do_not_get_merged_into_one_key() -> None:
"""通道名在拍平的写法里只用来定顺序,排完就没了,「哪几条来自哪个通道」事后查不到。"""
request = dataclasses.replace(
_request(FakeExecutor([])),
injections={
"skill": (
Injection(entry_id="s1", content=""),
Injection(entry_id="s2", content=""),
),
"memo": (Injection(entry_id="m1", content=""),),
},
)
snapshot = request.parameter_snapshot()
assert snapshot["request.injected_entry_ids.skill"] == "s1,s2"
assert snapshot["request.injected_entry_ids.memo"] == "m1"
def test_no_fingerprints_means_no_fingerprint_keys() -> None:
"""默认空映射时一个键都不写,不是写一个值为空串的键。
今天已经在跑的配置算出来的快照因此逐字节不变只有真的传了指纹的运行才多出那几项
"""
snapshot = _request(FakeExecutor([])).parameter_snapshot()
assert not [key for key in snapshot if key.startswith("request.fingerprint.")]
def test_every_fingerprint_enters_the_snapshot() -> None:
"""提示词模板不是数据是参数:它是一份跨运行复用的配方,每次运行拿它渲染出这一次的上下文。
不记的话这次用的是哪一版提示词就只剩下调用方的 git 提交这一个粒度而同一个提交下
完全可以试好几份不同的模板
"""
request = dataclasses.replace(
_request(FakeExecutor([])),
fingerprints={"prompt_template": "sha256:abc", "skill_pack": "sha256:def"},
)
snapshot = request.parameter_snapshot()
assert snapshot["request.fingerprint.prompt_template"] == "sha256:abc"
assert snapshot["request.fingerprint.skill_pack"] == "sha256:def"
def test_a_non_string_binding_value_is_refused_at_construction() -> None:
"""快照的取值类型已经是持久化契约的一部分,只靠反序列化那一侧的话,这次运行会照常跑完、
钱花光才在续跑读日志时发现有一项读不回来"""
with pytest.raises(ValueError, match="RunRequest.model_binding"):
dataclasses.replace(_request(FakeExecutor([])), model_binding={"round": 3})
def test_a_non_string_fingerprint_is_refused_at_construction() -> None:
"""`model_binding` 与 `fingerprints` 语义同族、形状相同,两个字段的行为必须一样——不一样
比两个都不校验更难查查的人会先怀疑自己传错了字段"""
with pytest.raises(ValueError, match="RunRequest.fingerprints"):
dataclasses.replace(_request(FakeExecutor([])), fingerprints={"template": 7})
with pytest.raises(ValueError, match="RunRequest.fingerprints"):
dataclasses.replace(_request(FakeExecutor([])), fingerprints={7: "sha256:abc"})
@pytest.mark.parametrize(
("field_name", "added_value"),
[("model_binding", "a"), ("fingerprints", "sha256:abc"), ("injections", ())],
)
def test_a_mapping_field_cannot_be_changed_after_construction(
field_name: str, added_value: object
) -> None:
"""构造期那道校验拦不住构造之后原地改,所以三个映射字段构造时就被冻成只读的。
`frozen=True` 只挡住把字段重新绑到另一个对象上 `fingerprints` 里塞一个整数它会
一路进到运行开始记录的参数快照要到续跑读日志反序列化那一关才炸而那时这次运行已经完整
跑过一遍钱也花完了`injections` 那一份还会同时改掉装配出来的消息序列
`fingerprints` 这一档同时守住默认值不传它的请求手上是一个 `default_factory` 造的空
dict不冻的话那个 dict 改得动而往里塞什么都不经过校验
"""
request = _request(FakeExecutor([]))
with pytest.raises(TypeError):
getattr(request, field_name)["新加的"] = added_value
def test_changing_the_dict_that_was_passed_in_does_not_change_the_request() -> None:
"""调用方传进来的那个 dict 之后再被改,请求看见的仍是构造那一刻的形状。
另一个方向不拷一份的话一个被复用的绑定 dict 改一个键就悄悄改掉了一个已经在跑的运行
的参数快照
"""
binding = {"item": "a"}
request = dataclasses.replace(_request(FakeExecutor([])), model_binding=binding)
binding["item"] = "b"
assert request.parameter_snapshot()["request.binding.item"] == "a"
def test_a_request_derived_with_replace_still_constructs() -> None:
"""`dataclasses.replace` 是推荐的派生范式,它会把已经冻过的那个只读映射再传进一次构造。
冻结那一步不能因此炸炸的话派生一个请求就得先把三个映射字段各拆回普通 dict而那正是
大多数调用方不会想到要做的一步派生出来的那个照样是冻的
"""
request = dataclasses.replace(
_request(FakeExecutor([])), fingerprints={"prompt_template": "sha256:abc"}
)
derived = dataclasses.replace(request, run_id="run-2")
assert derived.run_id == "run-2"
assert derived.parameter_snapshot()["request.fingerprint.prompt_template"] == "sha256:abc"
with pytest.raises(TypeError):
derived.fingerprints["prompt_template"] = "sha256:def"
async def test_the_frozen_binding_still_reaches_the_model_call_and_the_event() -> None:
"""绑定冻成只读映射之后,模型调用与事件上那两份照样读得出来、内容一致。
只读是这三个字段的全部改动透传路径没有变下游拿它查键比对序列化都和以前一样
只是拿它反过来改这次运行的配置不再行得通
"""
store = FakeStore()
sink = FakeSink()
model = FakeModel([_reply("go")])
definition = _definition(store, model, FakeParser({"go": ACT}), sink)
await run(definition, _request(FakeExecutor([_outcome(completed=True)]), binding={"item": "a"}))
(event,) = sink.events
assert model.calls[0].binding == {"item": "a"}
assert event.model_binding == {"item": "a"}
with pytest.raises(TypeError):
model.calls[0].binding["item"] = "b" # type: ignore[index]
class _BadParameters:
"""把一个接缝原样代理出去,只把 `parameters()` 换成一份取值不是字符串的映射。"""
def __init__(self, seam: object, parameters: Mapping[object, object]) -> None:
self._seam = seam
self._parameters = parameters
def __getattr__(self, name: str) -> object:
return getattr(self._seam, name)
def parameters(self) -> Mapping[object, object]:
return self._parameters
@pytest.mark.parametrize("seam", ["model_client", "decision_parser", "store", "event_sink"])
def test_a_definition_seam_reporting_a_non_string_is_refused(seam: str) -> None:
"""接缝实现由下游写,它返回什么算外部输入。五个接缝里任何一个返回一个整数,症状都一样:
这次运行照常跑完续跑时才炸
报错要指得出是哪个接缝只说快照必须是字符串的话七个来源里找不出是谁
"""
definition = _definition(FakeStore(), FakeModel([]), FakeParser({}))
broken = dataclasses.replace(
definition, **{seam: _BadParameters(getattr(definition, seam), {"oops": 1})}
)
with pytest.raises(ValueError, match=rf"{seam}\.parameters"):
broken.parameter_snapshot()
def test_the_action_executor_reporting_a_non_string_is_refused() -> None:
"""第五个接缝挂在请求上,聚合发生在另一处,所以它要单独验一遍。"""
request = dataclasses.replace(
_request(FakeExecutor([])),
action_executor=_BadParameters(FakeExecutor([]), {"session": 42}), # type: ignore[arg-type]
)
with pytest.raises(ValueError, match=r"action_executor\.parameters"):
request.parameter_snapshot()
# ---------------------------------------------------------------------------
@@ -699,6 +896,25 @@ async def test_resume_refuses_when_the_assembly_drifted() -> None:
await resume(definition, _request(FakeExecutor([_outcome()]), budget=other_budget))
async def test_resume_refuses_when_only_a_fingerprint_changed() -> None:
"""换一份模板、用同一个运行标识续跑,前几步用 A、后几步用 B,全程零报错,那次运行的数据
已经废了却没有任何东西提示守住这个失败场景是 `fingerprints` 存在的全部意义
别的东西一个字都没变预算绑定接缝注入都一样只有指纹换了
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
first = dataclasses.replace(
_request(FakeExecutor([_outcome(completed=True)])),
fingerprints={"prompt_template": "sha256:aaa"},
)
await run(definition, first)
second = dataclasses.replace(first, fingerprints={"prompt_template": "sha256:bbb"})
with pytest.raises(ParameterDriftError, match="request.fingerprint.prompt_template"):
await resume(definition, second)
async def test_resume_hands_back_a_finished_run_without_rerunning_it() -> None:
store = FakeStore()
model = FakeModel([_reply("go")])
@@ -788,6 +1004,81 @@ async def test_cancellation_propagates_and_still_writes_the_finished_marker() ->
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 动作执行接缝抛异常
# ---------------------------------------------------------------------------
class _RaisingExecutor(FakeExecutor):
"""执行动作时抛出,而不是返回一个填好状态的结果。"""
def __init__(self, error: BaseException) -> None:
super().__init__([])
self._error = error
async def execute(self, action: Action) -> ActionOutcome:
self.actions.append(action)
raise self._error
async def test_an_executor_exception_comes_straight_out_of_run() -> None:
"""库不接管,异常原样穿出去,不被转成任何停止原因。
接住就得给这一步编一个动作结果状态观察完成信号截断字符数四个字段全是库现造的
没有一个来自执行器而一条带着动作结果的完整步记录会被恢复读成上一步走完了那个未知
状态就被抹掉了转成 `ENV_ERROR` 还会把实现方的 bug 伪装成环境故障送进下游的统计
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
executor = _RaisingExecutor(AttributeError("包装类自己的 bug"))
with pytest.raises(AttributeError, match="包装类自己的 bug"):
await run(definition, _request(executor))
assert executor.actions != []
# 日志停在「动作意图有、步记录无」,也没有结束记录:不写恰好是照实。
assert [entry.kind for entry in store.of_type(Intent)] == [ # type: ignore[attr-defined]
IntentKind.MODEL_CALL,
IntentKind.ACTION,
]
assert store.of_type(StepCompleted) == []
assert store.of_type(RunFinished) == []
async def test_resume_after_an_executor_exception_reads_the_state_as_unknown() -> None:
"""副作用发生了没有,库无从知道,日志里也没有任何一处记着。
这和进程崩在动作执行中途是同一种状态恢复照四态表把它读成未知那条动作意图记着绝不
重放于是这次运行以续跑时状态未知收尾而不是重新执行一次动作
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
request = _request(_RaisingExecutor(AttributeError("包装类自己的 bug")))
with pytest.raises(AttributeError):
await run(definition, request)
result = await resume(definition, request)
assert result.stop_reason is StopReason.RESUME_STATE_UNKNOWN
assert result.steps == ()
async def test_cancellation_from_the_executor_still_writes_the_finished_marker() -> None:
"""取消要能穿过动作执行,`CancelledError` 原样重抛,但结束标记照常写下去。
不写的话恢复读到的是一次没有结束标记的运行会被当成可以续跑而它其实是被人主动叫停的
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
with pytest.raises(asyncio.CancelledError):
await run(definition, _request(_RaisingExecutor(asyncio.CancelledError())))
finished = store.of_type(RunFinished)
assert len(finished) == 1
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 并发隔离
# ---------------------------------------------------------------------------
+208 -8
View File
@@ -1,8 +1,12 @@
"""逐行追加那份存储实现的行为。
"""份存储实现的行为:逐行追加进文件的那个,以及只留在进程内存里的那个
**行为契约本身在 `tests/contract/test_run_store.py`**那套套件现在就接着这个实现这里只写
契约套件覆盖不到的部分文件长什么样坏行怎么算`fsync` 在哪几处以及那条契约测试明说
这一层验不了的原子性它点名要在这里用可注入的故障点补上
**行为契约本身在契约套件里**那套套件不针对任何具体实现写的是不管你怎么实现都必须满足
这些行为这里只写套件覆盖不到的部分文件长什么样坏行怎么算`fsync` 在哪几处那条契约
测试明说这一层验不了的原子性它点名要在这里用可注入的故障点补上以及易失那个实现独有
的几条它的独占判据它靠存载荷换来的两个方向的别名免疫
还有几条跨着两个实现跑坏记录在哪一步炸写入失败留不留痕那种两个实现必须一样的事只在
这里守得住契约套件对每个实现分别跑两边各自全绿并不代表它们一致
"""
import asyncio
@@ -12,9 +16,9 @@ from pathlib import Path
import pytest
from polyloop import stores
from polyloop.serialization import DecodeError
from polyloop.stores import RECORD_KEY, JsonlRunStore
from polyloop.stores import RECORD_KEY, JsonlRunStore, VolatileRunStore
from polyloop.stores import _jsonl as jsonl_module
from polyloop.types import (
ActionOutcome,
ActionStatus,
@@ -248,7 +252,7 @@ async def test_an_unknown_record_type_is_refused_not_skipped(tmp_path: Path) ->
async def test_a_write_that_dies_halfway_leaves_nothing_readable(tmp_path: Path) -> None:
"""崩在一次写中途,那条记录**整条不可见**,不是半条可见。
这是 `tests/contract/test_run_store.py` 里那条 `xfail` 点名要在这一层补的契约套件跑在
这是 `polyloop.testing.RunStoreContract` 里那条跳过点名要在这一层补的契约套件跑在
一个进程里面对一个已经装配好的实现没有位置插入那次崩溃这里靠替换掉那个内部的
把这些字节写进去来造它
@@ -456,7 +460,7 @@ async def test_creating_the_log_file_syncs_the_directory_entry(
这条持久性没有别的进程内可观测形态所以只能盯着那次调用本身
"""
synced: list[Path] = []
monkeypatch.setattr(stores, "_fsync_directory", synced.append)
monkeypatch.setattr(jsonl_module, "_fsync_directory", synced.append)
store = JsonlRunStore(directory=tmp_path)
await store.write_run_started(_started())
@@ -487,3 +491,199 @@ async def test_concurrent_writes_to_one_run_do_not_interleave(tmp_path: Path) ->
log = await store.read_log("r1")
assert len(log.intents) == 6
# ---------------------------------------------------------------------------
# 易失存储:只在进程内存里
# ---------------------------------------------------------------------------
async def test_a_volatile_store_reads_back_what_it_wrote() -> None:
"""写进去的读得回来,五种记录都算数。
不提供恢复说的是跨进程那一层进程内读不回来的话它连自家的准入标准都过不了契约
套件第一条要的就是写进去的意图读得回来
"""
store = VolatileRunStore()
result = RunResult(
run_id="r1", stop_reason=StopReason.TASK_COMPLETED, final_answer="42", steps=()
)
model_result = ModelCallResult(
run_id="r1",
result_id="m0",
reply=ModelReply(call_id="c1", content="hi", thinking=""),
failure=None,
)
finished = RunFinished(run_id="r1", result=result)
await store.write_run_started(_started())
await store.write_intent(_intent())
await store.write_model_call_result(model_result)
await store.write_step_completed(_step())
await store.write_run_finished(finished)
log = await store.read_log("r1")
assert log.started == _started()
assert log.intents == (_intent(),)
assert log.model_results == (model_result,)
assert log.steps == (_step(),)
assert log.finished == finished
async def test_a_volatile_store_keeps_two_runs_apart() -> None:
"""按运行标识分桶,两次运行互相看不见对方的记录。
端口不许持有当前运行的隐式状态那种实现会在并发下把 A 的意图写进 B 的日志而这种
错在单线程测试里永远不出现
"""
store = VolatileRunStore()
await store.write_intent(_intent("run-a", call_index=0))
await store.write_intent(_intent("run-b", call_index=1))
assert (await store.read_log("run-a")).intents == (_intent("run-a", call_index=0),)
assert (await store.read_log("run-b")).intents == (_intent("run-b", call_index=1),)
async def test_a_volatile_store_refuses_to_start_the_same_run_twice() -> None:
"""独占和逐行追加那个实现对齐:这个标识已经有日志了就直接失败。
重开一个已经开过的标识交错的记录序会让恢复读到同一步的两条意图判成日志被并发写过
于是这次运行从此续不了而两边的模型调用都已经花过钱了
"""
store = VolatileRunStore()
await store.write_run_started(_started())
with pytest.raises(FileExistsError):
await store.write_run_started(_started())
async def test_a_volatile_store_refuses_to_start_a_run_that_already_has_records() -> None:
"""判据是「这个标识有没有日志」,不是「有没有一条运行开始记录」。
逐行追加那边任何一次写都会把文件建出来于是独占创建会拦下这一种两处判据不一样的话
同一段代码在两个实现上一个通过一个失败
"""
store = VolatileRunStore()
await store.write_intent(_intent())
with pytest.raises(FileExistsError):
await store.write_run_started(_started())
async def test_an_unwritten_run_reads_back_empty_from_a_volatile_store() -> None:
"""驱动入口靠这条判断「这个标识是不是已经有日志了」。
抛异常的话那个判断就得写成捕获异常而用捕获异常做流程控制会把真正的存储故障一起吞掉
"""
store = VolatileRunStore()
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_mutating_the_snapshot_after_writing_does_not_change_the_log() -> None:
"""写进去之后调用方改自己手上那个映射,读回来的还是写进去时那份。
参数快照是一个 `Mapping`存对象引用就有别名 bug续跑时拿它和当前装配比对而它已经跟着
调用方后来的改动变了于是一次真的参数漂移被判成没漂移逐行追加那个实现因为要序列化成
JSON 文本天然免疫这件事这个实现靠桶里存载荷换到同样的免疫
"""
store = VolatileRunStore()
snapshot = {"model": "m-1"}
await store.write_run_started(RunStarted(run_id="r1", parameter_snapshot=snapshot))
snapshot["model"] = "m-2"
log = await store.read_log("r1")
assert log.started is not None
assert log.started.parameter_snapshot == {"model": "m-1"}
async def test_a_bad_field_type_lands_in_both_stores_and_fails_on_read_the_same_way(
tmp_path: Path,
) -> None:
"""字段类型不对的记录两个实现都收得下,都要到 `read_log` 才抛同一个 `DecodeError`。
落盘那个实现写入时只做 `json.dumps`而一个本该是整数的字符串是合法 JSON它写得进去
易失那个实现要是在写入时就解一遍当场拒绝同一段下游代码在它上面写就红换成落盘存储
要到读才红而两个实现的准入标准是同一套契约套件套件里没有一条覆盖得到这处分歧
这两个实现在什么时候炸上必须一致所以这条测试对两个都跑而且比对异常文本本身
"""
bad = Intent(
run_id="r1",
kind=IntentKind.MODEL_CALL,
call_index="zero", # type: ignore[arg-type]
result_id="m0",
replay_policy=ReplayPolicy.NEVER,
)
jsonl = JsonlRunStore(directory=tmp_path)
volatile = VolatileRunStore()
await jsonl.write_intent(bad)
await volatile.write_intent(bad)
with pytest.raises(DecodeError) as jsonl_error:
await jsonl.read_log("r1")
with pytest.raises(DecodeError) as volatile_error:
await volatile.read_log("r1")
assert str(volatile_error.value) == str(jsonl_error.value)
async def test_a_record_class_this_layer_does_not_know_leaves_no_trace_in_either_store(
tmp_path: Path,
) -> None:
"""这一层不认得的记录类两个实现都在写入时拒绝,而且什么都不留下。
不留痕迹这一半要紧的地方在易失那边失败的那次写要是先建了桶这个标识就再也开不了
新运行`write_run_started` 的独占会撞上那个空桶落盘那边对应的是不留下一个空文件
"""
# 步记录是别的记录的字段,不是一条日志行——它编得出来,但存储这一层不收它。
not_a_record = _step().step
jsonl = JsonlRunStore(directory=tmp_path)
volatile = VolatileRunStore()
with pytest.raises(KeyError):
await jsonl.write_intent(not_a_record) # type: ignore[arg-type]
with pytest.raises(KeyError):
await volatile.write_intent(not_a_record) # type: ignore[arg-type]
assert list(tmp_path.iterdir()) == []
assert (await volatile.read_log("r1")).started is None
await volatile.write_run_started(_started())
assert (await volatile.read_log("r1")).started == _started()
async def test_mutating_a_record_read_back_from_a_volatile_store_does_not_change_the_log() -> None:
"""读回来之后改它,再读一次还是原来的值。
桶里存的是载荷每次读现解出一批新对象把桶里那份直接交出去的话一次普通读取就足以
篡改日志`RunStarted` 带的参数快照是个 dict改它一下续跑的参数漂移判断读到的就是
改过的那份于是一次真的漂移被判成没漂移
"""
store = VolatileRunStore()
await store.write_run_started(RunStarted(run_id="r1", parameter_snapshot={"model": "m-1"}))
first = await store.read_log("r1")
assert first.started is not None
first.started.parameter_snapshot["model"] = "m-2" # type: ignore[index]
second = await store.read_log("r1")
assert second.started is not None
assert second.started.parameter_snapshot == {"model": "m-1"}
def test_the_volatile_store_reports_only_its_kind() -> None:
"""快照里只有形态,没有实例状态。
现在攒了几次运行这类状态写进去的话同一个存储对象在两次比对之间会给出不同的答案
于是一次装配完全没变的续跑被判成参数漂移
"""
store = VolatileRunStore()
assert store.parameters() == {"kind": "volatile"}
+9
View File
@@ -0,0 +1,9 @@
"""压测 harness:给 PolyLoop 造真实负载用的一次性工具,**不是库的一部分**。
`pyproject.toml` `[tool.setuptools.packages.find]` 只收 `src/`所以这个目录不会
被打包不会随 `pip install polyloop` 装到下游手里它可以依赖 `src/polyloop/` 明确
拒绝的东西httpxdocker 命令行某个具体 benchmark 的数据布局因为它的失败只会
影响我们自己的压测不会击穿任何下游项目
**反过来的方向是禁止的**`src/polyloop/` 里任何一处都不许 import `tools.soak`
"""
+846
View File
@@ -0,0 +1,846 @@
"""压测 harness 的环境层:一个 AppWorld 容器池,加一个薄 HTTP 客户端。
**这一层完全不碰模型** 它只提供起容器 实例化一道题 执行代码 问完成没有
评分 关闭这一串动作谁来决定执行什么代码是上层的事所以它可以离线验证
`check_appworld.py` 用一段写死的 Python 就能把整条链路走通一次模型调用都不用打
**为什么走 HTTP 而不是直接 import appworld** appworld 包钉在 pydantic 1.x
PolyGateway 要求 pydantic 2.8两者装不进同一个解释器AppWorld 官方把它的
`AppWorld` 类整个包成了一个 environment server 跑在官方镜像里所以本模块只是个瘦
客户端本进程一行 appworld 代码都不导入
**为什么需要容器池而不是一个容器** environment server 用一个模块级变量存当前任务
请求换题会被直接拒绝也就是说一个容器同一时刻只能跑一道题要并发跑 N 道题就得起
N 个容器各占一个宿主端口用完归还
**协议来自 dissect `harness/envs/`代码是独立写的** PolyLoop 是被 dissect 依赖的
反向 import 下游是硬约束CLAUDE.md §1.2 import-linter 断言dissect 那两个
文件在这里只当协议文档看端点形状初始化参数取值两个坑的成因都出自那里实现则
只保留压测用得着的最小子集不带 dissect 自己的 Episode/TaskEnv/ScoredEpisode 抽象
不带评分聚合不带快照参数
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
import httpx
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
logger = logging.getLogger(__name__)
#: environment server 的官方镜像。用滚动标签而不是 digest:压测只关心「跑得动多少
#: 并发」,不做跨时间可比的成绩,上游换镜像不影响结论。真做实验的那一侧(dissect)
#: 才需要把 digest 钉进快照。
DEFAULT_IMAGE = "ghcr.io/stonybrooknlp/appworld:latest"
#: 起始宿主端口,池占用 [PORT_BASE, PORT_BASE + size)。
#:
#: **刻意避开 8100**dissect 的默认值就是 8100,而这台机器上两边可能同时在跑。撞了
#: 端口的表现不是一句「端口被占用」——先起的那个容器活着,后起的 `docker run` 失败,
#: 于是压测报「容器起不来」,而真正在受害的是另一个项目的实验。
DEFAULT_PORT_BASE = 8200
#: 容器名前缀。名字里带端口号,`docker ps` 的输出就能直接对上出问题的那个端口。
CONTAINER_NAME_PREFIX = "polyloop-soak-appworld"
#: 健康检查路径。返回 2xx 即视为就绪。
_READINESS_PATH = "/"
#: 数据目录的挂载模式。**只读**:这份数据是 dissect 的实验数据,733 个任务目录,压测
#: 没有任何理由改它,而一次误写会污染另一个项目的实验输入且无人察觉。环境自己要写的
#: 东西全部落在输出目录那个挂载点上。
_DATA_MOUNT_MODE = "ro"
#: 连续多少次会话关闭失败就让整轮压测停下来。取 3 而不是 1,是因为一次网络抖动不该
#: 毁掉整批;取 3 而不是 50,是因为泄漏是累积的,等到几十次时环境早就不干净了。
_MAX_CLOSE_FAILURES = 3
#: 允许的挂载模式。docker 会把认不得的模式当成一个额外的挂载选项,拼错了不一定报错,
#: 所以在拼命令行之前先自己拦一道。
_MOUNT_MODES = frozenset({"ro", "rw"})
class ContainerPoolError(RuntimeError):
"""容器池的启动、健康检查或清理失败。
一律带上 docker stderr 或容器日志容器起不来时只报一句超时调试就只能靠猜
"""
class AppWorldError(RuntimeError):
"""AppWorld 环境返回了错误,或返回了我们无法解释的内容。
**它不表示模型写的代码跑挂了**那是正常观察 `AppWorldSession.execute`
原样返回这个异常只在环境本身坏了时抛连不上HTTP 2xx返回体不是约定的形状
"""
@dataclass(frozen=True)
class Mount:
"""一条 docker 卷挂载。
模式单独成字段而不是拼进字符串是为了让这个挂载是只读的在调用处看得见
`-v a:b:ro` 里那两个字母混在路径中间改错了不会有人发现
"""
host: Path
container: str
mode: str
def to_arg(self) -> str:
"""拼成 `docker run -v` 的取值。"""
if self.mode not in _MOUNT_MODES:
raise ValueError(f"挂载模式只能是 {sorted(_MOUNT_MODES)},收到 {self.mode!r}")
return f"{self.host}:{self.container}:{self.mode}"
@dataclass(frozen=True)
class TaskScore:
"""官方评测器对一道题的判定。
`success` 是过与不过AppWorld 没有连续分`detail` 是评测器返回的完整结构里面
逐条 requirement 的通过情况在排查是模型不行还是环境坏了时是唯一线索
"""
task_id: str
success: bool
n_executions: int
detail: Mapping[str, Any]
class ContainerPool:
"""一组同构容器与其宿主端口的租借池。
池里第 i 个容器占宿主端口 `port_base + i`容器内监听同一个端口号`-p` 的两侧
一致容器里的服务就不需要知道自己被映射到了哪里
用法::
async with ContainerPool(...) as pool:
async with pool.lease() as port:
... # 独占这个端口上的服务
池不清理租借者在容器里留下的状态归还前复位状态是租借者的责任 AppWorld
而言就是调 `/close`
**它刻意不知道容器里跑的是什么** 启动命令由 `container_args` 给出健康检查路径
`readiness_path` 给出本类只管容器的生死与端口的租借
"""
def __init__(
self,
*,
image: str,
size: int,
port_base: int,
name_prefix: str,
container_args: Callable[[int], Sequence[str]],
readiness_path: str,
mounts: Sequence[Mount] = (),
startup_timeout_s: float,
) -> None:
"""构造容器池。不启动任何东西,启动在 `start()`。
Args:
image: 镜像名需已在本地或可拉取
size: 容器数量即并发上限
port_base: 起始宿主端口
name_prefix: 容器名前缀实际名字是 `{prefix}-{端口号}`
container_args: 端口 容器内启动命令每个容器的命令都要带上自己的端口号
所以这里是函数而不是一份固定列表
readiness_path: 健康检查的 HTTP 路径
mounts: 卷挂载
startup_timeout_s: 单个容器从 `docker run` 到健康检查通过的时限
"""
if size < 1:
raise ValueError(f"池大小必须至少为 1,收到 {size}")
if port_base < 1024:
raise ValueError(f"起始端口须在非特权区间,收到 {port_base}")
if startup_timeout_s <= 0:
raise ValueError(f"启动时限必须为正,收到 {startup_timeout_s}")
self._image = image
self._size = size
self._ports = [port_base + i for i in range(size)]
self._name_prefix = name_prefix
self._container_args = container_args
self._readiness_path = readiness_path
self._mounts = tuple(mounts)
self._startup_timeout_s = startup_timeout_s
self._free_ports: asyncio.Queue[int] = asyncio.Queue()
self._started = False
self._stopped = False
self._leased = 0
@property
def ports(self) -> tuple[int, ...]:
"""池占用的全部宿主端口,无论此刻是否空闲。"""
return tuple(self._ports)
async def start(self) -> None:
"""启动全部容器并等它们就绪。任何一个起不来就把已起的全部拆掉再抛错。
留下半个池比干净地失败更难排查下一次跑会撞上同名容器而报出来的是
`docker run` 的重名错误跟真正的起因隔了一整轮
"""
if self._started:
raise ContainerPoolError("容器池已启动,不要重复调用 start()")
# 池是一次性的:停过就不能再起。允许重启的话,`stop()` 之后迟到归还的租约会把
# 端口 put 进队列,紧接着的 `start()` 再 put 一遍,队列里就有了重复端口——两个
# 任务会同时租到同一个容器,而 environment server 是单例,后者会被「当前活动任务
# 是另一道题」打挂。压测没有重启的用例,就不留这个坑。
if self._stopped:
raise ContainerPoolError("容器池已停止,不支持重启;需要的话新建一个池")
# 上一次运行若被强杀会留下同名容器,`docker run` 会因重名直接失败。先无条件清
# 一遍,让重跑不需要人工干预。
await self._remove_all()
# 用 TaskGroup 而不是 gathergather 在第一个子协程抛错时立刻把异常抛给调用方,
# 但**不取消其余子协程**,它们会一直跑到 startup_timeout_s。那意味着下面的清理
# 可能跑在某些 `docker run` 完成之前,于是那些容器活了下来占着端口——正是「要么
# 全起要么全拆」要防的半个池。TaskGroup 会取消其余任务并等它们收敛之后才抛。
try:
async with asyncio.TaskGroup() as group:
for port in self._ports:
group.create_task(self._start_one(port))
except BaseExceptionGroup as failures:
await self._remove_all()
# TaskGroup 把子任务的异常打包成组。这里拆开只抛第一个,让调用方拿到一个
# 平常的异常而不是需要 `except*` 才能接的组;其余的记进日志,不然多个容器
# 同时出问题时只看得见一个。
first, *rest = failures.exceptions
for extra in rest:
logger.error("容器池启动时的另一处失败:%s", extra)
# 不写 `from ...``first` 自己的 __cause__ 已经指着真正的故障原因(见
# `_start_one` 的 `from exc`),再显式挂一个 from 会把它顶掉,异常链上就只剩
# 一句「启动过程中出错」。B904 要防的是丢掉上下文,这里上下文本来就在。
raise first # noqa: B904
except BaseException:
# 取消之类不经由 TaskGroup 打包的路径,同样要保证不留下半个池。
await self._remove_all()
raise
for port in self._ports:
self._free_ports.put_nowait(port)
self._started = True
logger.info("容器池就绪:%d%s 实例,端口 %s", self._size, self._image, self._ports)
async def stop(self) -> None:
"""停止并删除全部容器。幂等,但停过之后不能再 `start()`。"""
if self._leased:
# 容器马上就没了,那些租约手里的端口随后会连不上。这说明调用方在还有任务在跑
# 的时候就退出了 `async with`,属于调用方的 bug;但沉默地让它表现成一堆网络
# 错误更糟——那时看到的是「AppWorld 连不上」,查的方向完全错了。
logger.error("停止容器池时仍有 %d 个租约在途,它们的请求将会失败", self._leased)
# 状态更新必须放 finally`docker rm` 也会失败(docker 二进制不在时
# create_subprocess_exec 直接抛 FileNotFoundError)。失败之后如果状态还停在
# 「已启动」、队列里还剩着端口,接下来的 lease() 会把端口发给一批已经不确定还在
# 不在的容器,故障从「停不掉」变成「跑在幽灵容器上」。
try:
await self._remove_all()
finally:
self._started = False
self._stopped = True
while not self._free_ports.empty():
self._free_ports.get_nowait()
@contextlib.asynccontextmanager
async def lease(self) -> AsyncIterator[int]:
"""租借一个空闲容器的宿主端口,退出时归还。
池满时阻塞等待**不设排队超时**排队时间是正常的并发背压把它变成异常只会
让上层写一堆重试逻辑而重试改变不了容器就这么多这个事实
"""
# 先判已停止:停过之后 `_started` 也是 False,只报「尚未启动,先调用 start()」
# 会把人引去调一个必然报「不支持重启」的方法。
if self._stopped:
raise ContainerPoolError("容器池已停止,它的容器都没了;需要的话新建一个池")
if not self._started:
raise ContainerPoolError("容器池尚未启动,先调用 start() 或用 async with")
port = await self._free_ports.get()
self._leased += 1
try:
yield port
finally:
self._leased -= 1
self._free_ports.put_nowait(port)
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.stop()
# -- 内部 ----------------------------------------------------------------
def _container_name(self, port: int) -> str:
return f"{self._name_prefix}-{port}"
async def _start_one(self, port: int) -> None:
"""起一个容器并等它健康。"""
name = self._container_name(port)
command = ["docker", "run", "-d", "--name", name, "-p", f"{port}:{port}"]
for mount in self._mounts:
command += ["-v", mount.to_arg()]
command += [self._image, *self._container_args(port)]
code, _, stderr = await _run(command)
if code != 0:
raise ContainerPoolError(f"容器 {name} 启动失败(docker run 退出码 {code}):{stderr}")
try:
await self._await_ready(port, name)
except asyncio.CancelledError:
# 取消必须原样传播。转成普通异常会让这个任务不再处于「已取消」状态,事件循环
# 随后报一次 unhandled exception,而现场看起来像是一次超时——真正发生的事
# (有人按了 Ctrl-C 或上层撤销了这一批)就此消失。
await self._remove_noisily(name)
raise
except Exception as exc:
# 健康检查失败时容器日志是唯一的线索,**必须先抓出来再删**。删掉之后
# `docker logs` 就没有东西可读了,而这类失败往往只在负载高的时候偶发。
# 保留 `from exc`:能进来的不止超时,readiness_path 写错导致的 httpx.InvalidURL
# 之类也会走到这儿,抹掉原因就只能靠猜。
reason = "未就绪(超时)" if isinstance(exc, TimeoutError) else "启动过程中出错"
logs = await self._container_logs(name)
await self._remove_noisily(name)
raise ContainerPoolError(
f"容器 {name} {reason}(等待上限 {self._startup_timeout_s} 秒)。容器日志:\n{logs}"
) from exc
async def _await_ready(self, port: int, name: str) -> None:
"""轮询健康检查端点直到返回 2xx 或超时。"""
url = f"http://127.0.0.1:{port}{self._readiness_path}"
deadline = asyncio.get_running_loop().time() + self._startup_timeout_s
# trust_env=False 是必须的:这台机器设了 http_proxy,而 httpx 默认会读环境变量,
# 于是连 127.0.0.1 的请求也被送去代理。表现是 HTTP 502——看起来像容器里的服务
# 出错,实际上请求根本没到过容器。
async with httpx.AsyncClient(timeout=5.0, trust_env=False) as client:
while asyncio.get_running_loop().time() < deadline:
# 容器刚起时连接被拒是常态,只有超时才算失败,所以这里不区分具体的网络
# 错误类型;非网络异常(配置错误之类)仍会照常冒泡。
try:
response = await client.get(url)
except httpx.TransportError:
await asyncio.sleep(0.5)
continue
if response.is_success:
logger.debug("容器 %s 就绪", name)
return
await asyncio.sleep(0.5)
raise TimeoutError(f"容器 {name} 健康检查超时")
async def _container_logs(self, name: str) -> str:
"""取容器日志用于报错。取不到就说明取不到,不返回空串冒充「日志为空」。"""
code, stdout, stderr = await _run(["docker", "logs", "--tail", "50", name])
if code != 0:
return f"<无法读取容器日志,docker logs 退出码 {code}{stderr}>"
return (stdout + stderr).strip() or "<容器未输出任何日志>"
async def _remove_noisily(self, name: str) -> None:
"""尽力删掉一个容器;删不掉只记日志,不改变正在传播的异常。
这里不用 `contextlib.suppress`清理失败意味着一个容器占着端口活了下来下一次
跑会以docker run 重名的形式失败而那时已经没人记得是这一次没删干净
"""
try:
code, _, stderr = await _run(["docker", "rm", "-f", name])
except OSError as exc:
logger.error("清理容器 %s 时连 docker 都没跑起来:%s", name, exc)
return
if code != 0:
logger.error("清理容器 %s 失败(退出码 %d):%s", name, code, stderr.strip())
async def _remove_all(self) -> None:
"""强删池内全部容器。
不检查退出码绝大多数非零都是容器本来就不存在而这正是启动前清残留时的常态
真正的故障docker 守护进程没了会在紧接着的 `docker run` 上以更清楚的形式暴露
"""
names = [self._container_name(port) for port in self._ports]
await _run(["docker", "rm", "-f", *names])
def container_args_for_port(port: int) -> Sequence[str]:
"""官方镜像的启动参数:第一个位置参数选服务类型,然后是端口。
单独提出来是为了不必起一个真容器就能核对这串参数
"""
return ["environment", "--port", str(port), "--no-show-usage"]
class AppWorldSession:
"""一道题的一次会话,绑定在池里的某一个容器上。
生命周期由 `AppWorldPool.session()` 管理不要直接构造直接构造出来的会话没有
对应的 `/close`容器里的资源会一直挂着
"""
def __init__(
self,
*,
client: httpx.AsyncClient,
base_url: str,
task_id: str,
instruction: str,
supervisor: Mapping[str, str],
datetime: str,
) -> None:
self._client = client
self._base_url = base_url
self._task_id = task_id
self._instruction = instruction
self._supervisor = dict(supervisor)
self._datetime = datetime
self._n_executions = 0
@property
def task_id(self) -> str:
"""本题的题目 ID。"""
return self._task_id
@property
def base_url(self) -> str:
"""本次会话所在容器的地址。排查时用来定位是哪个容器出的事。"""
return self._base_url
@property
def instruction(self) -> str:
"""题面:主管交给 agent 的自然语言指令。"""
return self._instruction
@property
def supervisor(self) -> Mapping[str, str]:
"""主管的身份信息(姓名、邮箱、电话)。agent 要靠它调 API。"""
return dict(self._supervisor)
@property
def datetime(self) -> str:
"""任务发生的虚拟时间,题目的一部分。"""
return self._datetime
@property
def n_executions(self) -> int:
"""到目前为止执行过多少次代码。"""
return self._n_executions
async def execute(self, code: str) -> str:
"""在环境里执行一段 Python 代码,返回它的输出。
这是 AppWorld 唯一的动作接口它不是 JSON 形式的工具调用agent 通过写代码调
``apis.<应用>.<接口>(...)`` 来操作各个 app通过 ``apis.supervisor.complete_task()``
声明做完了执行器是有状态的环境侧是一个常驻 IPython shell变量import
打开的句柄都跨步存活
**代码本身报错超时语法错都不抛异常** 环境会把 traceback 或超时提示放在
输出里返回那是给模型看的正常观察压成异常就等于把模型写错了环境坏了
混成同一件事而上层对这两者的处理完全不同只有环境自己坏了连不上HTTP
2xx返回体不是约定形状才抛 `AppWorldError`
"""
payload = await self._post("/execute", {"task_id": self._task_id, "code": code})
self._n_executions += 1
if not isinstance(payload, str):
raise AppWorldError(f"execute 期望返回字符串,收到 {type(payload).__name__}")
return payload
async def is_done(self) -> bool:
"""环境是否看到了完成信号,即 agent 调过 `apis.supervisor.complete_task()`。
这只是停机条件之一步数耗尽连续解析失败之类由上层自己判断不经过环境
"""
payload = await self._post("/task_completed", {"task_id": self._task_id})
return bool(payload)
async def evaluate(self) -> TaskScore:
"""跑官方评测器给这道题打分。**必须在会话关闭之前调用**,关闭之后环境状态就没了。
`suppress_errors=True` 是正常打分模式不是把错误藏起来AppWorld 用异常表达
这一条 requirement 没通过评测脚本把每条 requirement 包在一个上下文管理器里
断言失败时 `__exit__` 把它记进 failures 再返回 `suppress_errors` 决定是否抑制
False 的话第一条没过的 requirement 就会中断整个评测连分数都拿不到
代价是它无差别地吞掉所有异常包括评测基础设施自己的故障两者都表现成 failure
区别只在 `detail` 里那条 trace 的内容所以成绩集体为零时要去看 trace 是断言失败
还是别的东西别把环境坏了当成模型不行
"""
payload = await self._post(
"/evaluate",
{"task_id": self._task_id, "suppress_errors": True, "report": False},
)
if not isinstance(payload, dict):
raise AppWorldError(f"evaluate 期望返回对象,收到 {type(payload).__name__}")
return TaskScore(
task_id=self._task_id,
success=_success_of(payload, self._task_id),
n_executions=self._n_executions,
detail=payload,
)
async def close(self) -> None:
"""关闭会话,释放环境侧资源。由 `AppWorldPool.session()` 在退出时调用。"""
await self._post("/close", {"task_id": self._task_id})
async def _post(self, path: str, body: Mapping[str, Any]) -> Any:
return await _post_json(self._client, self._base_url, path, body)
class AppWorldPool:
"""AppWorld 环境的入口:管容器池、管 HTTP 连接、开会话。
用法::
pool = AppWorldPool(data_root=Path(...), size=2)
async with pool:
task_id = pool.list_task_ids("train")[0]
async with pool.session(task_id) as session:
print(await session.execute("print(1 + 1)"))
score = await session.evaluate()
"""
def __init__(
self,
*,
data_root: Path | str,
size: int,
outputs_dir: Path | str | None = None,
experiment_name: str = "polyloop-soak",
image: str = DEFAULT_IMAGE,
port_base: int = DEFAULT_PORT_BASE,
max_interactions: int = 40,
execution_timeout_s: int = 100,
startup_timeout_s: float = 180.0,
) -> None:
"""构造入口。不启动容器也不建连接,那些都在 `start()`。
Args:
data_root: AppWorld 的数据根目录 `appworld download data --root` 指定的
那个它下面应有 `data/datasets/` `data/tasks/`**这个目录以只读方式
挂进容器**压测不会改它
size: 容器数量即并发上限
outputs_dir: 环境侧写日志的目录挂到容器的 `/run/experiments/outputs`
默认落在系统临时目录下我们自己的一个位置不复用数据根目录下的
`experiments/outputs`因为那是别的项目的实验产物压测不该往里掺东西
experiment_name: 环境用它作为输出子目录名
image: environment server 的镜像
port_base: 容器池的起始宿主端口
max_interactions: 环境侧允许的最大执行次数当成上层步数预算的双保险
上层循环失控时环境会兜住40 dissect 的取值一致官方默认是 1000
execution_timeout_s: 单次代码执行的超时传给环境侧
startup_timeout_s: 单个容器启动并就绪的时限镜像已在本地时通常几秒就够
180 秒是为了容得下一次冷拉取
"""
self._data_root = Path(data_root).resolve()
self._datasets_dir = self._data_root / "data" / "datasets"
self._tasks_dir = self._data_root / "data" / "tasks"
self._outputs_dir = (
Path(outputs_dir).resolve()
if outputs_dir is not None
else Path(tempfile.gettempdir()) / "polyloop-soak-appworld-outputs"
)
self._experiment_name = experiment_name
self._max_interactions = max_interactions
self._execution_timeout_s = execution_timeout_s
self._client: httpx.AsyncClient | None = None
#: 每个容器端口各自的连续关闭失败次数。**必须按端口分开数**:池里有多个容器,
#: 用一个全局计数器的话,坏掉那个容器每次失败都会被其他容器的成功清零,阈值
#: 永远到不了,安全网等于不存在——而资源泄漏恰恰是发生在单个容器上的。
self._close_failures: dict[int, int] = {}
self._pool = ContainerPool(
image=image,
size=size,
port_base=port_base,
name_prefix=CONTAINER_NAME_PREFIX,
container_args=container_args_for_port,
readiness_path=_READINESS_PATH,
mounts=[
Mount(host=self._data_root / "data", container="/run/data", mode=_DATA_MOUNT_MODE),
Mount(
host=self._outputs_dir,
container="/run/experiments/outputs",
mode="rw",
),
],
startup_timeout_s=startup_timeout_s,
)
@property
def ports(self) -> tuple[int, ...]:
"""池占用的宿主端口。"""
return self._pool.ports
@property
def outputs_dir(self) -> Path:
"""环境侧日志落在宿主上的哪里。
**容器里的进程是 root写出来的文件也是 root **所以这个目录事后不由本模块
删除删不掉而一次删不掉的清理会以压测收尾报错的形式盖住真正的结果
"""
return self._outputs_dir
async def start(self) -> None:
"""检查数据、建连接、启动容器池。
任何一步失败都要把已经建起来的东西收回去`async with` `__aenter__` 抛错时
**不会**调用 `__aexit__`不自己收就是一次泄漏
"""
# 重入守卫。没有它的话第二次 start() 会先覆盖掉 self._client(旧连接就此泄漏),
# 再被容器池的「已启动」守卫打回,然后 except 里关掉刚建的新连接并置 None——结果
# 是容器还在跑、旧连接漏着、而对外声称尚未启动。
if self._client is not None:
raise AppWorldError("环境已启动,不要重复调用 start()")
self._verify_data_layout()
self._outputs_dir.mkdir(parents=True, exist_ok=True)
# HTTP 超时要盖过环境侧的执行超时,否则代码还在跑我们就先断了连接,表现成一次
# 假的网络故障。留 30 秒余量给序列化与调度。
#
# trust_env=False 同样是必须的,理由见 ContainerPool._await_ready。
self._client = httpx.AsyncClient(timeout=self._execution_timeout_s + 30.0, trust_env=False)
try:
await self._pool.start()
except BaseException:
await self._client.aclose()
self._client = None
raise
async def stop(self) -> None:
"""停止容器池并关闭 HTTP 连接。
try/finally 保证连接一定被关掉`pool.stop()` 也会失败docker 二进制不在时
`create_subprocess_exec` 直接抛 FileNotFoundError那时连接不能跟着漏掉
"""
try:
await self._pool.stop()
finally:
if self._client is not None:
await self._client.aclose()
self._client = None
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.stop()
def list_task_ids(self, split: str) -> list[str]:
"""列出某个数据划分下的全部题目 ID。
纯本地读文件不需要容器挑题数题的场合不该为了拿一串 ID 去起一堆容器
"""
dataset_file = self._datasets_dir / f"{split}.txt"
if not dataset_file.exists():
available = sorted(p.stem for p in self._datasets_dir.glob("*.txt"))
raise FileNotFoundError(f"找不到划分文件 {dataset_file};现有划分:{available}")
task_ids: list[str] = []
for line in dataset_file.read_text(encoding="utf-8").splitlines():
entry = line.strip()
if not entry:
continue
# 划分文件里的条目可能带 ":标签" 后缀,标签不是题目 ID 的一部分。
task_ids.append(entry.split(":")[0])
return task_ids
@contextlib.asynccontextmanager
async def session(self, task_id: str) -> AsyncIterator[AppWorldSession]:
"""开一次会话:租一个容器、实例化这道题,退出时关闭会话并归还容器。
**评测必须在退出这个上下文之前做**退出后环境状态就销毁了
"""
client = self._require_client()
async with self._pool.lease() as port:
base_url = f"http://127.0.0.1:{port}"
payload = await _post_json(
client,
base_url,
"/initialize",
{"task_id": task_id, **self._init_params()},
)
if not isinstance(payload, dict):
raise AppWorldError(f"initialize 期望返回对象,收到 {type(payload).__name__}")
session = AppWorldSession(
client=client,
base_url=base_url,
task_id=task_id,
instruction=_require(payload, "instruction", task_id),
supervisor=_require(payload, "supervisor", task_id),
datetime=_require(payload, "datetime", task_id),
)
try:
yield session
finally:
await self._close_quietly(session, port)
# -- 内部 ----------------------------------------------------------------
async def _close_quietly(self, session: AppWorldSession, port: int) -> None:
"""关闭会话;失败只记账不抛,但同一个容器连续失败到阈值就让整轮压测停下来。
不抛的理由走到这里时这道题的结果通常已经拿到手了为一次清理失败丢掉整道题的
数据不划算
不抛需要一张真的安全网因为**这个故障不会自己暴露**AppWorld
`/initialize` 拿到请求后第一件事就是覆盖全局的 world 变量既不检查旧的还开着
没有也不关它所以下一个租户不会报错只会让上一个 world 的资源永久留在容器
一个容器在压测里要连续跑成百上千个会话这是会累积到 fd 耗尽的慢性故障
唯一的线索只有一行日志
计数按端口分开理由见 `_close_failures` 的定义
"""
try:
await session.close()
except asyncio.CancelledError:
# 取消要原样传播:它不是「关闭失败」,记进失败计数会让一次 Ctrl-C 把安全网
# 的阈值推高,而真正的泄漏还没发生。
raise
except Exception as exc:
failures = self._close_failures.get(port, 0) + 1
self._close_failures[port] = failures
logger.error(
"题目 %s 的会话关闭失败(容器端口 %d,该容器已连续失败 %d 次):%s",
session.task_id,
port,
failures,
exc,
)
if failures >= _MAX_CLOSE_FAILURES:
raise AppWorldError(
f"端口 {port} 上的容器会话关闭已连续失败 {failures} 次。它内部的资源"
f"正在泄漏(AppWorld 的 /initialize 不会替我们清理旧会话),继续跑下去,"
f"落在这个容器上的任务都会产出在一个不健康的环境里"
) from exc
else:
self._close_failures.pop(port, None)
def _init_params(self) -> dict[str, Any]:
"""传给 `/initialize` 的参数。
**会影响 agent 行为的开关全部显式给值**不沿用 AppWorld 的默认默认值会随它
的版本变化而这些开关直接改变 agent 能做什么 `max_interactions` 其余取值
都等于 0.1.3 的默认值写出来是为了防版本漂移不是为了偏离官方设置
官方标注仅供测试不应改动的六个参数`raise_on_extra_parameters`
`import_utils``parse_datetimes``allow_datetime_change``add_login_shortcut`
`munchify_response`不传沿用它们的默认值
**刻意不传 `ground_truth_mode`** AppWorld 0.1.3 `AppWorldInitDefaults` 里那个
字段写的是 ``Literal["full" "minimal"]``两个字面量之间漏了逗号 Python 拼接成
单一取值 ``"fullminimal"``默认值 ``"minimal"`` 并不在这个 Literal 只是因为
pydantic 不校验默认值才没暴露一旦显式传 ``"minimal"``请求体就会走校验然后被拒
"""
return {
"experiment_name": self._experiment_name,
"max_interactions": self._max_interactions,
"max_api_calls_per_interaction": 1000,
"raise_on_unsafe_syntax": True,
"null_patch_unsafe_execution": True,
"load_ground_truth": True, # evaluate 需要它
"raise_on_failure": True,
# 环境自身的随机种子固定不动。它控制的是环境初始状态与 API 响应里的随机成分,
# 也就是「题目本身」——跟着别的什么东西变的话,两次压测面对的就不是同一道题,
# 而「同一道题这次慢了」正是压测要看的东西。100 是 AppWorld 的默认值。
"random_seed": 100,
"timeout_seconds": self._execution_timeout_s,
"show_api_response_schemas": True,
"gc_threshold": 500000,
}
def _require_client(self) -> httpx.AsyncClient:
"""取 HTTP 连接;没 start 过就用是调用方的顺序错误,直接说清楚。"""
if self._client is None:
raise AppWorldError("环境尚未启动,先调用 start() 或用 async with")
return self._client
def _verify_data_layout(self) -> None:
"""启动前确认数据在位,免得每个容器各自失败一次才发现是数据没下。"""
for path in (self._datasets_dir, self._tasks_dir):
if not path.is_dir():
raise FileNotFoundError(
f"AppWorld 数据缺失:{path} 不存在。在装有 appworld 的环境里跑:"
f"appworld install && appworld download data --root {self._data_root}"
)
def _success_of(tracker: Mapping[str, Any], task_id: str) -> bool:
"""从评测结果里取这道题过没过。
官方评测器先看 `success`再回落到 `passes_fully`字段迁移中的兼容写法两个都没有
就报错而不是当成失败读不出分数记成没做对会让一次接口变更表现成成绩暴跌
而且查不出原因
"""
for key in ("success", "passes_fully"):
if key in tracker:
return bool(tracker[key])
raise AppWorldError(
f"题目 {task_id} 的评测结果里既没有 success 也没有 passes_fully"
f"实有字段:{sorted(tracker)}"
)
def _require(payload: Mapping[str, Any], key: str, task_id: str) -> Any:
"""从环境返回里取一个必需字段,缺了就报错。"""
if key not in payload:
raise AppWorldError(f"题目 {task_id} 的环境返回缺字段 {key!r},实有字段:{sorted(payload)}")
return payload[key]
async def _post_json(
client: httpx.AsyncClient,
base_url: str,
path: str,
body: Mapping[str, Any],
) -> Any:
"""打一个 POST,检查状态码,剥掉 environment server 统一的 output 包装。
**传输层的失败也要翻成 `AppWorldError`** 连不上读超时连接中途断掉httpx 抛的是
它自己的异常类型而上层的动作执行接缝只认 `AppWorldError`不翻的话容器挂掉会让整
次运行以一个未捕获的第三方异常炸出去而正确的行为是记成环境故障由库合成一段观察
`env_error` 收尾这个缺口是压测里真把容器 `docker kill` 掉之后才暴露的在那之前
连不上这一档只写在上面那个 docstring 没有实现
`asyncio.CancelledError` 不在 `httpx.HTTPError` 的范围内它继承 `BaseException`
所以取消照旧原样穿过去
"""
try:
response = await client.post(f"{base_url}{path}", json=dict(body))
except httpx.HTTPError as exc:
raise AppWorldError(f"{path} 的请求没能完成:{type(exc).__name__}: {exc}") from exc
if response.is_error:
raise AppWorldError(f"{path} 返回 HTTP {response.status_code}{response.text[:2000]}")
payload = response.json()
if not isinstance(payload, dict) or "output" not in payload:
raise AppWorldError(f"{path} 的返回不是 {{'output': ...}} 的形态:{str(payload)[:500]}")
return payload["output"]
async def _run(command: Sequence[str]) -> tuple[int, str, str]:
"""跑一条 docker 命令,返回 (退出码, stdout, stderr)。"""
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
assert process.returncode is not None # communicate() 返回后必然已退出
return process.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace")
__all__ = [
"CONTAINER_NAME_PREFIX",
"DEFAULT_IMAGE",
"DEFAULT_PORT_BASE",
"AppWorldError",
"AppWorldPool",
"AppWorldSession",
"ContainerPool",
"ContainerPoolError",
"Mount",
"TaskScore",
"container_args_for_port",
]
+134
View File
@@ -0,0 +1,134 @@
"""AppWorld 环境层的离线冒烟:起 1 个容器,跑通一整条会话链路,**一次模型调用都不打**。
跑法在仓库根目录::
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \\
python -m tools.soak.check_appworld --data-root /path/to/appworld
`-m` 而不是直接给文件路径直接跑文件时 `tools` 不在 `sys.path`
`from tools.soak.appworld import ...` ImportError
它验的是环境层自己容器起得来题实例化得了代码执行得了评测调得通容器删得干净
执行的三段代码是写死的其中一段故意写错那一段验的是代码报错不抛异常错误文本原样
回来这条行为因为它是上层循环最依赖的一条而正常路径验不出来
退出码 0 表示全绿任何一步失败都以非零退出并把原始报错打出来**不做任何降级**
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
from tools.soak.appworld import AppWorldPool
#: 冒烟用的题目。取 train 划分的第一条而不是写死一个 ID:题集换过一次(发布的 train 是
#: 90 题,原论文写的是 105 题),写死的 ID 有一天会变成一句「找不到这道题」,而那时看起来
#: 像是环境坏了。
_SPLIT = "train"
#: 三段写死的代码。顺序有意义:先确认 API 文档读得到(环境的数据挂载没问题),再确认
#: 有状态执行器跨步保留变量(这是 AppWorld 与 docker-exec 型环境的关键区别),最后确认
#: 报错不会被压成异常。
_PROBES: tuple[tuple[str, str], ...] = (
("列出可用的 app", "print(apis.api_docs.show_app_descriptions())"),
("定义一个变量", "soak_marker = 6 * 7"),
("读回上一步的变量(验有状态执行器)", "print(soak_marker)"),
("故意写错(验错误不抛异常)", "print(this_name_does_not_exist)"),
)
def _line(title: str) -> None:
print(f"\n=== {title} ===", flush=True)
async def _smoke(data_root: Path, port_base: int) -> int:
pool = AppWorldPool(data_root=data_root, size=1, port_base=port_base)
_line("池配置")
print(f"数据根目录:{data_root}(只读挂载)")
print(f"输出目录: {pool.outputs_dir}")
print(f"宿主端口: {pool.ports}")
async with pool:
task_ids = pool.list_task_ids(_SPLIT)
task_id = task_ids[0]
_line(f"划分 {_SPLIT}")
print(f"{len(task_ids)} 道题,取第一道:{task_id}")
async with pool.session(task_id) as session:
_line("初始化")
print(f"容器地址:{session.base_url}")
print(f"虚拟时间:{session.datetime}")
print(f"主管: {session.supervisor}")
print(f"题面: {session.instruction.strip()[:400]}")
for title, code in _PROBES:
_line(f"执行:{title}")
print(f"$ {code}")
output = await session.execute(code)
print(output.strip()[:1200] or "<无输出>")
_line("问一次完成没有")
print(f"is_done() = {await session.is_done()}(没做题,应为 False")
_line("评测")
score = await session.evaluate()
print(f"success = {score.success}(没做题,应为 False")
print(f"执行次数 = {score.n_executions}")
print(f"detail 的键 = {sorted(score.detail)}")
_line("容器残留检查")
return await _report_leftovers()
async def _report_leftovers() -> int:
"""确认压测的容器一个都没剩下。剩了就非零退出——它会占着端口让下一次跑直接失败。"""
process = await asyncio.create_subprocess_exec(
"docker",
"ps",
"-a",
"--filter",
"name=polyloop-soak-appworld",
"--format",
"{{.Names}} {{.Status}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
print(f"docker ps 失败(退出码 {process.returncode}):{stderr.decode(errors='replace')}")
return 1
leftovers = stdout.decode(errors="replace").strip()
if leftovers:
print(f"仍有残留容器:\n{leftovers}")
return 1
print("docker ps -a 里没有 polyloop-soak-appworld 开头的容器,干净。")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--data-root",
type=Path,
required=True,
help="AppWorld 数据根目录,下面应有 data/datasets 与 data/tasks",
)
parser.add_argument(
"--port-base",
type=int,
default=8200,
help="容器池起始宿主端口(默认 8200,避开 dissect 的 8100",
)
args = parser.parse_args()
# 容器池的告警走 logging,默认级别是 WARNING 且没有 handler,会被静默丢掉——而
# 「清理容器失败」正是这条路上唯一的线索。
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
return asyncio.run(_smoke(args.data_root.resolve(), args.port_base))
if __name__ == "__main__":
sys.exit(main())
+3065
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
# 这两个提示词文件是什么
`run_prefix.txt``item_suffix.txt` 合起来是 AppWorld 官方 ReAct baseline 的提示词,
压测的 AppWorld 场景直接用它们。
原件是 `StonyBrookNLP/appworld` 仓库的
`experiments/prompts/react_code_agent/instructions.txt`371 行,Apache License 2.0)。
这里的两个文件经由 `reference/dissect/config/prompts/appworld/` 转手而来——那边把原件拆成
两段,并把示例演示里的主管信息换成了固定假值(`Sam Carter / sam.carter@example.com /
555-0100`)。除拆分与那处替换之外没有其他改动。原始许可与著作权归 AppWorld 作者,
Apache-2.0 允许再分发。
## 为什么照抄而不自己写一份
压测要拿 dissect 已有的 937 条真实轨迹当对照组——步数分布、停止原因构成、成功率。
提示词换一份,这三样都会跟着变,对照就失去意义:分不清是内核的行为变了还是提示词变了。
## 改动它们的后果
`observation_template` 与这份提示词是绑死的。提示词里整段示例演示都按
`Output:\n```\n…\n```\n\n` 的形态写观察,模板改了就会让示例与实况对不上,而那正是这份
提示词最想避免的事。模板的取值在 `tools/soak/scenarios/appworld.py`,改任何一边都要同时
改另一边,并且作废与 dissect 基线的对照。
## 变量
`run_prefix.txt` 里只有一个 `{{ app_descriptions }}`,值从环境跑一次
`print(apis.api_docs.show_app_descriptions())` 拿到,整个压测复用同一份。
`item_suffix.txt` 里有 `{{ main_user.first_name }}``{{ main_user.last_name }}`
`{{ main_user.email }}``{{ main_user.phone_number }}``{{ instruction }}`
前四个来自题目的主管信息,最后一个是题面。
渲染器是 `tools/soak/scenarios/appworld.py` 里自己写的极简替换,不是 Jinja——这两个模板
只用到 `{{ 名字 }}``{{ 名字.属性 }}` 两种形态,为它装一个模板引擎不值得。
渲染完会校验文本里不再残留 `{{`:留一个没替换的占位符,模型会看见字面量
`{{ instruction }}`,而那在轨迹里看起来只是模型表现差。
@@ -0,0 +1,5 @@
USER:
Using these APIs, now generate code to solve the actual task:
My name is: {{ main_user.first_name }} {{ main_user.last_name }}. My personal email is {{ main_user.email }} and phone number is {{ main_user.phone_number }}.
Task: {{ instruction }}
+365
View File
@@ -0,0 +1,365 @@
USER:
I am your supervisor, and you are an AI Assistant whose job is to complete my day-to-day tasks fully autonomously.
To do this, you will need to interact with app(s) (e.g., spotify, venmo etc) using their associated APIs on my behalf. For this you will undertake a *multi-step conversation* using a python REPL environment. That is, you will write the python code, the environment will execute it and show you the result, based on which, you will write python code for the next step and so on, until you've achieved the goal. This environment will let you interact with app(s) using their associated APIs on my behalf.
Here are three key APIs that you need to know to get more information
# To get a list of apps that are available to you.
```python
print(apis.api_docs.show_app_descriptions())
```
# To get the list of APIs under any app listed above, e.g. spotify
```python
print(apis.api_docs.show_api_descriptions(app_name='spotify'))
```
# To get the specification of a particular api, e.g. spotify app's login api
```python
print(apis.api_docs.show_api_doc(app_name='spotify', api_name='login'))
```
Each code execution will produce an output that you can use in subsequent calls. Using these APIs, you can now generate code, that I will execute, to solve the task. Let's start with the task
My name is: Sam Carter. My personal email is sam.carter@example.com and phone number is 555-0100.
Task: How many playlists do I have in my Spotify playlist library?
ASSISTANT:
Okay. Let's first find which APIs are available to use in Spotify.
```python
print(apis.api_docs.show_api_descriptions(app_name='spotify'))
```
USER:
Output:
```
[
...
"login : Login to your account.",
"logout : Logout from your account.",
...
]
```
ASSISTANT:
Okay. Looks like I can use the `login` api. Let's find its specifications.
```python
print(apis.api_docs.show_api_doc(app_name='spotify', api_name='login'))
```
USER:
Output:
```
{
"app_name": "spotify",
"api_name": "login",
"path": "/spotify/auth/token",
"method": "POST",
"description": "Login to your account.",
"parameters": [
{
"name": "username",
"type": "string",
"required": true,
"description": "Your account email.",
"default": null,
"constraints": []
},
{
"name": "password",
"type": "string",
"required": true,
"description": "Your account password.",
"default": null,
"constraints": []
}
],
"response_schemas": {
"success": {
"access_token": "string",
"token_type": "string"
},
"failure": {
"message": "string"
}
}
}
```
ASSISTANT:
I need the supervisor's username and password. Let's see if any app can help me get that.
```python
print(apis.api_docs.show_app_descriptions())
```
USER:
Output:
```
{{ app_descriptions }}
```
ASSISTANT:
Looks like the supervisor app could help me with that.
```python
print(apis.api_docs.show_api_descriptions(app_name='supervisor'))
```
USER:
Output:
```
[
...
"show_account_passwords : Show your supervisor's account passwords."
...
]
```
ASSISTANT:
I can use `show_account_passwords` to get the passwords. So I will call that and save it in a variable for future reference.
```python
passwords=apis.supervisor.show_account_passwords()
print(passwords)
```
USER:
Output:
```
[
{
"account_name": "spotify",
"password": "dummy_spotify_pass"
},
{
"account_name": "file_system",
"password": "dummy_fs_pass"
},
...
]
```
ASSISTANT:
So the Spotify password is an entry in the `passwords` list with the account_name=spotify. I can use it to login now with the provided email address: sam.carter@example.com.
```python
spotify_password = [account_password["account_name"] == "spotify" for account_password in passwords][0]["password"]
login_result = apis.spotify.login(username='sam.carter@example.com', password=spotify_password)
print(login_result)
```
USER:
Output:
```
{
"access_token": "fake_access_token",
"token_type": "Bearer"
}
```
ASSISTANT:
First, let's check the available APIs for the Spotify app.
```python
print(apis.api_docs.show_api_descriptions(app_name='spotify'))
```
USER:
Output:
```
[
...
{
"name": "show_playlist_library",
"description": "Search or show a list of playlists in your playlist library."
},
{
"name": "show_playlist",
"description": "Get detailed information about a specific playlist. You can view your own playlists or others' playlists if they are public."
},
...
]
```
ASSISTANT:
The `show_playlist_library` API seems to be the one we need. Let's check its specifications.
```python
print(apis.api_docs.show_api_doc(app_name='spotify', api_name='show_playlist_library'))
```
USER:
Output:
```
{
"app_name": "spotify",
"api_name": "show_playlist_library",
"path": "/spotify/library/playlists",
"method": "GET",
"description": "Search or show a list of playlists in your playlist library.",
"parameters": [
{
"name": "access_token",
"type": "string",
"required": true,
"description": "Access token obtained from spotify app login.",
"default": null,
"constraints": []
},
{
"name": "query",
"type": "string",
"required": false,
"description": "The search query string.",
"default": "",
"constraints": []
},
{
"name": "is_public",
"type": "boolean",
"required": false,
"description": "Whether to show public playlists or private playlists.",
"default": null,
"constraints": []
},
{
"name": "page_index",
"type": "integer",
"required": false,
"description": "The index of the page to return.",
"default": 0,
"constraints": [
"value >= 0"
]
},
{
"name": "page_limit",
"type": "integer",
"required": false,
"description": "The maximum number of results to return per page.",
"default": 5,
"constraints": [
"value >= 1, <= 20"
]
},
{
"name": "sort_by",
"type": "string",
"required": false,
"description": "The attribute to sort the library playlists by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and title. ...",
"default": null,
"constraints": []
}
],
"response_schemas": {
"success": [
{
"playlist_id": 1,
"title": "string",
"is_public": true,
"rating": 0.0,
"like_count": 1,
"review_count": 1,
"owner": {
"name": "string",
"email": "user@example.com"
},
"created_at": "2019-01-01T00:00:00",
"song_ids": [
1
]
}
],
"failure": {
"message": "string"
}
}
}
```
ASSISTANT:
I need to page through all the playlists to get the list of playlists and save it in `playlists`.
```python
page_index = 0
playlists = []
while page_index < 10:
playlist_page = apis.spotify.show_playlist_library(access_token=spotify_access_token, page_index=page_index)
if playlist_page:
playlists.extend(playlist_page)
page_index += 1
else:
break
num_playlists = len(playlists)
print(num_playlists)
```
USER:
Output:
```
23
```
ASSISTANT:
Now that the task is completed, I can call apis.supervisor.complete_task(). Since this task has an answer to be returned, I will pass that as an argument.
```python
apis.supervisor.complete_task(answer=num_playlists)
```
USER:
Output:
Marked the active task complete.
USER:
**Key instructions**:
A. General instructions:
- Act fully on your own. You must make all decisions yourself and never ask me or anyone else to confirm or clarify. Your role is to solve the task, not to bounce questions back, or provide me directions to follow.
- You have full access -- complete permission to operate across my connected accounts and services.
- Never invent or guess values. For example, if I ask you to play a song, do not assume the ID is 123. Instead, look it up properly through the right API.
- Never leave placeholders; don't output things like "your_username". Always fill in the real value by retrieving it via APIs (e.g., Supervisor app for credentials).
- When I omit details, choose any valid value. For example, if I ask you to buy something but don't specify which payment card to use, you may pick any one of my available cards.
- Avoid collateral damage. Only perform what I explicitly ask for. Example: if I ask you to buy something, do not delete emails, return the order, or perform unrelated account operations.
B. App-specific instructions:
- All my personal information (biographical details, credentials, addresses, cards) is stored in the Supervisor app, accessible via its APIs.
- Any reference to my friends, family or any other person or relation refers to the people in my phone's contacts list.
- Always obtain the current date or time, from Python function calls like `datetime.now()`, or from the phone app's get_current_date_and_time API, never from your internal clock.
- All requests are concerning a single, default (no) time zone.
- For temporal requests, use proper time boundaries, e.g., when asked about periods like "yesterday", use complete ranges: 00:00:00 to 23:59:59.
- References to "file system" mean the file system app, not the machine's OS. Do not use OS modules or functions.
- Paginated APIs: Always process all results, looping through the page_index. Don't stop at the first page.
C. Code-operation instructions
- Make sure to end code blocks with ``` followed by a newline(\n).
- Remember, you can use the variables in your code in subsequent code blocks.
- Remember that the email addresses, access tokens and variables (e.g. spotify_password) in the example above are not valid anymore.
- Always look at API specifications (using apis.api_docs.show_api_doc) before calling an API.
- Write small chunks of code and only one chunk of code in every step. Make sure everything is working correctly before making any irreversible changes.
- The Python environment supports the standard library. But system-level operations that may access or affect OS files, processes, etc., are not allowed and will raise an error if called.
- To interact with apps, only use the provided app APIs, and not the corresponding Python packages, e.g., do NOT use `spotipy` for Spotify.
- The provided API documentation has both the input arguments and the output JSON format. Use this information when making API calls and parsing their outputs.
D. Task-completion instructions:
You must call the `apis.supervisor.complete_task` API after completing the task.
- If an answer is needed, e.g., for "How many songs are in the Spotify queue?", call it with the appropriate answer argument value.
- If no answer is required, e.g., for "Start my Spotify music player.", omit the answer argument (or set it to None/null).
- The task is doable, but if you cannot find a way, you can call it with status="fail" to exit with failure.
When the answer is given:
- Keep answers minimal. Return only the entity, number, or direct value requested - not full sentences.
E.g., for the song title of the current playing track, return just the title.
- Numbers must be numeric and not in words.
E.g., for the number of songs in the queue, return "10", not "ten".
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
"""压测场景:把某一个具体 benchmark 接到 PolyLoop 的公共接缝上。
一个场景要交的东西是固定的四样一个决策解释器模型输出 动作一个动作执行器
动作 观察一份上下文提示词装配成消息序列以及一份装好的 `RunRequest`压测
的驱动侧只认这四样换一个 benchmark 就是换一个本包下的模块
**这一层允许知道 benchmark 的领域细节**`src/polyloop/` 不允许CLAUDE.md §1.1库里
一个业务词都不能有AppWorld 的动作是一段 Python 代码这种话必须写在某个地方写在
这里
"""
+621
View File
@@ -0,0 +1,621 @@
"""AppWorld 场景:把 `tools/soak/appworld.py` 的环境层包成 PolyLoop 认得的四样东西。
交出去的是一个决策解释器`AppWorldParser`一个动作执行器`AppWorldExecutor`一份
上下文`build_context`和一份装好的运行请求`build_run_request`库本体只认这几个接缝
AppWorld 的动作是一段 Python 代码主管的名字要写进提示词一无所知那些全在这里
**解析协议逐字复刻 dissect `harness/agent/parser.py`但一行都不 import ** PolyLoop
是被 dissect 依赖的库反向 import 下游是硬约束CLAUDE.md §1.2 import-linter 断言
那个文件在这里只当协议文档看两条正则判定顺序五条纠错说明的中文原文都照抄实现是
独立写的复刻而不是复用的代价是它会漂移收益是压测负载与 dissect 的生产解析行为逐位一致
于是压出来的解析失败率步数分布对 dissect 有参考价值
**提示词模板来自 AppWorld 官方Apache-2.0 dissect 转手拷进 `tools/soak/prompts/`**
它用行首独占一行的 `USER:` / `ASSISTANT:` 标记切分消息不是消息数组三百多行十来个
来回的示例演示当成一篇连续文本读和改比在结构化配置里写数组可维护得多而且能与官方模板
逐行 diff
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING
from polyloop.ports import Action, InvalidDecision, ParsedReply
from polyloop.session import RunRequest
from polyloop.tools import ToolRegistry
from polyloop.types import (
ActionOutcome,
ActionStatus,
Budget,
Context,
Message,
ReplayPolicy,
Role,
SyntheticObservations,
TextBlock,
)
from tools.soak.appworld import AppWorldError
if TYPE_CHECKING:
from polyloop.types import ModelReply
from tools.soak.appworld import AppWorldPool, AppWorldSession
class AppWorldScenarioError(RuntimeError):
"""这个场景装配不出来:模板缺变量、格式切不出消息、或环境给的必需信息是空的。
只有一个错误类型因为调用方对这几种情形的处置完全一样压测跑不起来人得去看一眼
分成三个类只会让每一处 `except` 都要写三个名字
"""
# ---------------------------------------------------------------------------
# 一、决策解释:从模型输出里抽出要执行的代码
# ---------------------------------------------------------------------------
#: 完整的围栏:```python 到配对的 ```,且**闭合围栏必须独占一行**。逐字照抄 dissect 的
#: `harness/agent/parser.py:44-46`。
#:
#: 「独占一行」这个约束是在修一个真 bug。写成非贪婪的 ```` ```python\s*\n(.*?)``` ```` 时,
#: 匹配遇到代码内部的三反引号就提前收尾——`print("```")` 会被截成 `print("`,一段语法错的
#: 残码被判成「解析成功」交给环境,同时模型输出被截断到那个伪结尾,后半段真正的代码从历史里
#: 消失。模型下一轮看到自己被腰斩的输出加一个语法错误,通常重写同一段代码,于是稳定循环到
#: 步数耗尽。AppWorld 里 agent 写邮件正文、生成 markdown 报告、打印带反引号的 API 文档摘录
#: 时都会踩到。
#:
#: 中间那段 `(?:\r?\n[ \t]*)?` 是可选的,为的是让空块 ```` ```python\n``` ```` 也能被识别成
#: 「块存在但内容为空」,从而给出对症的纠错说明。每处空白类都带上 `\r`,因为模型的输出可能
#: 是 Windows 换行——漏掉它的话 ```` ```python\r\n ```` 匹配不上,整条输出退到「未闭合」分支
#: 去,`\r` 还会留在代码末尾。
_FULL_BLOCK = re.compile(r"```python[ \t\r]*\n(.*?)(?:\r?\n[ \t]*)?```[ \t\r]*(?=\n|$)", re.DOTALL)
#: 未闭合的围栏:```python 之后一直到文本结束。逐字照抄 `parser.py:52`。
#:
#: 它是给「模型配了 stop 序列」那种配置兜底的:AppWorld 官方 CI 配的 stop 是 "```\n",那样
#: 模型的输出会正好停在闭合围栏之前,看起来就是缺了结尾。没有这条兜底,那种配置每一步都会
#: 解析失败。压测这一路不配 stop 序列,所以它基本不会被触发。
_PARTIAL_BLOCK = re.compile(r"```python[ \t\r]*\n(.*)", re.DOTALL)
#: 追加给纠错说明的一句提示。逐字照抄 `parser.py:59`。
#:
#: 多围栏策略取 first_only,模型输出会被截断到第一个围栏结束,它下一轮看到的是自己被腰斩的
#: 回复。不说明原因的话,它多半会以为输出被网络截断了而原样重发,白烧一步。
_ONLY_FIRST_BLOCK_HINT = "另外请注意:每一步只写一个代码块,我只会执行第一个。"
#: 未闭合围栏里什么都没有。逐字照抄 `parser.py:114-117`。
_EMPTY_PARTIAL_BLOCK = (
"你的回复里有一个 ```python 代码块,但它是空的。请在代码块里写出这一步要执行的 Python 代码。"
)
#: 闭合围栏后面还跟着别的东西。逐字照抄 `parser.py:128-131`。
_FENCE_NOT_ON_ITS_OWN_LINE = (
"你的代码块结尾的 ``` 后面还跟了别的内容。请让结尾的 ``` 单独占一行,"
"后面直接换行。" + _ONLY_FIRST_BLOCK_HINT
)
#: 一个围栏都没有。逐字照抄 `parser.py:141-144`。
_NO_CODE_BLOCK = (
"你的回复里没有可执行的代码块。请把这一步要执行的 Python 代码放进一个 "
"```python 开头、``` 结尾的代码块里;每一步只写一个代码块。"
)
#: 第一个完整围栏是空的。逐字照抄 `parser.py:161-165`。
_EMPTY_FIRST_BLOCK = (
"你的回复里第一个 ```python 代码块是空的。"
"请在代码块里写出这一步要执行的 Python 代码。" + _ONLY_FIRST_BLOCK_HINT
)
class AppWorldParser:
"""把模型输出解释成一段要在 AppWorld 里执行的 Python 代码。满足 `polyloop.ports.DecisionParser`。
**多围栏策略固定为 first_only**不做成构造参数dissect 把它做成显式配置项是因为那是
它要扫动的实验因子对照组 smolagents 把所有代码块拼起来执行压测只需要一份确定的
负载多留一个开关只会让这次压的是哪一路多一个变量
**不显式继承那个 Protocol**结构化子类型不需要继承继承会让这个模块 import 一个只用来
做名义基类的东西
"""
def parameters(self) -> Mapping[str, str]:
"""上报可复现参数:动作语言与多围栏策略。
两者都是模型看得见的东西的一部分换一种策略续跑前几步与后几步对同一份模型
输出的解释就不一样了而两段轨迹在文件里看起来是同一次运行
"""
return {"kind": "appworld_code_fence", "multi_block_policy": "first_only"}
def parse(self, reply: ModelReply) -> ParsedReply:
"""抽出要执行的代码。**同步,且对任何输入都不抛异常。**
判定顺序照 dissect `parse_code_action``parser.py:90-145`先找全部完整围栏
没有就退到未闭合围栏再没有就判失败三条支路里只有第一条会截断回填历史的那段文本
解释不出来时返回无效决策而不是抛异常`design/0007` 决策三那一步照常留痕
纠错说明回喂给模型循环继续
"""
output = reply.content
matches = list(_FULL_BLOCK.finditer(output))
if matches:
first = matches[0]
code = first.group(1).strip()
if not code:
return ParsedReply(
history_text=output,
decision=InvalidDecision(explanation=_EMPTY_FIRST_BLOCK),
)
# 截断到第一个围栏结束:模型常在代码块后面自行编造「执行结果」,留着的话下一轮
# 它会把那段幻想当成真发生过的事。
return ParsedReply(
history_text=output[: first.end()],
decision=Action(text=code, tool_call=None),
)
partial = _PARTIAL_BLOCK.search(output)
if partial:
code = partial.group(1).strip()
if not code:
return ParsedReply(
history_text=output,
decision=InvalidDecision(explanation=_EMPTY_PARTIAL_BLOCK),
)
if "```" in code:
# 走到这一支说明:文本里有闭合围栏,但它不满足「独占一行」,例如
# ```` ```python\nprint(1)\n``` done ````。这不是「被 stop 序列截断的未闭合
# 块」,是格式不规范。若照兜底分支处理,围栏连同后面的散文会一起被当成代码
# 交给环境执行,而且判定为解析成功——环境返回一个语法错误,事后统计会把它
# 算成「模型写错代码」,而实际是我们解析错了。
return ParsedReply(
history_text=output,
decision=InvalidDecision(explanation=_FENCE_NOT_ON_ITS_OWN_LINE),
)
# **这里与被复刻的 dissect 实现有一处不同**:dissect 在这一支给历史文本补回结尾的
# 三反引号(`parser.py:133-136`),让那条 assistant 消息形态完整;这里不补。
# 这处不同当初是为了迁就公共契约里一条「history_text 不长于模型原文」的断言,那条
# 断言已经撤销(`polyloop.testing.DecisionParserContract` 里对应那条用例的 docstring
# 写着理由),所以它现在没有存在的必要,是一笔记在案的欠账,不是一条长期决定。
# 留着不恢复是因为收益不抵成本:补回去要重跑一次打真实网关的压测,才能继续说
# 2026-08-11 那批轨迹是当前这版场景跑出来的;而这条路径要模型输出被 stop 序列截断
# 才走得到,压测不给模型配 stop 序列。哪天要重跑压测,顺手把它补回来。代码抽取本身
# 照旧兜底。
return ParsedReply(
history_text=output,
decision=Action(text=code, tool_call=None),
)
return ParsedReply(
history_text=output,
decision=InvalidDecision(explanation=_NO_CODE_BLOCK),
)
# ---------------------------------------------------------------------------
# 二、动作执行:把代码交给容器里的 AppWorld 环境
# ---------------------------------------------------------------------------
class AppWorldExecutor:
"""在一个已经开好的 AppWorld 会话里执行代码。满足 `polyloop.ports.ActionExecutor`。
一个实例绑定一道题的一次会话 `RunRequest` 一样是每次运行一个
"""
def __init__(self, *, session: AppWorldSession) -> None:
"""Args:
session: 已经实例化好一道题的会话 `AppWorldPool.session()` 产出
"""
self._session = session
@property
def env_executions(self) -> int:
"""环境自己数的实际执行次数。
**它必须来自环境不能是本地计数器** 故障注入那一步要验的是库说走了几步
环境真的被执行了几次对不对得上用本地计数器的话两边都出自我们自己验的就成了
我们和我们自己一致
"""
return self._session.n_executions
def parameters(self) -> Mapping[str, str]:
"""上报可复现参数:动作接口的种类与这次做的是哪道题。
**刻意不放 `base_url`** 容器端口是从池里租来的每次运行甚至同一次运行的每道题
都不同进了参数快照续跑时的逐字段比对必然报参数漂移而那是一次假故障
"""
return {"kind": "appworld_session", "task_id": self._session.task_id}
async def execute(self, action: Action) -> ActionOutcome:
"""执行一段代码,顺带问一次环境「做完了没有」。
**代码报错超时语法错都不是异常**环境把 traceback 或超时提示原样放在返回文本里
那是给模型看的正常观察所以这一路是 `EXECUTED` 而不是 `ENV_ERROR`只有环境本身坏了
连不上HTTP 2xx返回体不是约定形状才会抛 `AppWorldError`这条分界照
`polyloop.tools.RegistryExecutor` 的同名分支填
**`CancelledError` 不捕获**CLAUDE.md §1.6`AppWorldError` `RuntimeError`
子类下面那个 `except` 接不到继承自 `BaseException` 的取消别的未预料异常同样不接
压测就是要看见它们吞掉只会让故障变成一条内容古怪的观察CLAUDE.md §1.7
"""
try:
observation = await self._session.execute(action.text)
# 完成信号只有环境给得出(agent 调没调过 `apis.supervisor.complete_task()`),
# 而它每一步都可能翻转,所以每一步都问一次。
env_reported_completion = await self._session.is_done()
except AppWorldError as exc:
return ActionOutcome(
status=ActionStatus.ENV_ERROR,
observation=str(exc),
# 这段文本是环境(或与环境通信的那一层)给的,不是库合成的占位。
observation_is_synthetic=False,
env_reported_completion=False,
observation_truncated_chars=0,
)
return ActionOutcome(
status=ActionStatus.EXECUTED,
observation=observation,
observation_is_synthetic=False,
env_reported_completion=env_reported_completion,
# 这一层不截断观察。AppWorld 的输出由环境侧自己限长,我们原样转交。
observation_truncated_chars=0,
)
# ---------------------------------------------------------------------------
# 三、提示词装配:模板文本 → 消息序列
# ---------------------------------------------------------------------------
#: 提示词模板所在目录。两个文件来自 AppWorld 官方(Apache-2.0),经 dissect 转手,内容不改。
PROMPTS_DIR = Path(__file__).resolve().parents[1] / "prompts" / "appworld"
#: 行首独占一行的角色标记。逐字照抄 dissect 的 `harness/agent/context.py:31`。
#: 官方模板只用 USER / ASSISTANT 两种(ReAct 不使用 system 角色),SYSTEM 一并认得。
_ROLE_MARK = re.compile(r"^(USER|ASSISTANT|SYSTEM):\s*$", re.MULTILINE)
_ROLE_OF = {"USER": Role.USER, "ASSISTANT": Role.ASSISTANT, "SYSTEM": Role.SYSTEM}
#: 模板里的变量占位符:`{{ name }}` 与 `{{ name.attr }}` 两种形态,花括号内两侧允许有空格。
#:
#: **刻意不用 jinja2**:当前环境里没有它,而这两个模板一共只用到 `app_descriptions`、
#: `main_user` 的四个字段和 `instruction`。为一个五行的替换规则拉一个模板引擎进来,压测就多
#: 了一个装不上就跑不起来的依赖。
_PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\s*\}\}")
#: 主管信息里提示词要用到的四个字段。
_SUPERVISOR_FIELDS = ("first_name", "last_name", "email", "phone_number")
def split_by_role(text: str, *, where: str) -> list[tuple[Role, str]]:
"""按行首角色标记把整段模板切成 `(角色, 正文)` 列表。
Args:
text: 模板原文
where: 出错信息里用来指认是哪份模板
Raises:
AppWorldScenarioError: 没有任何角色标记第一个标记之前有内容或某一段是空的
"""
marks = list(_ROLE_MARK.finditer(text))
if not marks:
raise AppWorldScenarioError(f"{where} 里没有任何 USER:/ASSISTANT: 行首标记,切不出消息列表")
if text[: marks[0].start()].strip():
raise AppWorldScenarioError(f"{where} 的第一个角色标记之前有内容,无法归属到某个角色")
blocks: list[tuple[Role, str]] = []
for index, mark in enumerate(marks):
end = marks[index + 1].start() if index + 1 < len(marks) else len(text)
content = text[mark.end() : end].strip()
if not content:
raise AppWorldScenarioError(f"{where} 里第 {index + 1} 个消息块是空的")
blocks.append((_ROLE_OF[mark.group(1)], content))
return blocks
def render_template(text: str, variables: Mapping[str, object], *, where: str) -> str:
"""把 `{{ 变量 }}` 换成取值。
**变量缺失显式报错不当空串** 渲染成空串的表现是提示词里凭空少一段模型成绩下降
而没有任何地方会告诉你原因
**残留的 `{{` 也报错** 静默留下一个没替换的占位符会让模型看见字面量 `{{ instruction }}`
而那在轨迹里看起来只是模型表现差这道检查落在**模板**上而不是渲染结果上合法占位符
先从模板里抠掉剩下的文本里还有 `{{` 就说明写了一个形态不对的占位符`{{ a-b }}`
少一个右花括号之类查渲染结果的话插值内容里恰好带 `{{` 就会误报一次
Raises:
AppWorldScenarioError: 有形态不对的占位符变量没提供或取值不是字符串
"""
residual = _PLACEHOLDER.sub("", text)
if "{{" in residual:
raise AppWorldScenarioError(
f"{where} 里有形态不对的占位符:抠掉全部合法的 {{{{ 变量 }}}} 之后仍残留 '{{{{'"
f"渲染器只认 {{{{ name }}}}{{{{ name.attr }}}} 两种形态"
)
def replace(match: re.Match[str]) -> str:
return _lookup(match.group(1), match.group(2), variables, where=where)
return _PLACEHOLDER.sub(replace, text)
def render_to_messages(
text: str, variables: Mapping[str, object], *, where: str
) -> tuple[Message, ...]:
"""先按角色标记切分**模板原文**,再逐段渲染。
**顺序不能反** 反过来会开一个内容能改变结构的口子`run_prefix.txt` 里的
`{{ app_descriptions }}` 位于一个代码围栏内部如果先整段渲染再切分那么只要环境返回的
文本里出现独占一行的 `USER:`前缀就会被切出多余的消息而且不报错先切后渲染插值
内容永远只能落在某一条消息**内部**改不了消息边界
Raises:
AppWorldScenarioError: 切不出消息渲染出问题或某一段渲染后是空的
"""
messages: list[Message] = []
for index, (role, content) in enumerate(split_by_role(text, where=where)):
rendered = render_template(content, variables, where=f"{where}{index + 1} 个消息块")
rendered = rendered.strip()
if not rendered:
raise AppWorldScenarioError(f"{where} 里第 {index + 1} 个消息块渲染后是空的")
messages.append(Message(role=role, content=(TextBlock(text=rendered),)))
return tuple(messages)
def build_context(*, session: AppWorldSession, app_descriptions: str) -> Context:
"""把两份模板渲染成 PolyLoop 的上下文。
分两段是因为供应商按前缀缓存计费`run_prefix.txt` 整个压测里逐字节不变`item_suffix.txt`
每道题都不同把逐题变化的东西排到前面会让缓存静默失效
Args:
session: 已开好的会话题面与主管信息从它取
app_descriptions: run 恒定的可用 app 清单 `load_app_descriptions`
Raises:
AppWorldScenarioError: 模板文件缺失切不出消息或主管信息有空字段
"""
run_prefix, item_suffix = _read_templates()
supervisor = _verified_supervisor(session)
return Context(
run_level=render_to_messages(
run_prefix,
{"app_descriptions": app_descriptions},
where="run_prefix.txt",
),
goal_level=render_to_messages(
item_suffix,
{"main_user": supervisor, "instruction": session.instruction},
where="item_suffix.txt",
),
)
def _read_templates() -> tuple[str, str]:
"""读两份模板原文。缺文件直接报错,不兜底。"""
texts: list[str] = []
for name in ("run_prefix.txt", "item_suffix.txt"):
path = PROMPTS_DIR / name
if not path.is_file():
raise AppWorldScenarioError(f"提示词模板缺文件:{path}")
texts.append(path.read_text(encoding="utf-8"))
return texts[0], texts[1]
def _verified_supervisor(session: AppWorldSession) -> dict[str, str]:
"""取主管信息,并确认提示词要用的四个字段都非空。
单独校验是因为渲染器只拦得住键不存在拦不住键存在但值是 None 或空串后者会
被安静地渲染成字面量 `None` 或一段空白于是提示词变成 `My name is: Jose None.`
`phone number is `而模型会照着这个去调 API 查一个不存在的人这类故障不报错只让
成绩变差
"""
supervisor = dict(session.supervisor)
missing = [key for key in _SUPERVISOR_FIELDS if not supervisor.get(key)]
if missing:
raise AppWorldScenarioError(
f"题目 {session.task_id} 的主管信息缺字段 {missing}(值为空或 None)。"
f"提示词要用它们介绍任务委托人,缺了会误导模型去查一个不存在的人"
)
return supervisor
def _lookup(
name: str, attribute: str | None, variables: Mapping[str, object], *, where: str
) -> str:
"""取一个占位符的值。缺任何一环都报错。"""
if name not in variables:
raise AppWorldScenarioError(
f"{where} 用到了变量 {name!r},但没有提供它;已提供的是 {sorted(variables)}"
)
value = variables[name]
if attribute is None:
if not isinstance(value, str):
raise AppWorldScenarioError(
f"{where} 的变量 {name!r} 要直接插进文本,取值必须是字符串,"
f"收到 {type(value).__name__}"
)
return value
if not isinstance(value, Mapping):
raise AppWorldScenarioError(
f"{where}{name}.{attribute},但 {name!r} 不是一份映射,收到 {type(value).__name__}"
)
if attribute not in value:
raise AppWorldScenarioError(
f"{where}{name}.{attribute},但 {name!r} 里没有这个键;实有 {sorted(value)}"
)
item = value[attribute]
if not isinstance(item, str):
raise AppWorldScenarioError(
f"{where}{name}.{attribute} 取值必须是字符串,收到 {type(item).__name__}"
)
return item
# ---------------------------------------------------------------------------
# 四、run 级变量:可用 app 清单
# ---------------------------------------------------------------------------
#: 问环境要 app 清单的那段代码,出自官方提示词自己教的三个 API 之一。
_APP_DESCRIPTIONS_CODE = "print(apis.api_docs.show_app_descriptions())"
#: 缓存文件的默认位置。它进 `.gitignore`——这份文本是环境的产物,不是源码。
DEFAULT_APP_DESCRIPTIONS_CACHE = (
Path(__file__).resolve().parents[1] / ".cache" / "app_descriptions.txt"
)
async def load_app_descriptions(
pool: AppWorldPool,
*,
task_id: str,
cache_path: Path | str = DEFAULT_APP_DESCRIPTIONS_CACHE,
) -> str:
"""取可用 app 的清单,优先读磁盘缓存。
它是 run 级变量整个压测里所有任务共用同一份而且它是提示词固定前缀的一部分逐题重取
只会白占一个容器第一次要开一个会话去问环境之后直接读缓存
Args:
pool: 已经 `start()` 过的环境入口只有缓存不存在时才会用到它
task_id: 借哪道题来开这个会话问的是全局的 app 文档跟具体是哪道题无关
cache_path: 缓存文件
Raises:
AppWorldError: 环境返回了空文本空的 app 列表会让整批任务全部失败而表现是
模型不会用 API静默用空串的话这个故障永远查不出来
AppWorldScenarioError: 缓存文件存在但内容是空的
"""
path = Path(cache_path)
if path.exists():
cached = path.read_text(encoding="utf-8")
if not cached.strip():
raise AppWorldScenarioError(
f"app 清单的缓存文件 {path} 是空的。删掉它重新取;"
f"空清单会让整批任务全部失败,而表现是「模型不会用 API」"
)
return cached
async with pool.session(task_id) as session:
descriptions = await session.execute(_APP_DESCRIPTIONS_CODE)
if not descriptions.strip():
raise AppWorldError(
f"环境对 {_APP_DESCRIPTIONS_CODE} 返回了空文本。它是提示词里的 app 清单,"
f"空的话整批任务都会失败,而表现是「模型不会用 API」"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(descriptions, encoding="utf-8")
return descriptions
# ---------------------------------------------------------------------------
# 五、装配:预算、合成观察、运行请求
# ---------------------------------------------------------------------------
#: 环境输出回喂给模型时的包装格式。逐字照抄 dissect 的
#: `config/agent/appworld.yaml:31`,它本身照抄 AppWorld 官方——提示词里那整段十来个来回的
#: 示例演示都按这个格式写,改一个字符就会让示例与实况对不上,而那正是这份提示词最想避免的事。
OBSERVATION_TEMPLATE = "Output:\n```\n{observation}\n```\n\n"
#: 取消进来之后留给库写结束记录的秒数。
_CANCEL_GRACE_SECONDS = 5.0
def build_budget() -> Budget:
"""AppWorld 这一路的预算。四个数字的来历各不相同,都写在下面。"""
return Budget(
# 40 而不是 AppWorld 官方当前实验配置的 50,照 dissect 的生产配置
# `config/agent/appworld.yaml:19`):它对齐的是 ASSAY 公开的裸 ReAct 配置。它必须
# 小于等于环境侧的 max_interactions`AppWorldPool` 的构造参数,默认也是 40),两者
# 构成双保险。
max_steps=40,
# dissect 没有这一维。AppWorld 每步至多一个动作,所以取值等于 max_steps 在行为上无害:
# 动作数永远追不上步数,这一维不会先于步数耗尽。
max_actions=40,
# 3 而不是 1,是因为偶发一次格式失手不该毁掉整道题——纠错说明回喂之后模型通常能自己
# 纠正;3 而不是 10,是因为真陷进去之后每多一步都是白烧钱,40 步的预算经不起这种消耗。
# 照 `appworld.yaml:36`。
max_consecutive_parse_failures=3,
# 提示词字符数的硬上限,**不是截断阈值,是安全网**。压测不做上下文截断,但也不能让
# 提示词无限增长到撞上模型的上下文窗口。取值宽松,让它极少触发。照 `appworld.yaml:43`。
max_prompt_chars=400000,
)
def build_synthetic_observations() -> SyntheticObservations:
"""库合成、回填给模型看的那三段观察。
前两段照 dissect `harness/agent/loop.py:35,38` 逐字第三段 dissect 没有对应物它的
循环里没有动作被拒绝这一档因为那一档只在工具注册表分发时才可能出现 AppWorld
的动作是代码不是工具调用`AppWorldExecutor` 永远不返回 `NOT_EXECUTED`所以这段文本在
这条路上不会被用到字段是必填的写一段与另外两段同样形态的话比起随手填个空串出现
在轨迹里时至少还看得懂
"""
return SyntheticObservations(
action_rejected="[这一步的动作被拒绝,没有交给环境执行]",
env_failed="[环境故障,这一步的动作没有被执行]",
model_call_failed="[模型调用失败,这一步没有产出]",
)
def build_run_request(
*,
run_id: str,
session: AppWorldSession,
app_descriptions: str,
model_binding: Mapping[str, str],
) -> RunRequest:
"""把一道题装配成一次运行的请求。
Args:
run_id: 这次运行的标识同时是日志主键
session: 已经实例化好这道题的会话
app_descriptions: run 级的 app 清单 `load_app_descriptions`
model_binding: 项目自己的标识库不解释原样透传给每次模型调用
Raises:
AppWorldScenarioError: 上下文装配不出来
"""
return RunRequest(
run_id=run_id,
budget=build_budget(),
action_executor=AppWorldExecutor(session=session),
# 空注册表:AppWorld 的动作是一段代码,不走工具调用这条路。它与 `action_executor`
# 同时存在不是重复——不注册工具的项目就是传一个空注册表加一个环境句柄。
tools=ToolRegistry(),
context=build_context(session=session, app_descriptions=app_descriptions),
# 压测不注入 Skill 条目:注入是另一条正交的路,混进来会让「这次压的是什么」变多一维。
injections={},
model_binding=model_binding,
# **NEVER,不可改成 SAFE。** AppWorld 的代码执行有真实副作用(转账、下单、发消息),
# 而恢复时我们并不知道被打断的那次调用有没有真的执行到环境里。状态未知时重放一次
# 转账,损坏的是环境状态本身,事后从轨迹里分辨不出来。这条是故障注入那一步要验的
# 核心不变量。
model_replay_policy=ReplayPolicy.NEVER,
observation_template=OBSERVATION_TEMPLATE,
cancel_grace_seconds=_CANCEL_GRACE_SECONDS,
)
__all__ = [
"DEFAULT_APP_DESCRIPTIONS_CACHE",
"OBSERVATION_TEMPLATE",
"PROMPTS_DIR",
"AppWorldExecutor",
"AppWorldParser",
"AppWorldScenarioError",
"build_budget",
"build_context",
"build_run_request",
"build_synthetic_observations",
"load_app_descriptions",
"render_template",
"render_to_messages",
"split_by_role",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# 压测的四步:干跑 → 正常负载 → 故障注入 → 记分板。
#
# 这个脚本只做「在当前 shell 里按顺序跑」,自己不建 tmux 会话、也不 attach。**长跑要在 tmux
# 里跑**CLAUDE.md §4),所以正确的用法是人先开会话再调它:
#
# tmux new -s polyloop-soak
# SOAK_BUDGET_CALLS=4000 SOAK_FAULT_BUDGET_CALLS=600 SOAK_CONCURRENCY=4 \
# SOAK_LIMIT=100 SOAK_APPWORLD_DATA_ROOT=/path/to/appworld \
# bash tools/soak/soak.sh
#
# 会话名约定是 polyloop-soak。跑完不要急着 kill,留着给人复查。
#
# **末尾一律不接管道。** `pytest ... | tail` 的退出码来自管道最后一节,于是一次失败的跑会
# 报成 exit 0。要留日志就设 SOAK_LOG_DIR,那一路用重定向,不用 tee。
#
# 全部配置都是环境变量,没有位置参数:
#
# 必填
# SOAK_BUDGET_CALLS 正常负载的模型调用次数上限
# SOAK_FAULT_BUDGET_CALLS 故障注入那一步的模型调用次数上限
# SOAK_CONCURRENCY 同时在跑的任务数上限
# SOAK_LIMIT 每个场景最多跑几个任务(GovDoc 一个任务是三次运行)
# SOAK_APPWORLD_DATA_ROOT AppWorld 数据根目录(SOAK_SCENARIO 含 appworld 时)
#
# 可选
# SOAK_OUT 产物根目录,默认 soak-out/<时间戳>
# SOAK_SCENARIO appworld / govdoc / both,默认 both
# SOAK_CONTAINERS AppWorld 容器数,默认 4
# SOAK_SPLITS AppWorld 划分,空格分隔,默认 "train dev"
# SOAK_GOVDOC_DB GovDoc 的审核点 sqlite,不给就用场景里的默认路径
# SOAK_GOVDOC_CORPUS GovDoc 的语料目录,同上
# SOAK_LOG_DIR 每一步的输出重定向到这里;不给就直接打屏(tmux 里能实时看)
# SOAK_SKIP_FAULTS 非空则跳过故障注入那一步
# SOAK_CONDA_ENV conda 环境名,默认 PolyLoop
set -euo pipefail
STEP="启动"
trap 'echo "!!! 第「${STEP}」步失败(退出码 $?" >&2' ERR
CONDA_ENV="${SOAK_CONDA_ENV:-PolyLoop}"
SCENARIO="${SOAK_SCENARIO:-both}"
CONTAINERS="${SOAK_CONTAINERS:-4}"
SPLITS="${SOAK_SPLITS:-train dev}"
LOG_DIR="${SOAK_LOG_DIR:-}"
OUT="${SOAK_OUT:-soak-out/$(date +%Y%m%d-%H%M%S)}"
BUDGET_CALLS="${SOAK_BUDGET_CALLS:?必须给 SOAK_BUDGET_CALLS:正常负载的模型调用次数上限}"
FAULT_BUDGET_CALLS="${SOAK_FAULT_BUDGET_CALLS:?必须给 SOAK_FAULT_BUDGET_CALLS:故障注入那一步的上限}"
CONCURRENCY="${SOAK_CONCURRENCY:?必须给 SOAK_CONCURRENCY:同时在跑的任务数上限}"
LIMIT="${SOAK_LIMIT:?必须给 SOAK_LIMIT:每个场景最多跑几个任务}"
# conda 和 Python 各缓冲一层,两层都得拆——只加其中一个,长跑命令仍然全程无输出。
RUN=(env PYTHONUNBUFFERED=1 conda run --live-stream -n "$CONDA_ENV" python)
RUNS_DIR="$OUT/runs"
FAULT_RUNS_DIR="$OUT/fault-runs"
# 场景相关的参数拼成数组。**用数组不用字符串**:路径里有空格时字符串会在展开时被切开,
# 而表现是「找不到这个目录」,看起来像数据没准备好。
SCENARIO_ARGS=(--scenario "$SCENARIO" --limit "$LIMIT" --containers "$CONTAINERS")
if [[ "$SCENARIO" == "appworld" || "$SCENARIO" == "both" ]]; then
APPWORLD_DATA_ROOT="${SOAK_APPWORLD_DATA_ROOT:?SOAK_SCENARIO 含 appworld 时必须给 SOAK_APPWORLD_DATA_ROOT}"
SCENARIO_ARGS+=(--appworld-data-root "$APPWORLD_DATA_ROOT")
for split in $SPLITS; do
SCENARIO_ARGS+=(--split "$split")
done
fi
# 故障注入那一步的两个 GovDoc 路径没有默认值,而它们的权威在场景模块里。**问 Python 要,
# 不在这里写第二份**:两处各写一份路径,迟早有一处被改、另一处没改,而表现是「数据不在」。
GOVDOC_DB="${SOAK_GOVDOC_DB:-$("${RUN[@]}" -c 'from tools.soak.scenarios.govdoc import DEFAULT_DATA_ROOT as R; print(R / "app.sqlite")')}"
GOVDOC_CORPUS="${SOAK_GOVDOC_CORPUS:-$("${RUN[@]}" -c 'from tools.soak.scenarios.govdoc import DEFAULT_DATA_ROOT as R; print(R / "storage" / "prepared")')}"
if [[ "$SCENARIO" == "govdoc" || "$SCENARIO" == "both" ]]; then
SCENARIO_ARGS+=(--govdoc-db "$GOVDOC_DB" --govdoc-corpus "$GOVDOC_CORPUS")
fi
# 故障注入的参数是另一套:它自己的 `--split` 只收一个值,AppWorld 数据根目录那一项叫
# `--data-root`。这里按 `python -m tools.soak.faults --help` 的形状拼。
FAULT_ARGS=(--govdoc-db "$GOVDOC_DB" --govdoc-corpus "$GOVDOC_CORPUS")
if [[ "$SCENARIO" == "appworld" || "$SCENARIO" == "both" ]]; then
FAULT_ARGS+=(--data-root "$APPWORLD_DATA_ROOT" --split "${SPLITS%% *}")
fi
mkdir -p "$OUT"
step() {
local name="$1"
shift
STEP="$name"
echo ""
echo "=== [$name] $* ==="
if [[ -n "$LOG_DIR" ]]; then
mkdir -p "$LOG_DIR"
"$@" >"$LOG_DIR/$name.log" 2>&1
else
"$@"
fi
}
step 01-干跑 "${RUN[@]}" -m tools.soak.run_soak \
"${SCENARIO_ARGS[@]}" \
--budget-calls "$BUDGET_CALLS" \
--concurrency "$CONCURRENCY" \
--runs-dir "$RUNS_DIR" \
--report "$OUT/dry-run.md" \
--dry-run
step 02-正常负载 "${RUN[@]}" -m tools.soak.run_soak \
"${SCENARIO_ARGS[@]}" \
--budget-calls "$BUDGET_CALLS" \
--concurrency "$CONCURRENCY" \
--runs-dir "$RUNS_DIR" \
--report "$OUT/normal-load.md"
# 正常负载不该有缺文件,所以这一轮不开 --allow-undetermined:判不了就是要人去看一眼。
step 03-记分板-正常负载 "${RUN[@]}" -m tools.soak.scoreboard \
--runs-dir "$RUNS_DIR" \
--report "$OUT/scoreboard-normal.md" \
--completing-tool submit_finding
if [[ -n "${SOAK_SKIP_FAULTS:-}" ]]; then
echo ""
echo "=== [04-故障注入] 按 SOAK_SKIP_FAULTS 跳过 ==="
else
step 04-故障注入 "${RUN[@]}" -m tools.soak.faults \
"${FAULT_ARGS[@]}" \
--runs-dir "$FAULT_RUNS_DIR" \
--budget-calls "$FAULT_BUDGET_CALLS"
# 崩溃注入那一类天然会缺文件,这一轮才该开 --allow-undetermined。
step 05-记分板-故障注入 "${RUN[@]}" -m tools.soak.scoreboard \
--runs-dir "$FAULT_RUNS_DIR" \
--report "$OUT/scoreboard-faults.md" \
--completing-tool submit_finding \
--allow-undetermined
fi
STEP="收尾"
echo ""
echo "=== 全部步骤通过 ==="
echo "产物:$OUT"
ls -1 "$OUT"
+14
View File
@@ -0,0 +1,14 @@
"""压测 harness 自己的测试。
**它不进 `make ci`**`pyproject.toml` `testpaths` 只收 `tests/`那一套守的是库的公共
承诺而这里守的是压测工具跑法是显式给路径::
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \\
python -m pytest tools/soak/tests/ -p no:cacheprovider -q
分开是刻意的压测工具坏了只影响我们自己不该让库的 CI 变红反过来库的 CI 也不该因为
压测这边加了个依赖就装不上
这里的测试全都不起容器不打模型环境层与场景层的接缝上用的是假对象真起容器那条路是
`tools/soak/check_appworld.py`它单独手动跑
"""
+575
View File
@@ -0,0 +1,575 @@
"""AppWorld 场景适配器的测试。
分三块解析器提示词装配动作执行器
解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约写契约套件
本身是给下游继承基类在自己的子类里覆盖必需 fixture 用的压测这边不接那套装配只把五条
断言照着写一遍它是任何新适配器的准入标准压测的适配器也是适配器其中一条另外多守了一件
公共契约没要求的事`history_text` 不长于模型原文公共契约不断言长度解析器有权改写那段
文本改写既可能截短也可能补写那条断言守的是压测这一侧自己的选择解析器一律只截不补
执行器那块用一个假会话不起容器这里要验的是环境返回什么 结果对象怎么填这个映射
而那个映射与容器里发生了什么无关真起容器的那条链路由 `tools/soak/check_appworld.py`
"""
from __future__ import annotations
import asyncio
import pytest
from polyloop.ports import Action, InvalidDecision
from polyloop.types import ActionStatus, ModelReply, ReplayPolicy, Role
from tools.soak.appworld import AppWorldError
from tools.soak.scenarios.appworld import (
OBSERVATION_TEMPLATE,
AppWorldExecutor,
AppWorldParser,
AppWorldScenarioError,
build_budget,
build_context,
build_run_request,
build_synthetic_observations,
render_to_messages,
split_by_role,
)
def _reply(content: str) -> ModelReply:
return ModelReply(call_id="call-1", content=content, thinking="")
#: 一条正常的、能解析出动作的回复。
_ACTION_REPLY = _reply(
"先看看有哪些 app。\n\n```python\nprint(apis.api_docs.show_app_descriptions())\n```\n"
)
#: 一条解析不出任何东西的回复。
_INVALID_REPLY = _reply("我觉得应该先登录 spotify,但我不确定要用哪个接口。")
# ---------------------------------------------------------------------------
# 一、解析器:公共契约的五条
# ---------------------------------------------------------------------------
def test_parse_is_synchronous():
"""`parse` 是同步的,不是协程(契约一)。"""
parsed = AppWorldParser().parse(_ACTION_REPLY)
assert not hasattr(parsed, "__await__")
@pytest.mark.parametrize(
"content",
[
"```python\nprint(1)\n```\n然后我看到了 42。", # 成功且要截断
"```python\n```", # 空的完整围栏
"```python\nprint(1)", # 未闭合围栏兜底
"```python\nprint(1)\n``` done", # 闭合围栏后跟了内容
"什么代码都没有", # 一个围栏都没有
],
)
def test_scenario_parser_only_trims_history_text(content):
"""`history_text` 不会比模型原文长。这守的是这个场景自己的实现选择,不是公共契约。
公共契约不断言长度解析器有权改写回填历史的那段文本改写既可能截短也可能补写
`polyloop.testing.DecisionParserContract`这个场景的解析器一律只截不补所以长度
不会涨它也正是我们与被复刻的 dissect 实现分道的地方dissect 未闭合围栏兜底那一
支给历史文本补回结尾的三反引号补一个字符就会让这条断言失败
"""
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.history_text, str)
assert len(parsed.history_text) <= len(content)
def test_invalid_decision_carries_a_non_empty_explanation():
"""无效决策的说明文本非空(契约三)。"""
parsed = AppWorldParser().parse(_INVALID_REPLY)
assert isinstance(parsed.decision, InvalidDecision)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form():
"""动作分支带着这一步在轨迹里长什么样(契约四)。"""
parsed = AppWorldParser().parse(_ACTION_REPLY)
assert isinstance(parsed.decision, Action)
assert isinstance(parsed.decision.text, str)
assert parsed.decision.text == "print(apis.api_docs.show_app_descriptions())"
# AppWorld 的动作是代码,不是工具调用。
assert parsed.decision.tool_call is None
@pytest.mark.parametrize(
"content",
[
"",
"```",
"``````",
"```python",
"```python\r\n```",
"```PYTHON\nprint(1)\n```",
"\n\n\n",
'```python\nprint("```")\n```',
"```python\n```python\n```",
"{{ 不是模板 }} ```python``` ```",
],
)
def test_any_input_returns_a_decision_rather_than_raising(content):
"""怎么怪的输入都返回一个决策,不抛异常(契约五)。
库对解析失败的处置是留痕 + 回喂纠错说明 + 继续抛异常会让整次运行以一个跟模型无关
的理由炸掉而压测里模型什么都吐得出来
"""
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, Action | InvalidDecision)
if isinstance(parsed.decision, InvalidDecision):
assert parsed.decision.explanation != ""
# ---------------------------------------------------------------------------
# 二、解析器:协议细节
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"content",
[
"```\nprint(1)\n```",
"```bash\nls -la\n```",
"```py\nprint(1)\n```",
],
)
def test_only_python_fences_count(content):
"""只认 ```python,裸围栏与别的语言一律判失败。
放宽会踩到一类静默故障模型贴一段 bash 或一段 JSON 出来我们把它当 Python 交给环境
环境回一个语法错事后统计会记成模型写错代码
"""
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, InvalidDecision)
def test_first_block_wins_and_the_rest_is_cut_off():
"""多个围栏取第一个,并把回填历史的文本截断到它结束。
截断不是可有可无模型常在代码块后面自行编造执行结果留着的话下一轮它会把那段幻想
当成真发生过的事
"""
content = (
"第一步:\n\n```python\nfirst()\n```\n\n"
"Output:\n```\n我编的执行结果\n```\n\n"
"第二步:\n\n```python\nsecond()\n```\n"
)
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, Action)
assert parsed.decision.text == "first()"
assert parsed.history_text.endswith("```python\nfirst()\n```")
assert "second()" not in parsed.history_text
assert "我编的执行结果" not in parsed.history_text
def test_closing_fence_must_be_alone_on_its_line():
"""闭合围栏后面跟着别的内容判失败,并给出对症说明。
照兜底分支处理的话围栏连同后面的散文会一起被当成代码交给环境而且判定为解析成功
"""
parsed = AppWorldParser().parse(_reply("```python\nprint(1)\n``` 好了"))
assert isinstance(parsed.decision, InvalidDecision)
assert "单独占一行" in parsed.decision.explanation
def test_code_containing_backticks_survives():
"""代码内部的三反引号不该把围栏提前切断。
这是那条闭合围栏必须独占一行的正则要修的原 bug非贪婪匹配会把 `print("` 当成完整
代码交出去剩下半截连同真正的代码从历史里消失
"""
parsed = AppWorldParser().parse(_reply('```python\nprint("```")\n```\n'))
assert isinstance(parsed.decision, Action)
assert parsed.decision.text == 'print("```")'
def test_empty_fence_is_a_failure_with_its_own_explanation():
"""空围栏判失败,说明与「没有围栏」那条不同。
五条纠错说明各自对症压成一句会改掉模型收到的信息它的纠错行为也就跟着变
"""
empty = AppWorldParser().parse(_reply("```python\n```"))
none_at_all = AppWorldParser().parse(_reply("我不知道该写什么"))
assert isinstance(empty.decision, InvalidDecision)
assert isinstance(none_at_all.decision, InvalidDecision)
assert empty.decision.explanation != none_at_all.decision.explanation
assert "空的" in empty.decision.explanation
def test_unclosed_fence_still_yields_code():
"""未闭合的围栏仍然抽得出代码,且历史文本不比原文长。
dissect 在这一支补回结尾的三反引号我们不补压测的解析器一律只截不补
"""
content = "我来查一下。\n\n```python\nprint(apis.api_docs.show_app_descriptions())"
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, Action)
assert parsed.decision.text == "print(apis.api_docs.show_app_descriptions())"
assert parsed.history_text == content
assert not parsed.history_text.endswith("```")
def test_parser_parameters_pin_the_action_language():
"""参数快照里写明动作语言与多围栏策略。"""
assert AppWorldParser().parameters() == {
"kind": "appworld_code_fence",
"multi_block_policy": "first_only",
}
# ---------------------------------------------------------------------------
# 三、提示词装配
# ---------------------------------------------------------------------------
def test_split_by_role_finds_the_boundaries():
"""行首独占一行的标记才是边界,正文里的 `USER:` 不是。"""
text = "USER:\n你好\n\nASSISTANT:\n我在\n\nUSER:\n这里提到 USER: 但不在行首\n"
blocks = split_by_role(text, where="样例")
assert [role for role, _ in blocks] == [Role.USER, Role.ASSISTANT, Role.USER]
assert blocks[0][1] == "你好"
assert blocks[2][1] == "这里提到 USER: 但不在行首"
@pytest.mark.parametrize(
"text",
[
"这份模板一个角色标记都没有",
"开头就有内容\nUSER:\n你好\n",
"USER:\n\nASSISTANT:\n我在\n",
],
)
def test_split_by_role_rejects_malformed_templates(text):
"""没有标记、标记之前有内容、某一段是空的,三种都报错。"""
with pytest.raises(AppWorldScenarioError):
split_by_role(text, where="样例")
def test_variables_are_rendered_per_block():
"""`{{ 变量 }}` 与 `{{ 变量.字段 }}` 两种形态都认,花括号内两侧允许有空格。"""
text = "USER:\n{{greeting}} {{ user.first_name }} {{ user.last_name }}\n"
messages = render_to_messages(
text,
{"greeting": "你好", "user": {"first_name": "Sam", "last_name": "Carter"}},
where="样例",
)
assert len(messages) == 1
assert messages[0].content[0].text == "你好 Sam Carter"
def test_missing_variable_is_an_error_not_an_empty_string():
"""变量没提供就报错。渲染成空串的表现是提示词里凭空少一段,模型成绩下降而没人知道原因。"""
with pytest.raises(AppWorldScenarioError, match="instruction"):
render_to_messages("USER:\n任务:{{ instruction }}\n", {}, where="样例")
def test_missing_attribute_is_an_error():
"""点号取字段时字段不存在同样报错。"""
with pytest.raises(AppWorldScenarioError):
render_to_messages(
"USER:\n{{ user.email }}\n", {"user": {"first_name": "Sam"}}, where="样例"
)
@pytest.mark.parametrize(
"text",
[
"USER:\n你好 {{ 中文变量 }}\n",
"USER:\n你好 {{ a-b }}\n",
"USER:\n你好 {{ name }\n",
],
)
def test_leftover_double_braces_are_an_error(text):
"""形态不对的占位符要当场报错,不能原样留给模型看。
静默留下一个 `{{ instruction }}` 会让模型看见字面量占位符而那在轨迹里看起来只是模型
表现差
"""
with pytest.raises(AppWorldScenarioError):
render_to_messages(text, {"name": "Sam"}, where="样例")
def test_interpolated_content_cannot_move_message_boundaries():
"""插值内容里出现独占一行的 `USER:` 不会切出多余的消息。
先切后渲染才有这个性质反过来的话`run_prefix.txt` 里那个位于代码围栏内部的
`{{ app_descriptions }}` 只要带上一行 `USER:`前缀就会多切出几条消息而且不报错
"""
text = "USER:\n```\n{{ app_descriptions }}\n```\n\nASSISTANT:\n收到\n"
payload = "spotify: 音乐\nUSER:\nvenmo: 转账"
messages = render_to_messages(text, {"app_descriptions": payload}, where="样例")
assert len(messages) == 2
assert messages[0].role is Role.USER
assert messages[1].role is Role.ASSISTANT
assert "USER:" in messages[0].content[0].text
# ---------------------------------------------------------------------------
# 四、假会话与执行器
# ---------------------------------------------------------------------------
class _FakeSession:
"""一个假的 `AppWorldSession`:只实现场景层用得到的那几个成员,不碰容器。"""
def __init__(
self,
*,
output: str = "42\n",
done: bool = False,
raises: BaseException | None = None,
instruction: str = "How many playlists do I have?",
supervisor: dict[str, str] | None = None,
) -> None:
self.task_id = "82e2fac_1"
self.instruction = instruction
self.supervisor = (
supervisor
if supervisor is not None
else {
"first_name": "Sam",
"last_name": "Carter",
"email": "sam.carter@example.com",
"phone_number": "555-0100",
}
)
self.n_executions = 0
self._output = output
self._done = done
self._raises = raises
self.executed: list[str] = []
async def execute(self, code: str) -> str:
self.executed.append(code)
if self._raises is not None:
raise self._raises
self.n_executions += 1
return self._output
async def is_done(self) -> bool:
return self._done
async def test_normal_execution_is_an_executed_outcome():
"""代码跑完了就是「已执行」,观察是环境的原样输出。"""
session = _FakeSession(output="23\n")
executor = AppWorldExecutor(session=session)
outcome = await executor.execute(Action(text="print(23)", tool_call=None))
assert outcome.status is ActionStatus.EXECUTED
assert outcome.observation == "23\n"
assert outcome.observation_is_synthetic is False
assert outcome.env_reported_completion is False
assert outcome.observation_truncated_chars == 0
assert session.executed == ["print(23)"]
async def test_code_that_blows_up_is_still_an_executed_outcome():
"""模型写的代码报错是一条正常观察,不是环境故障。
压成 `ENV_ERROR` 就等于把模型写错了环境坏了混成同一件事而停止判定对这两者
的处置完全不同
"""
traceback = "Traceback (most recent call last):\nNameError: name 'x' is not defined"
executor = AppWorldExecutor(session=_FakeSession(output=traceback))
outcome = await executor.execute(Action(text="print(x)", tool_call=None))
assert outcome.status is ActionStatus.EXECUTED
assert outcome.observation == traceback
async def test_environment_reported_completion_is_forwarded():
"""环境说做完了,这个事实要原样填进结果——它是「任务完成」这个停止原因的唯一证据来源。"""
executor = AppWorldExecutor(session=_FakeSession(done=True))
outcome = await executor.execute(Action(text="apis.supervisor.complete_task()", tool_call=None))
assert outcome.env_reported_completion is True
assert outcome.status is ActionStatus.EXECUTED
async def test_environment_failure_becomes_env_error():
"""`AppWorldError` 才是环境故障,走 `ENV_ERROR`,观察是那条错误文本本身。"""
executor = AppWorldExecutor(
session=_FakeSession(raises=AppWorldError("/execute 返回 HTTP 500"))
)
outcome = await executor.execute(Action(text="print(1)", tool_call=None))
assert outcome.status is ActionStatus.ENV_ERROR
assert outcome.observation == "/execute 返回 HTTP 500"
assert outcome.observation_is_synthetic is False
assert outcome.env_reported_completion is False
assert outcome.observation_truncated_chars == 0
async def test_cancellation_passes_through():
"""取消原样穿出去(CLAUDE.md §1.6)。
吞掉它的后果不是取消失败这么直白是容器租约连接和临时目录持续泄漏而且一声
不吭
"""
executor = AppWorldExecutor(session=_FakeSession(raises=asyncio.CancelledError()))
with pytest.raises(asyncio.CancelledError):
await executor.execute(Action(text="print(1)", tool_call=None))
async def test_unexpected_errors_are_not_swallowed():
"""未预料的异常穿出去(CLAUDE.md §1.7)——压测就是要看见它。"""
executor = AppWorldExecutor(session=_FakeSession(raises=ZeroDivisionError("boom")))
with pytest.raises(ZeroDivisionError):
await executor.execute(Action(text="print(1)", tool_call=None))
async def test_execution_count_comes_from_the_environment():
"""执行次数转发环境自己的计数,不是本地计数器。
故障注入那一步要拿它跟库的自述对账用本地计数器的话对的就是我们和我们自己
"""
session = _FakeSession()
executor = AppWorldExecutor(session=session)
assert executor.env_executions == 0
await executor.execute(Action(text="print(1)", tool_call=None))
assert executor.env_executions == 1
assert executor.env_executions == session.n_executions
def test_executor_parameters_keep_the_port_out():
"""参数快照里有题号,没有 base_url——端口每次运行都不同,进快照会让续跑必然报参数漂移。"""
parameters = AppWorldExecutor(session=_FakeSession()).parameters()
assert parameters == {"kind": "appworld_session", "task_id": "82e2fac_1"}
assert "base_url" not in parameters
# ---------------------------------------------------------------------------
# 五、整体装配
# ---------------------------------------------------------------------------
def test_context_is_built_from_the_vendored_templates():
"""两份真模板切得出消息,题面与主管信息落在题级那一段。"""
session = _FakeSession(instruction="How many playlists do I have?")
context = build_context(session=session, app_descriptions="spotify: 音乐\nvenmo: 转账")
assert context.run_level[0].role is Role.USER
assert len(context.run_level) > 1
run_text = "\n".join(block.text for m in context.run_level for block in m.content)
assert "spotify: 音乐" in run_text
# 题面属于题级那一段:它逐题变化,混进 run 级会打断供应商的前缀缓存。
assert "How many playlists do I have?" not in run_text
assert len(context.goal_level) == 1
goal_text = context.goal_level[0].content[0].text
assert "How many playlists do I have?" in goal_text
assert "Sam Carter" in goal_text
assert "sam.carter@example.com" in goal_text
assert "555-0100" in goal_text
@pytest.mark.parametrize("blank_field", ["first_name", "last_name", "email", "phone_number"])
def test_blank_supervisor_fields_are_rejected(blank_field):
"""主管信息里任何一个字段为空都报错。
渲染器只拦得住键不存在空值会安静地渲染成一段空白提示词变成 `phone number is `
模型照着去查一个不存在的人不报错只让成绩变差
"""
supervisor = {
"first_name": "Sam",
"last_name": "Carter",
"email": "sam.carter@example.com",
"phone_number": "555-0100",
}
supervisor[blank_field] = ""
with pytest.raises(AppWorldScenarioError, match=blank_field):
build_context(session=_FakeSession(supervisor=supervisor), app_descriptions="spotify: 音乐")
def test_budget_matches_the_production_configuration():
"""预算四项照 dissect 的生产配置(max_actions 除外,AppWorld 每步至多一个动作)。"""
budget = build_budget()
assert budget.max_steps == 40
assert budget.max_actions == 40
assert budget.max_consecutive_parse_failures == 3
assert budget.max_prompt_chars == 400000
def test_synthetic_observations_are_all_non_empty():
"""三段合成观察都得有内容——它们会被模型看见。"""
synthetic = build_synthetic_observations()
assert synthetic.action_rejected
assert synthetic.env_failed
assert synthetic.model_call_failed
def test_run_request_is_assembled_with_the_pinned_values():
"""装出来的请求:空注册表、NEVER 重放、逐字照抄的观察格式。"""
session = _FakeSession()
request = build_run_request(
run_id="soak-0001",
session=session,
app_descriptions="spotify: 音乐",
model_binding={"scenario": "appworld"},
)
assert request.run_id == "soak-0001"
assert isinstance(request.action_executor, AppWorldExecutor)
assert request.tools.names() == ()
assert request.injections == {}
assert request.model_binding == {"scenario": "appworld"}
# AppWorld 的代码执行有真实副作用(转账、下单),状态未知时绝不重放。
assert request.model_replay_policy is ReplayPolicy.NEVER
assert request.observation_template == OBSERVATION_TEMPLATE
assert request.observation_template == "Output:\n```\n{observation}\n```\n\n"
assert request.cancel_grace_seconds == 5.0
def test_run_request_snapshot_has_no_container_port():
"""参数快照里不该出现容器端口,否则续跑必然报参数漂移。"""
request = build_run_request(
run_id="soak-0002",
session=_FakeSession(),
app_descriptions="spotify: 音乐",
model_binding={},
)
snapshot = request.parameter_snapshot()
assert not any("127.0.0.1" in value for value in snapshot.values())
assert "82e2fac_1" in snapshot.values()
File diff suppressed because it is too large Load Diff
+671
View File
@@ -0,0 +1,671 @@
"""GovDoc 公文审核场景适配器的测试。
分五块脱敏解析器工具阶段收窄装配
脱敏那块里最重要的一条是校验函数对未脱敏文本确实会抛异常一个永远返回通过的校验函数比
没有校验更糟它会让所有人以为这道闸在守着而它什么都没守
解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约写契约套件
本身是给下游继承基类在自己的子类里覆盖必需 fixture 用的压测这边不接那套装配只把五条
断言照着写一遍它是任何新适配器的准入标准压测的适配器也是适配器其中一条另外多守了一件
公共契约没要求的事`history_text` 不长于模型原文公共契约不断言长度解析器有权改写那段
文本改写既可能截短也可能补写那条断言守的是压测这一侧自己的选择解析器一律只截不补
用真实数据的那几条在数据目录不在时跳过而不是失败那份数据是另一个项目的工作副本不在本仓库
换一台机器就没有
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING
import pytest
from polyloop.ports import Action, FinalAnswer, InvalidDecision
from polyloop.tools import ToolRegistry
from polyloop.types import ModelReply, ReplayPolicy
from tools.soak.scenarios.govdoc import (
AUDIT_LOG_NAME,
DEFAULT_DATA_ROOT,
FINDING_NAME,
MAX_READ_LINES,
PHASE_TOOLS,
VERDICT_LEVELS,
AuditTask,
Checkpoint,
CorpusDocument,
GovDocParser,
GovDocScenarioError,
GovDocTools,
RedactionResidueError,
Redactor,
assert_no_residue,
build_audit_tasks,
build_context,
build_run_request,
make_run_id,
read_audit_lines,
)
if TYPE_CHECKING:
from pathlib import Path
#: 一段自造的「像真的」文本:机构全称、医院、财政局、固定电话、统一社会信用代码、邮箱、
#: 联系人各一处,同一家公司出现两次。
#:
#: **全部是编造的,一个字都不取自真实文书**:机构名前面带「虚构」两字,邮箱域名带 example,
#: 号码是连号。测试数据本身要是可识别的,那这份测试就成了它自己要挡的那种泄漏。
DIRTY_TEXT = """项目名称:某设备采购
采购人虚构市第三人民医院
采购代理机构虚构鸿远工程咨询有限公司
监督部门虚构市财政局
代理机构地址虚构市朝阳街道 88
联系人赵明
电话0768-12345678
邮箱zhaoming@example-invalid.cn
统一社会信用代码91445102MA4XK7YQ3B
中标供应商虚构鸿远工程咨询有限公司
预算金额8,736,100.00
项目编号440513-2023-03374
"""
def _reply(content: str) -> ModelReply:
return ModelReply(call_id="call-1", content=content, thinking="")
def _sample_task() -> AuditTask:
checkpoint = Checkpoint(
checkpoint_id="cp-1",
category="不合理条件限制或排斥供应商",
title="1.直接或变相对外地企业进入本地市场设置阻碍。",
description="采购文件设置供应商注册地等不合理的资格条件、评审因素。",
legal_basis=("政府采购法第5条", "第22条第二款"),
severity="major",
)
document = CorpusDocument.from_text(
logical_name="tender.md",
text="\n".join(f"{number} 行:投标人须在本地注册。" for number in range(1, 1001)),
)
return AuditTask(index=0, checkpoint=checkpoint, documents=(document,))
def _tools(tmp_path: Path) -> GovDocTools:
return GovDocTools(documents=_sample_task().documents, workspace=tmp_path)
# ---------------------------------------------------------------------------
# 一、脱敏
# ---------------------------------------------------------------------------
def test_validator_rejects_unredacted_text():
"""这条是这份测试里最要紧的一条:校验函数必须真的会拒。"""
with pytest.raises(RedactionResidueError):
assert_no_residue(DIRTY_TEXT, where="自造样本")
def test_validator_accepts_redacted_text():
result = Redactor().redact(DIRTY_TEXT)
assert_no_residue(result.text, where="自造样本")
def test_every_identifier_category_is_replaced():
result = Redactor().redact(DIRTY_TEXT)
for category in ("company", "hospital", "bureau", "phone", "email", "uscc", "person"):
assert result.counts.get(category, 0) >= 1, f"{category} 一处都没替换:{result.counts}"
for leaked in (
"虚构鸿远工程咨询有限公司",
"虚构市第三人民医院",
"虚构市财政局",
"0768-12345678",
"zhaoming@example-invalid.cn",
"91445102MA4XK7YQ3B",
"赵明",
):
assert leaked not in result.text
def test_same_original_gets_one_stable_alias():
result = Redactor().redact(DIRTY_TEXT)
# 那家公司在原文里出现两次(第 3 行的代理机构、倒数第 3 行的中标供应商),
# 替换后必须还是同一个假名、同样两次。
company_alias = result.text.splitlines()[2].split("", 1)[1]
assert company_alias.startswith("示例")
assert result.text.count(company_alias) == 2
assert result.distinct["company"] == 1
def test_alias_is_stable_across_documents():
redactor = Redactor()
first = redactor.redact(DIRTY_TEXT).text
second = redactor.redact("中标人是虚构鸿远工程咨询有限公司。").text
alias = first.splitlines()[2].split("", 1)[1]
assert alias in second
def test_amounts_and_project_numbers_survive():
result = Redactor().redact(DIRTY_TEXT)
assert "8,736,100.00" in result.text
assert "440513-2023-03374" in result.text
def test_generic_institution_words_survive():
text = "评标委员会依法组建,投标人可由总公司授权分公司投标。"
result = Redactor().redact(text)
assert result.text == text
def test_redaction_is_idempotent():
once = Redactor().redact(DIRTY_TEXT).text
twice = Redactor().redact(once).text
assert twice == once
# ---------------------------------------------------------------------------
# 二、决策解释器:五条公共契约 + 两种容错 + 五种失败
# ---------------------------------------------------------------------------
_ACTION_REPLY = (
'{"tool": "read_document", "arguments": {"path": "tender.md", "start_line": 1, "end_line": 20}}'
)
_INVALID_REPLY = "我先想想应该从哪里开始查。"
def test_contract_1_parse_is_synchronous():
parsed = GovDocParser().parse(_reply(_ACTION_REPLY))
assert not hasattr(parsed, "__await__")
@pytest.mark.parametrize("content", [_ACTION_REPLY, _INVALID_REPLY, "", "```json\n{}\n```"])
def test_scenario_parser_only_trims_history_text(content: str):
"""`history_text` 不会比模型原文长。这守的是这个场景自己的实现选择,不是公共契约。
公共契约不断言长度解析器有权改写回填历史的那段文本改写既可能截短也可能补写
`polyloop.testing.DecisionParserContract`这个场景的解析器直接回填模型原文不拼接
任何东西所以长度不会涨
"""
parsed = GovDocParser().parse(_reply(content))
assert isinstance(parsed.history_text, str)
assert len(parsed.history_text) <= len(content)
def test_contract_3_invalid_decision_explains_itself():
parsed = GovDocParser().parse(_reply(_INVALID_REPLY))
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation.strip()
def test_contract_4_action_carries_text():
parsed = GovDocParser().parse(_reply(_ACTION_REPLY))
assert isinstance(parsed.decision, Action)
assert isinstance(parsed.decision.text, str)
assert parsed.decision.text
assert parsed.decision.tool_call is not None
assert parsed.decision.tool_call.name == "read_document"
assert parsed.decision.tool_call.arguments["path"] == "tender.md"
@pytest.mark.parametrize(
"content",
[
"",
" ",
"{",
"```json\n",
"[1, 2, 3]",
'{"tool": null}',
'{"tool": "x", "arguments": 5}',
"```\n```",
"\x00\x01",
"{" * 500,
],
)
def test_contract_5_parse_never_raises(content: str):
parsed = GovDocParser().parse(_reply(content))
assert isinstance(parsed.decision, Action | FinalAnswer | InvalidDecision)
def test_tolerates_code_fences():
for fenced in (
f"```json\n{_ACTION_REPLY}\n```",
f"```\n{_ACTION_REPLY}\n```",
f"我打算先读一段:\n```json\n{_ACTION_REPLY}\n```\n读完再说。",
):
parsed = GovDocParser().parse(_reply(fenced))
assert isinstance(parsed.decision, Action), fenced
assert parsed.decision.tool_call is not None
assert parsed.decision.tool_call.name == "read_document"
def test_tolerates_flattened_arguments():
parsed = GovDocParser().parse(
_reply('{"tool": "read_document", "path": "tender.md", "start_line": 1, "end_line": 20}')
)
assert isinstance(parsed.decision, Action)
assert parsed.decision.tool_call is not None
assert parsed.decision.tool_call.arguments == {
"path": "tender.md",
"start_line": 1,
"end_line": 20,
}
def test_five_parse_failures_get_five_explanations():
parser = GovDocParser()
explanations = {}
for label, content in {
"没有 JSON": "我准备开始审核了。",
"语法错": '{"tool": "read_document", "arguments": {,}}',
"不是对象": "[1, 2, 3]",
"缺 tool": '{"arguments": {"path": "tender.md"}}',
"arguments 不是对象": '{"tool": "read_document", "arguments": "tender.md"}',
}.items():
decision = parser.parse(_reply(content)).decision
assert isinstance(decision, InvalidDecision), label
explanations[label] = decision.explanation
assert len(set(explanations.values())) == 5, explanations
assert "语法" in explanations["语法错"]
assert "tool" in explanations["缺 tool"]
assert "arguments" in explanations["arguments 不是对象"]
def test_final_answer_branch():
parsed = GovDocParser().parse(_reply('{"final_answer": "已写出 plan.md,列了 3 处候选证据。"}'))
assert isinstance(parsed.decision, FinalAnswer)
assert parsed.decision.text == "已写出 plan.md,列了 3 处候选证据。"
assert len(parsed.history_text) <= len(
'{"final_answer": "已写出 plan.md,列了 3 处候选证据。"}'
)
def test_final_answer_tolerates_code_fences():
inner = '{"final_answer": "已写出 evidence.md3 条证据。"}'
for fenced in (
f"```json\n{inner}\n```",
f"```\n{inner}\n```",
f"这一阶段做完了:\n```json\n{inner}\n```\n",
):
parsed = GovDocParser().parse(_reply(fenced))
assert isinstance(parsed.decision, FinalAnswer), fenced
assert parsed.decision.text == "已写出 evidence.md3 条证据。"
def test_tool_wins_when_both_keys_are_present():
"""一边调工具一边宣布做完时以工具为准:按 final_answer 收尾会把那次调用整个丢掉。"""
parsed = GovDocParser().parse(
_reply(
'{"tool": "write_note", "arguments": {"filename": "plan.md", "content": "x"},'
' "final_answer": "我写完了"}'
)
)
assert isinstance(parsed.decision, Action)
assert parsed.decision.tool_call is not None
assert parsed.decision.tool_call.name == "write_note"
def test_flattened_arguments_do_not_swallow_final_answer():
parsed = GovDocParser().parse(
_reply(
'{"tool": "write_note", "filename": "plan.md", "content": "x", "final_answer": ""}'
)
)
assert isinstance(parsed.decision, Action)
assert parsed.decision.tool_call is not None
assert parsed.decision.tool_call.arguments == {"filename": "plan.md", "content": "x"}
@pytest.mark.parametrize(
"content",
[
'{"answer": "我做完了"}',
'{"arguments": {"path": "tender.md"}}',
'{"final_answer": ""}',
'{"final_answer": " "}',
"{}",
],
)
def test_neither_key_is_still_invalid(content: str):
decision = GovDocParser().parse(_reply(content)).decision
assert isinstance(decision, InvalidDecision)
assert decision.explanation.strip()
def test_missing_key_explanation_names_both_shapes():
decision = GovDocParser().parse(_reply('{"note": "x"}')).decision
assert isinstance(decision, InvalidDecision)
assert "tool" in decision.explanation
assert "final_answer" in decision.explanation
def test_parser_reports_its_parameters():
assert GovDocParser().parameters() == {"kind": "govdoc_json_tool_call"}
# ---------------------------------------------------------------------------
# 三、四个工具
# ---------------------------------------------------------------------------
async def test_read_document_returns_numbered_lines(tmp_path: Path):
body = await _tools(tmp_path).read_document(
{"path": "tender.md", "start_line": 3, "end_line": 5}
)
assert body.splitlines() == [
"3: 第 3 行:投标人须在本地注册。",
"4: 第 4 行:投标人须在本地注册。",
"5: 第 5 行:投标人须在本地注册。",
]
async def test_read_document_truncates_and_says_so(tmp_path: Path):
body = await _tools(tmp_path).read_document(
{"path": "tender.md", "start_line": 1, "end_line": 1000}
)
lines = body.splitlines()
assert len(lines) == MAX_READ_LINES + 1
assert lines[MAX_READ_LINES - 1].startswith(f"{MAX_READ_LINES}: ")
assert f"第 1 到第 {MAX_READ_LINES}" in lines[-1]
assert f"还剩 {1000 - MAX_READ_LINES} 行未返回" in lines[-1]
assert "共 1000 行" in lines[-1]
@pytest.mark.parametrize(
"path", ["../etc/passwd", "/etc/passwd", "notes/plan.md", "nope.md", ".write_audit.log"]
)
async def test_read_document_rejects_unregistered_path(tmp_path: Path, path: str):
with pytest.raises(ValueError):
await _tools(tmp_path).read_document({"path": path, "start_line": 1, "end_line": 2})
async def test_read_document_reads_workspace_notes(tmp_path: Path):
tools = _tools(tmp_path)
await tools.write_note({"filename": "plan.md", "content": "第一条:查注册地要求"})
body = await tools.read_document({"path": "plan.md", "start_line": 1, "end_line": 10})
assert "第一条:查注册地要求" in body
async def test_grep_document_caps_matches(tmp_path: Path):
tools = _tools(tmp_path)
body = await tools.grep_document({"pattern": "本地注册", "path": "tender.md", "max_matches": 3})
lines = body.splitlines()
assert lines[0].startswith("1: ")
assert len(lines) == 4
assert "共 1000 处命中" in lines[-1]
async def test_grep_document_raises_on_bad_pattern(tmp_path: Path):
# 报错文本要指向正则本身。断言这一句是为了挡住「因为别的参数报错而恰好也抛了 ValueError」
# 那种假绿——这条曾经真的因为可选参数 max_matches 没传而在别处先炸掉。
with pytest.raises(ValueError, match="正则"):
await _tools(tmp_path).grep_document({"pattern": "([", "path": "tender.md"})
async def test_grep_document_max_matches_is_optional(tmp_path: Path):
body = await _tools(tmp_path).grep_document({"pattern": "本地注册", "path": "tender.md"})
assert len(body.splitlines()) == 51
@pytest.mark.parametrize("filename", ["../escape.md", "sub/plan.md", "", ".hidden"])
async def test_write_note_rejects_paths(tmp_path: Path, filename: str):
with pytest.raises(ValueError):
await _tools(tmp_path).write_note({"filename": filename, "content": "x"})
async def test_write_note_writes_and_audits(tmp_path: Path):
tools = _tools(tmp_path)
await tools.write_note({"filename": "plan.md", "content": "第一版"})
await tools.write_note({"filename": "plan.md", "content": "第二版"})
assert (tmp_path / "plan.md").read_text(encoding="utf-8") == "第二版"
audit = read_audit_lines(tmp_path)
assert len(audit) == 2
assert all(line.startswith("write_note\tplan.md\t") for line in audit)
# 两次内容不同,摘要也要不同——审计要能区分「同一个动作被执行了两次」与「两次写的是同一份」。
assert audit[0] != audit[1]
@pytest.mark.parametrize("verdict", ["合格", "compliant", "", "合规 "])
async def test_submit_finding_rejects_bad_verdict(tmp_path: Path, verdict: str):
with pytest.raises(ValueError):
await _tools(tmp_path).submit_finding(
{"verdict": verdict, "evidence": "第 3 行", "reasoning": ""}
)
async def test_submit_finding_writes_and_audits(tmp_path: Path):
tools = _tools(tmp_path)
for verdict in VERDICT_LEVELS:
await tools.submit_finding(
{"verdict": verdict, "evidence": "tender.md 第 3 行", "reasoning": "见证据"}
)
payload = json.loads((tmp_path / FINDING_NAME).read_text(encoding="utf-8"))
assert payload["verdict"] == VERDICT_LEVELS[-1]
audit = read_audit_lines(tmp_path)
assert len(audit) == len(VERDICT_LEVELS)
assert all(line.startswith(f"submit_finding\t{FINDING_NAME}\t") for line in audit)
async def test_audit_log_is_not_readable_by_the_model(tmp_path: Path):
"""审计是环境侧的账,不是给模型看的材料。"""
tools = _tools(tmp_path)
await tools.write_note({"filename": "plan.md", "content": "x"})
assert (tmp_path / AUDIT_LOG_NAME).is_file()
with pytest.raises(ValueError):
await tools.read_document({"path": AUDIT_LOG_NAME, "start_line": 1, "end_line": 2})
async def test_wrong_argument_types_raise_plain_errors(tmp_path: Path):
tools = _tools(tmp_path)
with pytest.raises(ValueError):
await tools.read_document({"path": "tender.md", "start_line": True, "end_line": 2})
with pytest.raises(ValueError):
await tools.read_document({"path": 3, "start_line": 1, "end_line": 2})
def test_replay_policies_and_completion_flag(tmp_path: Path):
registry = _tools(tmp_path).registry()
assert registry.names() == (
"read_document",
"grep_document",
"write_note",
"submit_finding",
)
policies = {name: registry.spec_for(name).replay_policy for name in registry.names()}
assert policies == {
"read_document": ReplayPolicy.SAFE,
"grep_document": ReplayPolicy.SAFE,
"write_note": ReplayPolicy.NEVER,
"submit_finding": ReplayPolicy.NEVER,
}
completes = [name for name in registry.names() if registry.spec_for(name).completes_run]
assert completes == ["submit_finding"]
def test_tool_parameters_are_closed_json_schema(tmp_path: Path):
for spec in _tools(tmp_path).registry().schema_for_model():
parameters = spec["parameters"]
assert parameters["type"] == "object"
assert parameters["additionalProperties"] is False
assert parameters["required"]
json.dumps(spec, ensure_ascii=False)
# ---------------------------------------------------------------------------
# 四、阶段收窄
# ---------------------------------------------------------------------------
def test_summarize_drops_grep_keeps_submit(tmp_path: Path):
full = _tools(tmp_path).registry()
narrowed = full.restrict_to(PHASE_TOOLS["summarize"])
assert "grep_document" not in narrowed.names()
assert "write_note" not in narrowed.names()
assert "submit_finding" in narrowed.names()
assert "read_document" in narrowed.names()
def test_restrict_to_leaves_the_source_registry_alone(tmp_path: Path):
full = _tools(tmp_path).registry()
before = full.names()
full.restrict_to(PHASE_TOOLS["summarize"])
assert full.names() == before
assert len(before) == 4
def test_plan_and_execute_have_no_submit_tool(tmp_path: Path):
full = _tools(tmp_path).registry()
for phase in ("plan", "execute"):
narrowed = full.restrict_to(PHASE_TOOLS[phase])
assert "submit_finding" not in narrowed.names()
assert narrowed.names() == ("read_document", "grep_document", "write_note")
# ---------------------------------------------------------------------------
# 五、装配
# ---------------------------------------------------------------------------
def test_run_id_shape():
assert make_run_id(task_index=7, phase="plan") == "govdoc-7-plan"
for phase in ("plan", "execute", "summarize"):
assert re.fullmatch(r"[A-Za-z0-9._\-]+", make_run_id(task_index=12, phase=phase))
with pytest.raises(GovDocScenarioError):
make_run_id(task_index=7, phase="finalize")
def test_all_three_phases_assemble(tmp_path: Path):
task = _sample_task()
for phase in ("plan", "execute", "summarize"):
request = build_run_request(
task=task,
phase=phase,
run_id=make_run_id(task_index=task.index, phase=phase),
workspace=tmp_path,
model_binding={"session_id": "soak-1"},
)
# 库会校验执行器与本次可见注册表同源(session/__init__.py:156-170)。
assert request.action_executor.registry == request.tools
assert request.tools.names() == PHASE_TOOLS[phase]
assert "{observation}" in request.observation_template
assert request.model_replay_policy is ReplayPolicy.NEVER
assert request.cancel_grace_seconds == 5.0
assert request.injections == {}
def test_budgets_are_the_measured_step_counts(tmp_path: Path):
"""20 / 25 / 16 是实测出来的,不是照抄 gov-auditor.yaml 的 50 / 50 / 16。
断言具体数字是因为这三个数直接决定一次全量压测的调用量改动必须是有意的
"""
task = _sample_task()
budgets = {
phase: build_run_request(
task=task,
phase=phase,
run_id=make_run_id(task_index=0, phase=phase),
workspace=tmp_path,
model_binding={},
).budget
for phase in ("plan", "execute", "summarize")
}
assert (budgets["plan"].max_steps, budgets["plan"].max_actions) == (20, 20)
assert (budgets["execute"].max_steps, budgets["execute"].max_actions) == (25, 25)
assert (budgets["summarize"].max_steps, budgets["summarize"].max_actions) == (16, 16)
for budget in budgets.values():
assert budget.max_consecutive_parse_failures == 3
assert budget.max_prompt_chars == 400_000
# 一次全量(20 个任务 × 3 个阶段)的步数上限。这一条守的是额度,不是行为。
assert sum(budget.max_steps for budget in budgets.values()) * 20 == 1220
def test_plan_and_execute_prompts_spell_out_the_final_answer_closing(tmp_path: Path):
"""plan 与 execute 的工具集里没有带 completes_run 的工具,收尾只能靠最终回答。
提示词不写清楚这一步这两个阶段就必然跑满预算实测过停止原因全是 step_budget
"""
task = _sample_task()
registry = _tools(tmp_path).registry()
for phase in ("plan", "execute"):
narrowed = registry.restrict_to(PHASE_TOOLS[phase])
system = build_context(task=task, phase=phase, tools=narrowed).run_level[0].content[0].text
assert '{"final_answer":' in system
assert not any(narrowed.spec_for(name).completes_run for name in narrowed.names())
# summarize 不变:它靠 submit_finding 这条提交型完成通路结束,不给最终回答这条路。
summarize = registry.restrict_to(PHASE_TOOLS["summarize"])
system = (
build_context(task=task, phase="summarize", tools=summarize).run_level[0].content[0].text
)
assert "final_answer" not in system
assert any(summarize.spec_for(name).completes_run for name in summarize.names())
def test_context_carries_tools_but_not_the_document_body(tmp_path: Path):
task = _sample_task()
registry = _tools(tmp_path).registry().restrict_to(PHASE_TOOLS["summarize"])
context = build_context(task=task, phase="summarize", tools=registry)
system = context.run_level[0].content[0].text
goal = context.goal_level[0].content[0].text
# 提示词正文里会点名说「本阶段没有检索工具」,所以工具清单要在 schema 那一段里查。
assert '"name": "submit_finding"' in system
assert '"name": "grep_document"' not in system
assert task.checkpoint.title in goal
assert "共 1000 行" in goal
# 公文正文必须靠工具读,不能整篇塞进上下文。
assert task.documents[0].lines[0] not in system + goal
async def test_phases_share_state_through_the_workspace(tmp_path: Path):
task = _sample_task()
plan_tools = GovDocTools(documents=task.documents, workspace=tmp_path)
await plan_tools.write_note({"filename": "evidence.md", "content": "证据一:第 3 行"})
summarize_tools = GovDocTools(documents=task.documents, workspace=tmp_path)
body = await summarize_tools.read_document(
{"path": "evidence.md", "start_line": 1, "end_line": 5}
)
assert "证据一" in body
def test_empty_registry_restricts_to_empty(tmp_path: Path):
assert ToolRegistry(()).restrict_to(()).names() == ()
# ---------------------------------------------------------------------------
# 六、真实数据(数据目录不在就跳过)
# ---------------------------------------------------------------------------
_HAS_REAL_DATA = DEFAULT_DATA_ROOT.is_dir()
_needs_real_data = pytest.mark.skipif(
not _HAS_REAL_DATA, reason=f"数据源不在这台机器上:{DEFAULT_DATA_ROOT}"
)
@_needs_real_data
def test_real_corpus_assembles_and_passes_the_gate():
tasks, reports = build_audit_tasks(checkpoint_count=3)
assert len(tasks) == 3
assert reports
document = tasks[0].documents[0]
assert document.logical_name == "tender.md"
assert document.line_count > 2000
assert document.char_count > 100_000
# 装配路径上已经调过校验函数,这里再自己确认一遍:这道闸是压测能不能启动的判据。
assert_no_residue("\n".join(document.lines), where="真实语料")
assert_no_residue(tasks[0].checkpoint.render(), where="真实审核点")
replaced = sum(count for report in reports for count in report.counts.values())
assert replaced > 0
@_needs_real_data
def test_real_task_assembles_three_requests(tmp_path: Path):
tasks, _ = build_audit_tasks(checkpoint_count=1)
for phase in ("plan", "execute", "summarize"):
request = build_run_request(
task=tasks[0],
phase=phase,
run_id=make_run_id(task_index=0, phase=phase),
workspace=tmp_path,
model_binding={},
)
assert request.action_executor.registry == request.tools
+615
View File
@@ -0,0 +1,615 @@
"""压测入口驱动的测试。**不打真实模型、不起容器**,模型那一侧全是写死脚本的替身。
分五块参数校验预算护栏与并发上限产物与记分板的对接错误隔离与取消GovDoc 的阶段串行
最有价值的一条是产物喂给记分板判成全绿驱动写四个文件记分板读四个文件两边的字段
约定只写在记分板的模块 docstring 没有任何机器约束把它们钉在一起那条测试就是那个约束
少写一个字段类型写错一个它当场红
GovDoc 那几块用手工搭的 `AuditTask`不读磁盘上的真实语料那份数据是另一个项目的工作副本
换一台机器就没有而这里要验的是编排阶段串行任务并发跟语料内容无关
"""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING
import pytest
from polyloop.types import ModelReply
from tools.soak import scoreboard
from tools.soak.run_soak import (
BudgetGuard,
RunOutcome,
Unit,
dispatch,
execute_run,
interleave,
main,
run_govdoc_task,
select_appworld_tasks,
)
from tools.soak.scenarios import govdoc as govdoc_scenario
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from pathlib import Path
from polyloop.ports import ModelCall
# ---------------------------------------------------------------------------
# 替身
# ---------------------------------------------------------------------------
class _ScriptedModel:
"""按 run_id 给回复的模型替身。每次调用都让出一次事件循环,好让并发真的发生。"""
def __init__(self, *, render: Callable[[ModelCall], str]) -> None:
self._render = render
#: 调用到达的顺序,按 run_id 记。阶段串行与任务并发都从它上面判。
self.seen: list[str] = []
async def call(self, call: ModelCall) -> ModelReply:
await asyncio.sleep(0.001)
self.seen.append(call.run_id)
return ModelReply(
call_id=f"call-{len(self.seen)}",
content=self._render(call),
thinking="",
)
def parameters(self): # noqa: ANN201 - 替身,签名由 Protocol 定
return {"kind": "scripted"}
def _guard(*, render: Callable[[ModelCall], str], limit: int = 1000) -> BudgetGuard:
return BudgetGuard(inner=_ScriptedModel(render=render), limit=limit)
_SUBMIT = json.dumps(
{
"tool": "submit_finding",
"arguments": {
"verdict": "存疑",
"evidence": "tender.md 第 1 行:示例甲公司",
"reasoning": "测试替身给的固定结论",
},
},
ensure_ascii=False,
)
#: 解析不出来的输出。GovDoc 的预算里连续解析失败上限是 3,所以三步就停——plan 与 execute
#: 两个阶段没有结束通路(提交工具只在 summarize 那一阶段),不这样它们会一路走到 50 步。
_GARBAGE = "我先想一想这道题。"
#: summarize 阶段的第一步:先读一段再提交。
#:
#: **不是可有可无的一步。** 记分板的「提示词字符数单调不减」要两步才凑得出相邻一对,
#: 一步就提交的运行在它那里是「无从判起」而不是「通过」。这条测试断言的是整批全绿,
#: 所以替身必须走够两步——否则它测到的是记分板在数据不足时的行为,不是产物合不合约定。
_READ = json.dumps(
{"tool": "read_document", "arguments": {"path": "tender.md", "start_line": 1, "end_line": 2}},
ensure_ascii=False,
)
def _audit_task(index: int) -> govdoc_scenario.AuditTask:
checkpoint = govdoc_scenario.Checkpoint(
checkpoint_id=f"cp-{index}",
category="资格条件",
title="供应商资格要求",
description="不得以注册地设置差别待遇。",
legal_basis=("政府采购法第五条",),
severity="",
)
document = govdoc_scenario.CorpusDocument.from_text(
logical_name="tender.md",
text="第一行:示例甲公司参与投标。\n第二行:投标截止时间为示例日期。",
)
return govdoc_scenario.AuditTask(index=index, checkpoint=checkpoint, documents=(document,))
def _fake_unit(
label: str,
*,
body: Callable[[], object],
) -> Unit:
async def start() -> Sequence[RunOutcome]:
result = body()
if asyncio.iscoroutine(result):
await result
return [
RunOutcome(
run_id=label,
scenario="fake",
task_id=label,
phase=None,
stop_reason="task_completed",
steps=1,
model_calls=1,
wall_ms=0,
)
]
return Unit(label=label, scenario="fake", run=start)
# ---------------------------------------------------------------------------
# 一、参数校验
# ---------------------------------------------------------------------------
def test_missing_budget_calls_refuses(tmp_path: Path) -> None:
with pytest.raises(SystemExit) as caught:
main(["--scenario", "govdoc", "--concurrency", "2", "--runs-dir", str(tmp_path)])
assert caught.value.code != 0
def test_missing_concurrency_refuses(tmp_path: Path) -> None:
with pytest.raises(SystemExit) as caught:
main(["--scenario", "govdoc", "--budget-calls", "10", "--runs-dir", str(tmp_path)])
assert caught.value.code != 0
def test_non_positive_budget_refuses(tmp_path: Path) -> None:
with pytest.raises(SystemExit):
main(
[
"--scenario",
"govdoc",
"--budget-calls",
"0",
"--concurrency",
"1",
"--runs-dir",
str(tmp_path),
]
)
def test_appworld_without_data_root_refuses(tmp_path: Path) -> None:
with pytest.raises(SystemExit):
main(
[
"--scenario",
"appworld",
"--budget-calls",
"5",
"--concurrency",
"1",
"--runs-dir",
str(tmp_path),
"--dry-run",
]
)
# ---------------------------------------------------------------------------
# 二、预算护栏与并发上限
# ---------------------------------------------------------------------------
async def test_budget_counts_are_per_run_and_total() -> None:
guard = _guard(render=lambda call: "ok")
calls = [_call("run-a"), _call("run-a"), _call("run-b")]
for item in calls:
await guard.call(item)
assert guard.total == 3
assert guard.calls_for("run-a") == 2
assert guard.calls_for("run-b") == 1
assert guard.exhausted is False
async def test_budget_stops_dispatch_and_lets_running_tasks_finish() -> None:
guard = _guard(render=lambda call: "ok", limit=2)
async def slow() -> None:
await guard.call(_call("slow"))
await asyncio.sleep(0.05)
async def quick() -> None:
await guard.call(_call("quick"))
units = [
_fake_unit("unit-1", body=slow),
_fake_unit("unit-2", body=quick),
_fake_unit("unit-3", body=quick),
_fake_unit("unit-4", body=quick),
]
report = await dispatch(units, concurrency=2, guard=guard)
assert report.dispatched == 2
# 「第 N 个任务」从 1 数:第 3 个是第一个没派出去的。
assert report.stopped_at == 3
# 慢的那个是在预算耗尽时正在跑的,它必须跑完并留下结果,不许被砍断。
assert {item.run_id for item in report.outcomes} == {"unit-1", "unit-2"}
assert guard.total == 2
async def test_concurrency_never_exceeds_the_cap() -> None:
live = 0
peak = 0
async def body() -> None:
nonlocal live, peak
live += 1
peak = max(peak, live)
await asyncio.sleep(0.005)
live -= 1
units = [_fake_unit(f"unit-{index}", body=body) for index in range(12)]
report = await dispatch(units, concurrency=3)
assert peak == 3
assert report.dispatched == 12
assert len(report.outcomes) == 12
async def test_zero_concurrency_is_rejected() -> None:
with pytest.raises(ValueError, match="并发上限"):
await dispatch([], concurrency=0)
def test_interleave_alternates_between_scenarios() -> None:
left = [_fake_unit(f"L{index}", body=lambda: None) for index in range(3)]
right = [_fake_unit(f"R{index}", body=lambda: None) for index in range(2)]
assert [unit.label for unit in interleave([left, right])] == ["L0", "R0", "L1", "R1", "L2"]
def test_select_appworld_tasks_dedupes_across_splits() -> None:
class _Pool:
def list_task_ids(self, split: str) -> list[str]:
return {"train": ["a", "b"], "dev": ["b", "c", "d"]}[split]
assert select_appworld_tasks(_Pool(), splits=["train", "dev"], limit=None) == [
"a",
"b",
"c",
"d",
]
assert select_appworld_tasks(_Pool(), splits=["train", "dev"], limit=3) == ["a", "b", "c"]
# ---------------------------------------------------------------------------
# 三、错误隔离与取消
# ---------------------------------------------------------------------------
async def test_one_failing_unit_does_not_stop_the_batch() -> None:
def boom() -> None:
raise RuntimeError("这一道题炸了")
units = [
_fake_unit("ok-1", body=lambda: None),
_fake_unit("boom", body=boom),
_fake_unit("ok-2", body=lambda: None),
]
report = await dispatch(units, concurrency=1)
assert {item.run_id for item in report.outcomes} == {"ok-1", "ok-2"}
assert len(report.failures) == 1
label, message = report.failures[0]
assert label == "boom"
assert "RuntimeError" in message and "这一道题炸了" in message
async def test_cancellation_passes_through_and_cleans_up() -> None:
running = asyncio.Event()
cleaned = False
async def body() -> None:
nonlocal cleaned
running.set()
try:
await asyncio.sleep(10)
finally:
cleaned = True
units = [_fake_unit("slow", body=body)]
task = asyncio.create_task(dispatch(units, concurrency=1))
await running.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cleaned is True
# ---------------------------------------------------------------------------
# 四、产物:字段齐全,且记分板判得动
# ---------------------------------------------------------------------------
async def _run_summarize(tmp_path: Path, *, index: int = 0) -> None:
"""跑一次 summarize 阶段:模型第一步就提交结论,运行以 task_completed 结束。"""
guard = _guard(render=lambda call: _SUBMIT)
await run_govdoc_task(
task=_audit_task(index),
runs_dir=tmp_path / "runs",
workspace_root=tmp_path / "workspaces",
guard=guard,
phases=("summarize",),
)
async def test_sidecar_fields_are_complete_and_typed(tmp_path: Path) -> None:
await _run_summarize(tmp_path)
runs = tmp_path / "runs"
run_id = govdoc_scenario.make_run_id(task_index=0, phase="summarize")
assert (runs / f"{run_id}.jsonl").is_file()
assert (runs / f"{run_id}.result.json").is_file()
assert (runs / f"{run_id}.events.jsonl").is_file()
meta = json.loads((runs / f"{run_id}.meta.json").read_text(encoding="utf-8"))
assert set(meta) == {
"scenario",
"task_id",
"phase",
"wall_ms",
"model_calls",
"sink_failures",
"env_executions",
"fault",
"success",
"resumed_from_step",
}
assert meta["scenario"] == "govdoc"
assert meta["task_id"] == "cp-0"
assert meta["phase"] == "summarize"
assert isinstance(meta["wall_ms"], int)
assert meta["model_calls"] == 1
assert meta["sink_failures"] == 0
# submit_finding 往审计账里追加一行,那是环境自己记的账。
assert meta["env_executions"] == 1
assert meta["fault"] is None
assert meta["success"] is None
assert meta["resumed_from_step"] == 0
events = [
json.loads(line)
for line in (runs / f"{run_id}.events.jsonl").read_text(encoding="utf-8").splitlines()
]
assert events == [{"kind": "step_finished", "run_id": run_id, "step_idx": 0}]
result = json.loads((runs / f"{run_id}.result.json").read_text(encoding="utf-8"))
assert result["run_id"] == run_id
assert result["stop_reason"] == "task_completed"
assert result["event_delivery_failures"] == 0
async def test_artifacts_pass_the_scoreboard(tmp_path: Path) -> None:
"""把驱动写出来的产物直接喂给记分板,要它报全绿。
两边的字段约定没有任何机器约束把它们钉在一起这条测试就是那个约束
"""
def render(call: object) -> str:
if "summarize" not in call.run_id: # type: ignore[attr-defined]
return _GARBAGE
return _READ if call.call_index == 0 else _SUBMIT # type: ignore[attr-defined]
guard = _guard(render=render)
for index in range(2):
await run_govdoc_task(
task=_audit_task(index),
runs_dir=tmp_path / "runs",
workspace_root=tmp_path / "workspaces",
guard=guard,
)
board = scoreboard.evaluate(
tmp_path / "runs",
completing_tools=frozenset({"submit_finding"}),
)
trouble = [
f"{item.name}: {[evidence.describe() for evidence in (*item.breaches, *item.undetermined)]}"
for item in (*board.breached, *board.undetermined)
]
assert board.verdict is scoreboard.Verdict.PASSED, trouble
assert len(board.summaries) == 6
assert {item.scenario for item in board.summaries} == {"govdoc"}
async def test_sink_failures_match_what_the_library_counted(tmp_path: Path) -> None:
"""事件文件写不出去时,出口自己数的失败次数与结果里那份必须一致。
这条不变量是记分板的一条判定而两边的计数由不同的代码写出口在自己的 `except` 里加一
库在接住异常之后加一只要出口漏加或多加记分板当场报击穿
"""
runs = tmp_path / "runs"
runs.mkdir()
run_id = govdoc_scenario.make_run_id(task_index=0, phase="summarize")
# 在事件文件该在的位置放一个目录,追加写就必然失败。
(runs / f"{run_id}.events.jsonl").mkdir()
guard = _guard(render=lambda call: _SUBMIT)
await run_govdoc_task(
task=_audit_task(0),
runs_dir=runs,
workspace_root=tmp_path / "workspaces",
guard=guard,
phases=("summarize",),
)
meta = json.loads((runs / f"{run_id}.meta.json").read_text(encoding="utf-8"))
result = json.loads((runs / f"{run_id}.result.json").read_text(encoding="utf-8"))
assert meta["sink_failures"] == 1
assert result["event_delivery_failures"] == meta["sink_failures"]
async def test_a_failing_run_still_leaves_meta(tmp_path: Path) -> None:
"""`run` 抛异常也要留下 `.meta.json`——缺文件在记分板那边只是「无法判定」。
这里用同一个运行标识跑第二次来触发失败库对已经有日志的标识直接报 `RunIdentityError`
而那是压测里最可能真的撞上的一种失败任务集去重漏了一处
"""
runs = tmp_path / "runs"
await _run_summarize(tmp_path)
run_id = govdoc_scenario.make_run_id(task_index=0, phase="summarize")
(runs / f"{run_id}.result.json").unlink()
guard = _guard(render=lambda call: _SUBMIT)
task = _audit_task(0)
request = govdoc_scenario.build_run_request(
task=task,
phase="summarize",
run_id=run_id,
workspace=tmp_path / "ws",
model_binding={},
)
outcome = await execute_run(
runs_dir=runs,
request=request,
model_client=guard,
decision_parser=govdoc_scenario.GovDocParser(),
synthetic_observations=govdoc_scenario.SYNTHETIC_OBSERVATIONS,
scenario="govdoc",
task_id=task.checkpoint.checkpoint_id,
phase="summarize",
count_model_calls=lambda: guard.calls_for(run_id),
)
assert outcome.error is not None
assert "RunIdentityError" in outcome.error
assert outcome.stop_reason is None
meta = json.loads((runs / f"{run_id}.meta.json").read_text(encoding="utf-8"))
assert meta["model_calls"] == 0
assert meta["success"] is None
# 这一次没有结果,所以没有 `.result.json` 可写——记分板会把它记成一条要人看见的观察。
assert not (runs / f"{run_id}.result.json").exists()
async def test_a_model_error_is_a_stop_reason_not_a_crash(tmp_path: Path) -> None:
"""模型调用失败不会把异常抛出循环,它是一次以 `llm_error` 结束的正常运行。
压测的报告里这两件事必须分得开`error` 是驱动这边出的事`llm_error` 是库判定的停止原因
"""
class _Exploding:
async def call(self, call: ModelCall) -> ModelReply:
raise RuntimeError("网关炸了")
def parameters(self): # noqa: ANN202 - 替身
return {"kind": "exploding"}
guard = BudgetGuard(inner=_Exploding(), limit=5)
task = _audit_task(0)
request = govdoc_scenario.build_run_request(
task=task,
phase="summarize",
run_id="govdoc-0-summarize",
workspace=tmp_path / "ws",
model_binding={},
)
outcome = await execute_run(
runs_dir=tmp_path / "runs",
request=request,
model_client=guard,
decision_parser=govdoc_scenario.GovDocParser(),
synthetic_observations=govdoc_scenario.SYNTHETIC_OBSERVATIONS,
scenario="govdoc",
task_id=task.checkpoint.checkpoint_id,
phase="summarize",
count_model_calls=lambda: guard.calls_for("govdoc-0-summarize"),
)
assert outcome.error is None
assert outcome.stop_reason == "llm_error"
meta = json.loads(
(tmp_path / "runs" / "govdoc-0-summarize.meta.json").read_text(encoding="utf-8")
)
assert meta["model_calls"] == 1
assert (tmp_path / "runs" / "govdoc-0-summarize.result.json").is_file()
# ---------------------------------------------------------------------------
# 五、GovDoc:阶段串行、任务并发
# ---------------------------------------------------------------------------
async def test_govdoc_phases_run_in_order_within_a_task(tmp_path: Path) -> None:
model = _ScriptedModel(render=lambda call: _GARBAGE)
guard = BudgetGuard(inner=model, limit=1000)
await run_govdoc_task(
task=_audit_task(0),
runs_dir=tmp_path / "runs",
workspace_root=tmp_path / "workspaces",
guard=guard,
)
phases = [run_id.rsplit("-", 1)[1] for run_id in model.seen]
# 每个阶段的调用连成一段,段与段之间不交错。
assert phases == sorted(phases, key=["plan", "execute", "summarize"].index)
assert set(phases) == {"plan", "execute", "summarize"}
async def test_govdoc_tasks_overlap_while_phases_do_not(tmp_path: Path) -> None:
model = _ScriptedModel(render=lambda call: _GARBAGE)
guard = BudgetGuard(inner=model, limit=1000)
units = [
Unit(
label=f"govdoc/{index}",
scenario="govdoc",
run=_govdoc_runner(
index=index,
runs_dir=tmp_path / "runs",
workspace_root=tmp_path / "workspaces",
guard=guard,
),
)
for index in range(2)
]
report = await dispatch(units, concurrency=2, guard=guard)
assert len(report.outcomes) == 6
assert report.failures == ()
# 阶段串行:同一个任务里,后一阶段的第一次调用晚于前一阶段的最后一次调用。
for index in range(2):
own = [
position
for position, run_id in enumerate(model.seen)
if run_id.startswith(f"govdoc-{index}-")
]
for earlier, later in (("plan", "execute"), ("execute", "summarize")):
last_earlier = max(
position for position in own if model.seen[position].endswith(earlier)
)
first_later = min(position for position in own if model.seen[position].endswith(later))
assert last_earlier < first_later
# 任务并发:两个任务的调用在时间上交错。
owners = [run_id.split("-")[1] for run_id in model.seen]
assert any(left != right for left, right in zip(owners, owners[1:], strict=False))
def _govdoc_runner(*, index: int, runs_dir: Path, workspace_root: Path, guard: BudgetGuard): # noqa: ANN202
async def start() -> Sequence[RunOutcome]:
return await run_govdoc_task(
task=_audit_task(index),
runs_dir=runs_dir,
workspace_root=workspace_root,
guard=guard,
)
return start
# ---------------------------------------------------------------------------
# 小工具
# ---------------------------------------------------------------------------
def _call(run_id: str) -> ModelCall:
from polyloop.ports import ModelCall as _ModelCall
return _ModelCall(
messages=(),
call_index=0,
run_id=run_id,
result_id=f"{run_id}-0",
binding={},
)
File diff suppressed because it is too large Load Diff