Compare commits

10 Commits

Author SHA1 Message Date
iomgaa 8f5caa0924 docs: CLAUDE.md 补上发布的判据与代理这条环境事实,清掉三处过期记载
§1.10 那条指向的发布指南现在有了,同时补两件调研 PolyGateway 时核实到的:它当年那次
补救只写了文档没有回补上传,所以 1.0.6 与 1.1.0 到今天仍然不在 registry 上,而 dissect
的依赖恰好钉在那个空区间里装不上——记下教训不等于修好问题;以及发布完成的判据是外部可见
结果不是本地步骤跑通,1.1.2 三步全绿而包页面是空白的。

§4 新增一条:这台机器的代理到不了外面,访问实验室 Gitea 的命令都要绕开,否则失败看起来
像服务器挂了。过期记载三处:conda 环境早就建了、契约测试早就写了、十个模块早就不是空骨架。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:12:46 -04:00
iomgaa 303e4ebc1c chore(release): 打包元数据补齐,版本定到 1.0.1
发布前置:readme 与 project.urls 从第一版就写全——缺 readme 的话 registry 包页面正文一片
空白,而 twine 只警告不阻塞上传,三步全绿产物却是坏的(PolyGateway 1.1.2 的教训)。
build 与 twine 钉进 dev extra 而不是靠人手装,PolyGateway 那边它们不在任何 extra 里,
于是发布指南得多写一条「记得先装」,那种步骤迟早有人漏。

README 里那条「项目还没有可用的功能、十个模块是空骨架」的横幅早就过期了,而打包会把当时的
README 固化进 sdist、发布后再改无效,所以在构建之前换成安装说明与现状。顺带修两处与事实
不符的:GovDoc-SaaS 的实现已经在 8 月 3 日整体清空,⑥ 对它的验收标准改成设计级验收。

新建 CHANGELOG.md,写清楚首个版本为什么是 1.0.1 而不是 0.x——「公共类型的字段只增不删不
改名」那条承诺从第一个下游装上它那天起就生效,而 0.x 意味着随时可以破坏兼容。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:11:04 -04:00
iomgaa d4aa5e117e test(e2e): 补上打真实网关的那一层,四层测试到齐
两个用例:一次运行走通「模型→解释→工具→观察回填→再问模型→收尾」,以及取消能穿过真实的
HTTP 请求并留下已取消的结束记录。测试自带解释器、工具注册表与事件出口——库故意不带它们,
带了就等于替某一家定了动作语言。

断言只绑结构不变量,一条都不绑模型输出的文字:停止原因落在两个正常终态里、至少两步且至少
一步真的执行过动作、每步 call_id 非空(网关那边生成的,替身给不出来)、日志读回来与返回值
逐字段相等、事件条数等于步数。绑内容的测试会随机红,而随机红的测试很快没人看。

两道跳过闸:网关装没装,以及 POLYLOOP_E2E 是不是 1。分开是因为填好密钥不等于同意花钱。
实测跑一次三次真实模型调用、约 12 秒;开关关着时整层跳过;make ci 283 passed / 16 skipped。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:49:11 -04:00
iomgaa 94b9923d8f chore: 加 .env.example,只列 e2e 那一层必需的键
.gitignore 里早就写好了 !.env.example 的例外,文件一直没建。只列 e2e 跑起来必需的最小集,
每个键什么意思、还有哪些可选键,权威在 PolyGateway 自己的模板里,不在本仓库复述第二遍。

两处不是照抄网关默认值的:缓存必须 none——开着的话第二次跑同样的提示词直接命中缓存返回,
而 e2e 唯一要证明的就是「真的打出去过一次」;e2e 开关和密钥分成两件事——填好密钥不等于
同意花钱,没这道闸的话有人随手跑一次全套测试就会产生真实账单。

模板填完之后实测装配得起来:GatewaySettings.from_env() 与 GatewayClient.from_env() 都过,
适配器的 parameters() 读得出源名、供应商与模型名。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:02:20 -04:00
iomgaa 95898bccf3 docs: 事件集落地后回写 GovDoc 缺口清单与阶段清单
「审计出口与事件流的关系」那条缺口转到已回答:审计由意图日志承担,出口要包在存储接缝上
而不是接在事件出口上;三类事件里 phase_recovery 那一类在界外。新登记一条缺口:工具单独的
耗时拿不到,本库只有整步墙钟,迁过去口径会变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:57:09 -04:00
iomgaa 8c642e7881 test(contract): 按 Codex 审查改掉五条说明型测试的名字
它们函数体是空的,名字却承诺了一个行为,读起来像「这条已经验过了」——而契约套件是新适配器
的准入标准,下游跑一遍看见绿的会以为自己那一条被验过。名字改成说清楚断言在哪一层,
函数体那段解释保持不动。

Codex 另报「测试 docstring 里出现 GovDoc 是业务词汇」,不采纳:§1.1 那道扫描挡的是领域词
(公文、超声、招标),说明一条测试被哪个消费者的需求逼出来不构成业务假设,仓库里这类引用
本来就有十几处。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:56:02 -04:00
iomgaa e71dca7c35 feat(session): 落成事件出口,五个接缝全部有调用点
Event 从零字段变成 kind + run_id + model_binding + step,新增 EventKind(只有一种取值,
但第一天就带 kind,逼每个出口分发)。事件在「一步走完」原子落地之后发,只有这次进程里
真的执行过的步才发;投递失败接住、计数进 RunResult、继续跑,CancelledError 原样穿过。

契约套件那两条 xfail 关掉:一条要断言的是库发了几次、接缝自己看不到;另一条的前提是错的
——审计纪律由意图日志承担不由事件流承担,改成在 unit 层验日志里原文与改写后的文本各有
位置。_project_observation 那段说「将来靠事件流送出去」的注释一并改对。

283 passed / 15 skipped / 2 xfailed,剩下两条 xfail 是原子写与前缀持久性,没有机器兜底。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:51:19 -04:00
iomgaa 2385da3a97 docs(design): 0013 转已接受,补进四条来自 pi 的实据
去核 reference/pi 之后补的:它同一组三份投影对不上的地方有四处不是一处(决策四);
它先发事件再落盘,反着做没问题是因为订阅者与进程同生共死,本库的出口活得比进程久
(决策五);它的回调失败处置独立收敛到同一条分界——观察型抛异常照跑、工具执行前那个
fail-closed(决策八);它一轮发十来条是被终端界面逼出来的,而它同样把实时事件流与
持久记录分成两套(决策三)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:43:20 -04:00
iomgaa 8aa430c7df test(contract): 0007 确认后关掉那三条 xfail
解释器不许抛异常是接缝自己的行为,改成正面断言(等实现接进来才跑)。另两条的答案
落在库这一侧不在接缝上:动作状态的触发条件是执行器自己的判断,套件面对任意实现逼
不出后两档,硬探会把 dissect 那种状态恒为 EXECUTED 的合法实现判成不合格;观察由谁
合成同理。两条留成不断言的说明,指向 tests/unit/test_session.py 里真正验它们的地方。

273 passed / 15 skipped / 4 xfailed。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:53:27 -04:00
iomgaa 1cd4344b16 docs(design): 落成 0013,定事件集、发事件的那一处与回调清单
事件集只有「一步走完」一种,带整条步记录而不是摘要;审计纪律由意图日志承担,
事件流保持可丢。写文档时去核 0007 决策二那笔「执行器原文丢了」的代价,发现它
并没有真的发生——「一步走完」这条记录持久化的是动作执行接缝的原样返回值,库只
替换了步记录里那一列,所以那段文本一直在日志里。

状态待确认:Event 从零字段变成有字段,按 CLAUDE.md §2 要过人类门。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:50:33 -04:00
16 changed files with 1293 additions and 92 deletions
+64
View File
@@ -0,0 +1,64 @@
# PolyLoop 的环境配置模板。复制成 .env 再填,.env 不入库(.gitignore 已经挡了)。
#
# 只有 tests/e2e/ 那一层需要它——那是唯一会打真实模型网关的测试层,其余三层
# unit / integration / contract)不读任何环境变量,`make ci` 也不读。
#
# 本库自己一个环境变量都不读。模型调用一律走 PolyGatewayCLAUDE.md §1.5),
# 下面除了第一节那个开关之外,全部是 PolyGateway 的装配入口消费的键。
#
# **这里只列 e2e 跑起来必需的那几个。** 每个键什么意思、还有哪些可选键、多源怎么配,
# 权威是 PolyGateway 自己的 .env.example——那是它的参数,不在本仓库复述第二遍
# (复述规则见 CLAUDE.md §0)。
#
# 两条读取规则,和 PolyGateway 一致:读的是**当前工作目录**下的 .env,所以测试要从仓库
# 根跑;shell 里导出的同名环境变量**优先于** .env 里的值,临时换一个模型不必改文件。
# ── 一、e2e 的开关 ────────────────────────────────────────────────
# 不是 1 的话,tests/e2e/ 整层跳过。
#
# **填好密钥不等于同意花钱**,所以它和密钥分成两件事。没有这道开关的话,任何人
# 配好 .env 之后随手跑一次全套测试,就会打出去一串真实调用并产生真实账单,而他
# 本来只是想看看测试过不过。
POLYLOOP_E2E=0
# ── 二、模型源,至少一个 ──────────────────────────────────────────
# 键的形状是 {SCOPE}__{PROVIDER}__{序号}__{字段}。**本库的 e2e 固定用 LLM 这个
# scope**,不做成可配——多一个旋钮就多一处「这次跑的到底是哪套配置」要对照两个
# 地方才答得出来。
#
# PROVIDER 那一段必须是 PolyGateway 注册表里的键(qwen / deepseek / openai …),
# 而且它是键名的一部分:换供应商要把下面四行的 QWEN 一起改掉。
#
# 挑一个便宜的小模型。e2e 验的是「这条链路通不通」——提示词拼对了没有、工具
# schema 模型认不认、回复能不能被解释、轨迹落盘对不对——不是模型答得好不好。
LLM__QWEN__1__BASE_URL=
LLM__QWEN__1__API_KEY=
LLM__QWEN__1__MODEL=
LLM__QWEN__1__TIMEOUT_S=120
# ── 三、治理参数 ──────────────────────────────────────────────────
# 这几个 PolyGateway 一律要求显式声明、不给默认值,缺了直接报错。它们是「失败了
# 怎么办」,不影响模型看见什么,所以照最常见的值填就行。
LLM__RETRY__MAX_ATTEMPTS=3
LLM__RETRY__BACKOFF_BASE_S=2.0
LLM__RETRY__BACKOFF_MAX_S=30.0
LLM__BREAKER__FAIL_THRESHOLD=5
LLM__BREAKER__COOLDOWN_S=60
# ── 四、装配选择 ──────────────────────────────────────────────────
# **缓存必须是 none。** 开着的话,第二次跑同样的提示词会直接命中缓存返回,而
# e2e 唯一要证明的就是「真的打出去过一次」——那次运行会全绿地什么都没验,而且
# 从外部看不出来它和真的打过一次有什么区别。
PGW_CACHE_BACKEND=none
# 遥测也是 none。开 sqlite 或 postgres 要另外建库,而本库不管遥测(CLAUDE.md §1.5);
# 真要看用量,那是在网关自己的账目里查,按调用标识连表。
PGW_TELEMETRY_BACKEND=none
+51
View File
@@ -0,0 +1,51 @@
# CHANGELOG
**这份文件是「哪个版本改了什么」的唯一权威**`CLAUDE.md` §0)。未发布的改动攒在「未发布」
那一段,发布时改成 `## X.Y.Z(日期)`。发布的完整步骤在
`research-wiki/guides/releasing.md`
版本号语义按 `CLAUDE.md` §1.3:公共类型的字段只增不删不改名,新增字段必带默认值;要删要改
就发新 major 并写迁移指引。
## 未发布
## 1.0.12026-08-11
首个发布版本。十个模块全部落地,四层测试都在跑。
**为什么首个版本是 1.0.1 而不是 0.x**`CLAUDE.md` §1.3 那条「公共类型的字段只增不删不改名」
从第一个下游装上它的那天起就生效,而 0.x 在语义化版本里意味着「随时可以破坏兼容」——两者
对不上。用 1.x 开头是在声明那条承诺现在就算数。
### 公共 API
- **`polyloop.types`**:消息与内容块、上下文与注入、动作结果、预算、停止原因、逐步轨迹的
一行(`StepRecord`)、一次运行的结果(`RunResult`),以及五种持久化日志记录。持久化结构带
独立的 schema 版本,读到不认得的版本直接失败,不靠默认值补齐(`CLAUDE.md` §1.4)。
- **`polyloop.ports`**:五个接缝的 Protocol——模型调用、决策解释、动作执行、存储、事件出口。
每个都带一个同步的 `parameters()`,装配时聚合成参数快照写进运行开始记录,续跑时逐字段比对。
- **`polyloop.session`**`run``resume` 两个入口,以及它们收的两个装配对象
`AgentDefinition` 跨运行不变、`RunRequest` 每次运行一份)。
- **`polyloop.tools`**:工具注册表。注册、模型可见的 schema 生成、存在性与参数校验、分发,
四件事由同一个注册表实例驱动,所以「模型看得见但调不到」这种状态构造不出来。
- **`polyloop.serialization`**:持久化记录的编解码。读到没有版本字段的载荷直接失败。
- **`polyloop.stores`**:逐行追加的 jsonl 存储。必须显式 import,不进顶层。
- **`polyloop.adapters`**PolyGateway 的模型调用适配器,装它要 `polyloop[gateway]`
必须显式 import——顺手导出会让每个进程在 import 本库时把网关连同它的 provider 目录一起拉起来。
### 这一版保证了什么
- **一次运行是有界的**:步数、动作数、连续解析失败次数、提示词规模四个预算,停止判定的顺序
写死在主循环里,判定结果随每一步落盘——崩在中间也不会把「恰好用满预算完成」记成「预算耗尽」。
- **崩溃之后能从断点续跑**:一步之内四次写,其中两次是耐久屏障;恢复读意图日志判断上一步
处在哪一档(还没开始 / 执行完了 / 状态未知 / 日志损坏),按工具声明的重放策略处置。
十个写入边界逐个崩过一遍,续跑结果与不中断跑完逐字段相等。
- **取消能穿透**`asyncio.CancelledError` 不被捕获吞没,取消进来之后在宽限期内写下结束记录
——不写的话恢复会把一次被主动叫停的运行当成可以续跑。
- **并发跑同一份定义互不干扰**:装配对象不持有任何一次运行的状态。
### 已知欠账
- `stores` 只有 jsonl 一种形态,关系数据库那种由下游自己实现,`tests/contract/` 是它的准入标准。
- 契约套件里解释器、执行器、模型客户端那几条等下游把实现接进来才跑得到。
- 原子写的「崩在中间时两者都不可见」与前缀持久性这两条承诺没有机器兜底,标成 `xfail`
+7 -4
View File
@@ -56,7 +56,8 @@
**公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。 **公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。
**断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。 **断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。
9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。 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` 拿不到任何修复,且无人发现。完整步骤`research-wiki/guides/`(还没写) 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`
**判据是外部可见结果,不是本地步骤跑通**:收尾要以下游视角逐一打开产物——registry 包页面的正文与仓库链接、仓库的 Releases 页、装完之后包里的文件。PolyGateway 的 1.1.2 三步全绿,包页面却是空白的。
11. **动手前先看 README 的阶段清单。** 不要为了还没到的阶段提前写大量代码,也不要为假设中的工作量预先埋好一堆结构——这就是 §6 YAGNI 的意思,只是在阶段这个尺度上再说一次。 11. **动手前先看 README 的阶段清单。** 不要为了还没到的阶段提前写大量代码,也不要为假设中的工作量预先埋好一堆结构——这就是 §6 YAGNI 的意思,只是在阶段这个尺度上再说一次。
## 2. 人类门(仅以下需要用户批准,其余自行判断) ## 2. 人类门(仅以下需要用户批准,其余自行判断)
@@ -99,7 +100,8 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。**
## 4. 环境与运行 ## 4. 环境与运行
- Conda 环境 `PolyLoop`**还没建**),Python 3.11。3.11 不是选出来的,是被下游钉死的:dissect 和 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本 - **这台机器设了 `http_proxy` / `https_proxy`,指向一个到不了外面的本地代理。** 凡是访问实验室 Gitea 的命令(上传发布产物、验证已发布、从私有源装包)都得绕开它,否则失败的形态是网关错误而不是「代理有问题」,很容易被当成服务器挂了。具体命令在 `research-wiki/guides/releasing.md``README.md` 的安装一节
- Conda 环境 `PolyLoop`Python 3.11。3.11 不是选出来的,是被下游钉死的:dissect 和 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。
- **Python 命令一律用这个形状**:`PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop <cmd>`。conda 和 Python 各缓冲一层,两层都得拆:只加 `--live-stream` 或只加 `-u` / `PYTHONUNBUFFERED` 都仍然全程无输出,直到进程结束才一次性吐出。六种组合的实测与原理见 `reference/CHSAnalyzer/research-wiki/explanation/conda-run-output-buffering.md`(同一台机器、同一套 conda,结论直接适用)。 - **Python 命令一律用这个形状**:`PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop <cmd>`。conda 和 Python 各缓冲一层,两层都得拆:只加 `--live-stream` 或只加 `-u` / `PYTHONUNBUFFERED` 都仍然全程无输出,直到进程结束才一次性吐出。六种组合的实测与原理见 `reference/CHSAnalyzer/research-wiki/explanation/conda-run-output-buffering.md`(同一台机器、同一套 conda,结论直接适用)。
- **超过约一分钟的命令(测试套件、压测、真实网关回归)必须放进 tmux 跑**,不要阻塞在前台,也不要只丢进后台。tmux 会话人和 AI 都能 attach,可以一起看同一份实时输出、随时中断。会话按用途命名(如 `polyloop-e2e`),跑完不要急着 kill,留着给人复查。 - **超过约一分钟的命令(测试套件、压测、真实网关回归)必须放进 tmux 跑**,不要阻塞在前台,也不要只丢进后台。tmux 会话人和 AI 都能 attach,可以一起看同一份实时输出、随时中断。会话按用途命名(如 `polyloop-e2e`),跑完不要急着 kill,留着给人复查。
- **长跑命令末尾不得接管道。** `pytest ... | tail` 的退出码来自管道最后一节,于是失败的测试跑会报成 exit 0。要判断完成用 `wait` 或轮询 PID**不要用 `pgrep -f "<完整命令串>"`**——它会匹配到自己,形成永不结束的等待。这两条是 PolyGateway 实测撞出来的,两种失败都以「看起来还在跑」的形态呈现,从外部区分不了。 - **长跑命令末尾不得接管道。** `pytest ... | tail` 的退出码来自管道最后一节,于是失败的测试跑会报成 exit 0。要判断完成用 `wait` 或轮询 PID**不要用 `pgrep -f "<完整命令串>"`**——它会匹配到自己,形成永不结束的等待。这两条是 PolyGateway 实测撞出来的,两种失败都以「看起来还在跑」的形态呈现,从外部区分不了。
@@ -121,8 +123,9 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。**
(公共类型和枚举取值不在这里,权威见 §0 表格) (公共类型和枚举取值不在这里,权威见 §0 表格)
★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删) ★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删)
★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0), ★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0),
也是任何新适配器的准入标准。目录已建、测试还没写 也是任何新适配器的准入标准
src/polyloop/ 库本体。十个模块的空骨架已建,内容还没写 tests/e2e/ 打真实模型网关,会产生真实费用。默认不跑,两道闸见 .env.example
★ src/polyloop/ 库本体,十个模块
``` ```
常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md` 常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`
+36 -14
View File
@@ -9,17 +9,38 @@
--- ---
## ⚠️ 项目还没有可用的功能(2026-08-07 起) ## 安装
`src/polyloop/` 下是十个模块的空骨架——目录和依赖契约先于代码存在,模块里一个类一个函数都 发布在实验室自建的 Gitea PyPI registry 上,公网 PyPI 查它是 404,所以装它必须自己带索引地址:
还没有。下面的阶段清单是唯一的进度权威。
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polyloop==1.0.*"
```
模型调用要经 PolyGateway,那部分是一个单独的 extra——不用它的人不该被迫装上网关:
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polyloop[gateway]==1.0.*"
```
写进 `requirements.txt` 的话,那一行 `--extra-index-url` 必须排在 `polyloop` 之前。
**这台开发机上的注意事项**:它设了 `http_proxy` 指向一个到不了外面的本地代理,走代理会失败,
装的时候加 `NO_PROXY=gitea.iomgaa.online`
## 现状
十个模块全部落地,四层测试都在跑。**还没有任何下游项目真的用过它**——这是它现在最大的未验证项,
下面的阶段清单是唯一的进度权威。
## 消费者与验收标准 ## 消费者与验收标准
| 项目 | 现状 | 本库对它的验收标准 | | 项目 | 现状 | 本库对它的验收标准 |
|---|---|---| |---|---|---|
| dissect | 已有跑着的 `harness/agent/`loop / context / memory / parser | **能把那套循环搬到本库上,dissect 原有测试全绿。** 一手需求证据最强 | | dissect | 已有跑着的 `harness/agent/`loop / context / memory / parser | **能把那套循环搬到本库上,dissect 原有测试全绿。** 一手需求证据最强 |
| GovDoc-SaaS | 已有 `packages/docagent-core/`(第一次抽库尝试,含 agent / workflow / retrieval / taskrun | **能替代掉 `docagent-core/agent`,能替代更多更好。** 哪些子包能一并接管,在第 ② 阶段判断 | | GovDoc-SaaS | 仓库 2026-08-03 起整体重建,实现全部清空,自己的清单停在「构建文档框架」 | **不做迁移验收,做设计级验收**:它真实需要的东西逐条能不能被承载,见 `research-wiki/migrations/govdoc-saas.md`。它将来写 agent 层时是直接长在本库上,不是迁过来 |
| CHSAnalyzer | 还没写到 agent 那一步,只有设计方案 | **远期可以兼容使用。** 它的 agent 需求要么本库能满足,要么明确写进「不属于本库」清单并说明为什么 | | CHSAnalyzer | 还没写到 agent 那一步,只有设计方案 | **远期可以兼容使用。** 它的 agent 需求要么本库能满足,要么明确写进「不属于本库」清单并说明为什么 |
三者的证据强度不同,能进本库的语义也就分档:dissect 和 GovDoc-SaaS 的真实代码是一手证据, 三者的证据强度不同,能进本库的语义也就分档:dissect 和 GovDoc-SaaS 的真实代码是一手证据,
@@ -39,13 +60,14 @@
所以它排在架构前面:边界画错,后面每一份架构文档都要重写 所以它排在架构前面:边界画错,后面每一份架构文档都要重写
- [x] ③ 架构 —— `research-wiki/explanation/architecture.md``pyproject.toml` 的 import-linter 契约。 - [x] ③ 架构 —— `research-wiki/explanation/architecture.md``pyproject.toml` 的 import-linter 契约。
架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点 架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点
- [x] ④ 测试框架 —— unit / integration / contract 三层已在跑;**e2e 还是空的**,它要打真实 - [x] ④ 测试框架 —— 四层都在跑(划分判据是「依赖什么」,见 [CLAUDE.md](CLAUDE.md) §1.9)。
模型网关(划分判据是「依赖什么」,见 [CLAUDE.md](CLAUDE.md) §1.9)。`tests/contract/` e2e 打真实模型网关、会产生真实费用,所以默认不跑:要 `POLYLOOP_E2E=1` 加显式
那套公共行为一致性用例接上了自带的存储实现,解释器与执行器那几条仍等下游把实现接进来 `pytest -m e2e`,配置见 [.env.example](.env.example)。`tests/contract/` 那套公共行为
- [x] ⑤ 实现 —— 十个模块全部落地。**两处已知欠账**:事件出口收下了但没有调用点(`Event` 一致性用例接上了自带的存储实现,解释器与执行器那几条仍等下游把实现接进来
还没有字段,事件集要独立成一份 design doc);`stores` 只有逐行追加那一种形态,关系 - [x] ⑤ 实现 —— 十个模块全部落地,五个接缝都有调用点。**一处已知欠账**:`stores` 只有逐行
数据库那种由下游自己实现,契约套件是它的准入标准 追加那一种形态,关系数据库那种由下游自己实现,契约套件是它的准入标准
- [ ] ⑥ 迁移验收 —— 真的把 dissect 与 GovDoc-SaaS 迁过来,以两边测试全绿为准 - [ ] ⑥ 迁移验收 —— 真的把 dissect 迁过来,以它原有测试全绿为准。GovDoc-SaaS 那半不是迁移
而是设计级验收(理由见上面那张表),口径在 `research-wiki/migrations/govdoc-saas.md`
## 本地检查 ## 本地检查
@@ -55,8 +77,8 @@ make test # pyteste2e 默认不跑,它打真实网关要花钱)
make ci # 上面两条 make ci # 上面两条
``` ```
仓库还没有 remote,所以没有 CI workflow`make ci` 就是当前的全部机器闸,和 PolyGateway 还没有 CI workflow`make ci` 就是当前的全部机器闸,和 PolyGateway 一样。发布怎么做见
一样。等仓库推上去之后按 `research-wiki/guides/` 补 workflow(那份也还没写) [research-wiki/guides/releasing.md](research-wiki/guides/releasing.md)
文档质量不走机器检查,走 [CLAUDE.md](CLAUDE.md) §3 的「硕士生阅读」评审。 文档质量不走机器检查,走 [CLAUDE.md](CLAUDE.md) §3 的「硕士生阅读」评审。
@@ -69,7 +91,7 @@ make ci # 上面两条
|---|---| |---|---|
| `reference/agent-core.md` | 别人为本项目写的一份架构提案。其中任何一条在被我们自己的 design doc 采纳前都不作数 | | `reference/agent-core.md` | 别人为本项目写的一份架构提案。其中任何一条在被我们自己的 design doc 采纳前都不作数 |
| `reference/dissect/` | 消费者,已有 ReAct 循环实现 | | `reference/dissect/` | 消费者,已有 ReAct 循环实现 |
| `reference/GovDoc-SaaS/``background` 分支) | 消费者,已有第一次抽库尝试 `packages/docagent-core/` | | `reference/GovDoc-SaaS/``background` 分支) | 消费者的**旧代码**,第一次抽库尝试 `packages/docagent-core/`。它那边的活仓库已经把这份降级成「只作研究输入,不是当前事实来源」 |
| `reference/GovDoc-Editor/` | GovDoc-SaaS 重构之前的那一版,今天跑在生产上。需求来源,不是迁移对象 | | `reference/GovDoc-Editor/` | GovDoc-SaaS 重构之前的那一版,今天跑在生产上。需求来源,不是迁移对象 |
| `reference/CHSAnalyzer/` | 远期消费者;同时是本仓库协作规范的蓝本 | | `reference/CHSAnalyzer/` | 远期消费者;同时是本仓库协作规范的蓝本 |
| `reference/PolyGateway/` | 本库的依赖,也是「实验室共用库该怎么做」的蓝本 | | `reference/PolyGateway/` | 本库的依赖,也是「实验室共用库该怎么做」的蓝本 |
+17 -1
View File
@@ -4,8 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polyloop" name = "polyloop"
version = "0.0.0" # 与 src/polyloop/__init__.py 的 __version__ 必须一致,由 tests/unit/test_package.py 断言。
version = "1.0.1"
description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入" description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入"
# registry 的包页面正文只认这一项:缺了它页面就是一片空白,而 twine 只会警告
# long_description missing,不阻塞上传——三步全绿、产物是坏的(PolyGateway 1.1.2 的教训)。
# README 在打包时被固化进产物,发布之后再改无效,所以改 README 必须排在构建之前。
readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
# 3.11 是被下游钉死的:dissect 与 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。 # 3.11 是被下游钉死的:dissect 与 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。
dependencies = [] dependencies = []
@@ -25,8 +30,19 @@ dev = [
"pytest-asyncio==1.4.0", "pytest-asyncio==1.4.0",
"ruff==0.16.2", "ruff==0.16.2",
"import-linter==2.13", "import-linter==2.13",
# 发布用的两个。**刻意放进 dev 而不是靠人手装**:PolyGateway 那边它们不在任何 extra 里,
# 于是发布指南必须多写一条「记得先装这两个」,而那种步骤迟早有人漏。
"build==1.5.0",
"twine==7.0.0",
] ]
# 包页面上那几个链接。PyPI 的元数据里没有「仓库」这个字段,所以 registry 不会自动把包挂到
# 仓库上——那一步只能在网页上手动做,这里这几条是包页面上唯一能自带的去处。
[project.urls]
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"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -0,0 +1,309 @@
# Design 0013 · 事件集与具名回调清单
**日期** 2026-08-10 · **状态** 已接受(2026-08-10 项目负责人确认)
**落实** `0003-public-api-shape.md` 决策五那句「观察走事件流、干预走具名回调」,并补上
`0007-seam-behaviour.md`「留给后续的」那半个契约——事件出口的「投递失败不打断循环」已经定了,
「发出去的事件里有什么」还没有,因为 `Event` 到今天只有一个名字、零个字段。
**取代** `0007` 决策二末尾那句设想(「要补的话,将来靠事件流把执行器原文送出去做审计」)。
决策六换了另一条路,理由在那一节。
**触及** `../../src/polyloop/ports/``Event` 加字段、新增 `EventKind`)、
`../../src/polyloop/session/`(发事件的那一处)、`../../tests/contract/test_event_sink.py`
**它过了 `../../CLAUDE.md` §2 那道人类门**,因为改的是公共类型的字段:`Event` 从零字段变成有
字段,而 `EventKind` 的取值集合从此是一份对外承诺。
## 读本文需要的几个名字
**接缝**——库留给项目替换的扩展点,用 Protocol 表达。本库有五个:模型调用、决策解释、动作
执行、存储、事件出口。**事件出口**这一个的形状是一个异步的 `emit(event: Event) -> None`
加一个同步的 `parameters()`(后者交出这个实现的参数,进参数快照)。
**意图日志**——库把一次运行写成一串只追加的记录:运行开始、意图(「要做一件有副作用的事
了」)、模型调用结果、一步走完、运行结束。崩溃之后的恢复判定读这串记录,靠「有意图没结果」
这种形态判断上次停在哪一档。
**步记录(`StepRecord`)与「一步走完」记录(`StepCompleted`)是两个东西**。前者是逐步轨迹
里的一行,下游拿它做分析;后者是日志里的一条,它把动作执行接缝的**原样返回值**和那一行步
记录**一次原子写下去**。本文两处都要用到,不能互换。
**回填进历史**——历史是喂给模型的那串消息。把观察回填进历史,就是在下一次模型调用之前往
消息序列里追加一条,好让模型看见上一步的结果。
**参数快照**——装配时向每个接缝要一份字符串到字符串的映射,合并起来写进运行开始记录。续跑
时把当前装配现算的那份与记录里的逐字段比对,任何一项不一致直接报错。「模型看得见的东西必须
能进参数快照」是本库反复用到的一条判据:进不了快照,就意味着它可以在两次运行之间悄悄变化。
**`reference/pi`**——`reference/` 下六个参考仓库之一,另一个 agent 执行内核。本文引用它是当
反例,它不是本项目的设计依据。
## 需求从哪来
`../migrations/govdoc-saas.md` 的缺口登记里有一条:GovDoc 的审计出口是一个「发一条带类型和
载荷的事件」的接口,而它的业务侧有一条硬纪律——**agent 的原始输出、修复后的输出、恢复来源
全程留痕,禁止静默修复**。那条纪律迁过来之后要有地方承载,不然它迁不了。
它今天的事件模型写在 `reference/GovDoc-SaaS/research-wiki/schemas/docagent-audit-events.md`
三类事件:
| 它的事件 | 埋点 | 载荷 |
|---|---|---|
| `llm_step` | 解析模型响应之后,成功与失败都记一条 | `session_id` `iteration` `call_id` `raw_content`(模型原文,全量不截断)`repaired_content`(修复之后的文本)`parse_error` `parse_ok` |
| `tool_call` | 工具执行之后,含无效调用 | `session_id` `iteration` `tool_name` `args` `output_digest`(工具输出的截断摘要)`is_valid` `duration_ms` |
| `phase_recovery` | 阶段执行器抢救产物成功时 | `session_id` `phase_name` `recovered_outputs` `recovered_from` |
第三类在本库界外——它发自阶段编排层,而阶段编排按 `../explanation/scope.md` 不归本库
`0003` 决策五复述过这条)。前两类都在界内,而且**都是一次迭代发一条**。
dissect 那边没有对应需求:`reference/dissect/harness/agent/loop.py``harness/runner.py`
没有任何回调、钩子或进度上报。所以事件流现在只有一个真实消费者。
## 决策一:事件流是活的观察通道,它带的每一条事实在存储里另有一份
**不变量:一条事件里的任何事实,在存储里都能再找到一份。** 事件丢了,丢的是「早一点看见」,
不是那件事本身。
这条不是风格偏好,它是「投递失败不打断循环」那条契约成立的前提。出口允许抛异常、库接住并
继续跑(`0007` 已定),而这件事只有在事件不是任何事实的唯一出口时才安全——否则一个连不上的
后端会让一次运行的部分事实**静默消失**,而运行本身照常返回成功。
`0003` 驳回过反方向的那条路:给事件流加投递保证,让它承担审计。驳回的理由是一旦有投递保证,
事件流就变成持久结构,此后每加一个事件类型都要走人类门改 schema 版本。审计因此落到存储上,
而事件流的「可丢」靠上面那条不变量兑现。
## 决策二:那条审计纪律由存储承担,事件流只是让人不必轮询存储
纪律要留痕的三样东西,在意图日志里各有确定的位置:
| 纪律要的 | 在哪 |
|---|---|
| agent 的原始输出 | `ModelCallResult.reply.content`——模型调用结果记录里那段回复,库不改它 |
| 修复后的输出 | `StepRecord.raw_output`——解释器交回来的、回填进历史的那段 assistant 文本 |
| 恢复来源 | 意图日志本身:哪一步有意图没结果、按哪条重放策略处置过 |
**原文与修复后的文本落在两条不同的记录里,这不是巧合,是 `0002` 的结构决定的。** 模型调用
结算时写一条结果记录,那时还没解释;解释完、动作走完之后写一条「一步走完」,那里面的文本是
解释器交回来的。两次写之间隔着一次动作执行,所以两份文本天然分开存,而「解释器改写过什么」
正好是两者的差。
于是 `../../tests/contract/test_event_sink.py` 里那条
`test_audit_events_carry_both_raw_and_repaired_model_output` 的前提是错的:**事件不带这两样,
存储带**。它要改写成断言存储这一侧,落在驱动入口那一层的测试里——也就是跑完一次完整运行、
再把日志读回来,验里面能不能同时读出原文与修复后的文本。放在事件出口的契约套件里验不了,
因为那套件只看一个出口实现,看不到一次运行。
事件流剩下的用处只有一个:让同一个进程里的观察者不必轮询存储就能知道跑到哪儿了。GovDoc 的
审计管道要「不丢」,那它该包在存储接缝上,不是接在事件出口上——存储的写是运行的一部分,写
失败运行就停;事件的投递不是。
## 决策三:事件集只有一种取值——一步走完
```python
class EventKind(StrEnum):
STEP_FINISHED = "step_finished"
```
GovDoc 的两类界内事件都是**一次迭代发一条**,而一次迭代在本库就是一步。拆成两条只多出一个
信息:模型答完了但动作还没跑完。GovDoc 的工具是工作区里的文件操作,那段间隔可以忽略;dissect
的动作是执行一段 Python、可能很慢,但 dissect 根本不看事件。**一个真实消费者都举不出来的
分辨率,按 `0001` 决策三不实现也不预留。**
`reference/pi` 是另一个极端,一轮迭代发十来条(`agent/test/agent-loop.test.ts:1185` 把整个
序列钉死了),事件类型十种。那个粒度是被终端界面逼出来的——它要让用户看着模型一个字一个字
往外吐,所以连流式分片都发一条。本库没有这种消费者。值得注意的是 pi 也把实时事件流与持久
记录分成了两套东西:落盘那套是另外九种记录(`agent/src/harness/session/types.ts:203`),事件
流不落盘。这跟决策一是同一个分法,只是它的事件流因为要喂界面而切得极细。
一步走完时那条步记录覆盖了它两类事件载荷的大部分:`raw_output` 是修复后的文本,`parse_ok`
`parse_error` 是解析结果,`tool_name` / `tool_arguments` / `observation` / `action_status`
是工具那一侧。**没有覆盖的有两样,各自的去处写在这里,免得下一个人以为它们被漏了:**
| 它的载荷 | 步记录里没有 | 去哪拿 |
|---|---|---|
| `raw_content`(模型原文) | 步记录里那段是修复后的 | 模型调用结果记录,见决策二 |
| `duration_ms`(工具单独耗时) | 只有整步墙钟 `step_wall_ms` | 现在拿不到,登记在下面的代价里 |
**没有「运行开始」与「运行结束」两种事件。** 事件出口是在进程内被 `await` 调用的,收到事件的
那段代码和调用 `run` 的是同一个进程:它自己知道运行是什么时候开始的,也会在运行结束时拿到
`RunResult`。**用返回值交付终态比用一条可丢的事件交付更可靠**——最后一条事件投递失败时终态
就丢了,而返回值丢不了。取消那一档拿不到返回值(`CancelledError` 原样抛给调用方),但调用方
正是取消的那一方,它知道;终态在存储的结束记录里。
要把进度写进一张业务表供另一个进程轮询的实现,照这个分工:逐步的行由事件出口写,终态那一行
由拿到 `RunResult` 的调用方写。
**`EventKind` 的取值集合从此是对外承诺**,加一个取值和改停止原因的取值一样要走人类门
`../../CLAUDE.md` §2)。加取值本身在类型上是兼容的,但对一个「假定每条事件都是步事件」而
写的出口来说,新取值会被静默地当成步事件读——所以第一版就带 `kind`,让每个出口从第一天起
就得分发。这跟 `0003` 决策六是同一个判据:容器的形状要一次选对,里面装什么可以以后加。
## 决策四:事件带的是那条步记录本身,不是它的摘要
```python
@dataclass(frozen=True, slots=True, kw_only=True)
class Event:
kind: EventKind
run_id: str
model_binding: Mapping[str, str]
step: StepRecord
```
数据类的这三个开关是本库所有公共数据类的统一形状,理由在 `0009`,不在本文重复。
**不挑几个字段拼一份摘要。** 挑出来的那份是一次投影,而投影会漂移。`0003` 决策五驳回观察
投影接缝时举过参考仓库 `reference/pi` 的例子,写这一条时又去核了一遍,实际情况比那句话更糟:
它同一组三份投影对不上的地方有四处,而不是一处。
| 同一条会话条目 | `harness/session/context.ts:65` | `harness/compaction/compaction.ts:68` | `harness/compaction/branch-summarization.ts:112` |
|---|---|---|---|
| `custom` 类型的条目 | 走投影器,可见 | 落到默认分支,不可见 | 显式返回空,不可见 |
| 压缩摘要后面保留的那截尾巴 | 整段带上 | 丢掉 | 丢掉 |
| 停止原因是「推迟」的助手消息 | 显式排除 | 没有这个判断,带进去 | 没有这个判断,带进去 |
| 工具结果消息 | 保留 | 保留 | 显式丢弃 |
四处没有一处是写错的——每一份投影单独看都讲得通,它们只是在不同时间被不同的需求改过。这正是
投影这种东西的失效形态:不报错、不崩溃,只是同一条记录在两个地方长得不一样,而发现它要有人
同时读三个文件。
带整条记录就没有这个问题:`StepRecord` 加一个字段,事件里自动就有,两边不可能对不上。
**`run_id` 在场,因为一个出口可以被并发的多次运行共用。** 事件出口挂在定义上(`0003` 决策
三),而定义可以被多次运行共用;没有运行标识,两次并发运行的事件在出口那边混成一串。
**`model_binding` 在场,因为项目自己那套标识没法从运行标识倒推。** GovDoc 的审计要求三类事件
必带会话标识,而会话标识是它自己的标识、住在请求的绑定里;一次会话包含三个阶段、也就是三次
运行,所以运行标识与会话标识不是一回事。让项目把会话标识编进运行标识再解析出来,等于往一个
库明确声明「不解析」的不透明字符串里塞结构。字段名与请求上那个 `model_binding` 逐字相同,
因为它就是同一份映射原样传过来的;值只能是字符串这条约束也一并继承,理由在 `0003` 决策三
——它的全部键值都要进参数快照,而快照是一份字符串到字符串的映射。
**没有 `schema_version`。** 事件不是持久化结构,`../../CLAUDE.md` §1.4 管的是会被下游存进
数据库或实验数据集的结构。下游把事件存下来时,存的是它自己那张表的 schema。而真正需要版本
的那部分——步记录——本来就带着自己的 `schema_version` 一起进来了。
## 决策五:只有一处发事件的地方,在「一步走完」原子落地之后
一步走完、`StepCompleted` 原子落地、**然后**发事件,再进入下一次迭代的停止判定。
**先写后发,顺序不能倒过来。** 倒过来的话,进程崩在发事件与落盘之间,观察者看见了一步而
存储里没有——那正是决策一那条不变量被打破的样子,而它的表现是「进度表里有第 7 步、日志里
只到第 6 步」,谁也说不清哪个是真的。
**`reference/pi` 是反着做的**`coding-agent/src/core/agent-session.ts:633`,那一行的注释就写着
先发给扩展),而它那么做没有问题,因为它的订阅者是同一个进程里的终端界面:进程死了订阅者跟着
死,「看见了但没落盘」这件事不会留下任何痕迹。本库的出口可以是一个活得比进程久的数据库,
所以同一个顺序在这里会留下一份对不上的记录。同一件事在两个项目里答案不同,差别在订阅者的
寿命,不在哪种写法更讲究。
**发在停止判定之前,因为停止判定可能不返回。** 判定一旦决定收尾,控制流就去写结束记录、
组装 `RunResult`、返回给调用方了。把发事件排在判定之后,最后一步就没有事件——而一次运行的
最后一步恰恰是最值得看见的那一步(它是「做完了」还是「预算烧光了」)。排在判定之前,每一步
都发,没有例外要记。
**续跑时不为已经完成的步补发事件。** 事件是「这次进程里真的发生了什么」的实时投影,补发等于
宣称一件早就发生过的事刚刚发生,而接进度表的那一侧会多出一批重复行。判据是**这次进程里有没有
真的执行**:从日志里读回来直接跳过的步不发;按重放策略在这次进程里重新执行了一遍的步照发。
观察者要补全前半段,从存储里读。
**投递不排队、不并发、不缓冲。** 一条一条 `await` 过去,按发生顺序。排队要引入后台任务、
背压和关闭时的排空,而那是治理,按 `../../CLAUDE.md` §1.5 不在本库做。代价照实认下:一个慢
出口会按步拖慢整次运行。要异步就在出口实现里自己排队——它知道自己能丢多少,库不知道。
## 决策六:执行器被丢掉的那段原文已经在日志里,不必再开一条路
`0007` 决策二定了动作被拒绝或环境故障时,回填进历史的是库合成的那段文本,执行器返回的
`observation` 不进历史。它同时认下一笔代价——「哪个参数不合法」这种只有执行器知道的信息
丢了——并设想将来靠事件流把它送出去做审计。
**那笔代价没有实际发生,因为它只是没进历史,并没有没进日志。** 「一步走完」这条记录同时写
两样东西:动作执行接缝的**原样返回值**,以及那一行步记录。库替换的只是步记录里的
`observation` 那一列,接缝返回的整个结构原样落盘,其中就包括执行器自己写的那段文本。
于是那条设想不必落地,而且**不该落地**:走事件流的话,出口一失败,那段文本就再也查不出来了,
而 GovDoc 那条纪律的原话正是「禁止静默修复」——一次被库替换掉的观察,替换前的样子只能存在
于一个可丢的通道里,这本身就是一次可能静默的修复。它今天审计里那个 `output_digest` 在无效
调用那一档记的,正是它的工具调度器给出的那段拒绝说明;对应到本库,那就是动作执行接缝返回的
`observation`,而它在日志里。
**代价是拿它要读日志,读不到轨迹里。** 下游拿到的 `RunResult.steps` 是步记录序列,里面没有
这一段;要审计就得把日志读回来。接受,因为另一头是往每一条步记录上再挂一份可能上兆的文本,
而需要它的只有出错的那几步。
## 决策七:投递失败按次计数,只算这次进程的
`RunResult.event_delivery_failures` 已经在类型里了,它的口径定在这里。
**捕获 `Exception`,不捕获 `BaseException`。** `asyncio.CancelledError` 必须原样穿过
`../../CLAUDE.md` §1.6)——在发事件这一下把取消吞掉,取消就会晚一整步才生效。
**每一次抛异常的 `emit` 计一次。** 不去重、不按步合并:出口连续失败十步就是十,那个数字要
能反映「有多失败」,而不只是「失败过」。
**捕获之后记一条错误日志,不静默继续**`../../CLAUDE.md` §1.7),也**不把失败转成一条事件
从同一个出口再发一次**——`0003` 决策四已经定了这条,理由是自我喂食:一个持续失败的出口会让
失败处理路径变成递归,而递归的表现是进程卡住或栈溢出,不是一条错误日志。
**计数是每次运行的,不是每个出口的。** 出口挂在定义上、可以被并发的多次运行共用,而这个数
属于一次运行的结果,所以它住在运行自己的状态里。
**不跨进程累加。** 崩溃续跑之后,返回值里的数字是这次进程投递失败的次数,上一次进程那些不
算在内。要累加就得把它写进结束记录,而那会把一个纯观察量变成持久结构——`0003` 驳回给事件流
加投递保证时说的正是这个。
## 决策八:具名回调清单现在是空的
`0003` 决策五定了「观察走事件流、干预走具名回调」。事件流那一半上面定完了,另一半没有内容:
**本库现在一个具名回调都不提供。**
这不是遗漏。GovDoc 那套钩子有四个扩展点,逐个都已经有别的归宿:
| 它的钩子 | 在本库的归宿 |
|---|---|
| 步骤开始前注入上下文 | 上下文装配不设接缝(`0003` 决策五),注入走请求上的注入槽(`0010` |
| 工具执行后替换工具输出 | 观察投影不设接缝(`0003` 决策五),那条驳回举的反例正是这个钩子——它的文档承诺「替换」而代码做的是「追加」 |
| 步骤结束后持久化中间状态 | 存储接缝在做这件事,而且它是必填的(`0003` 的否决方案里驳回了「做成可选参数」) |
| 循环结束后做遥测与清理 | `run` 的返回值就是这个 |
**将来要加一个具名回调,判据两条**:按 `0001` 决策三得有两个真实消费者;而且它改动的东西
必须能进参数快照——回调一旦能改变模型看见的内容,那份内容就成了这次运行的参数,不进快照
就等于有一个影响行为又读不出来的默认值。
**观察通道与干预通道的分界在失败处置上:观察通道失败了运行继续,干预通道失败了运行必须停。**
一个没生效的干预和一个生效了的干预会产出两次不同的运行,而静默继续意味着事后分不出是哪一种。
事件出口按前者处置(接住、计数、继续),任何将来的回调按后者(原样抛出、终止运行)。
`reference/pi` 独立走到了同一条线上:它的扩展回调抛异常会被接住、转成一条错误事件、其余回调
照跑(`coding-agent/src/core/extensions/runner.ts:809`),唯独工具执行前那一个回调抛异常会挡住
这次工具调用(`coding-agent/src/core/agent-session.ts:491`)。它的设计文档把这条写成一句
——抛异常的回调不会让运行失败,只有工具执行前那个是 fail-closed
`agent/docs/harness-v2.md:1290`)。两边都不是从原则推出来的,是各自撞到「一个悄悄没生效的
干预事后查不出来」之后收敛到的同一处。
## 代价
**慢出口按步拖慢运行。** 决策五不排队,所以一个每步花两百毫秒写数据库的出口,在五十步的
长运行上就是十秒。缓解只能在出口实现里做。
**一条事件带着整条步记录,可能很大。** `raw_output``observation` 都没有长度上限,出口
每步都会收到一份完整副本。
**工具单独的耗时拿不到。** 库只记整步墙钟,里面混着模型调用、解释和动作执行三段。GovDoc 今天
`tool_call` 事件记的是工具那一段,迁过来之后这个数会变成整步的数,两批数据不可比。这一条
登记进 `../migrations/govdoc-saas.md` 的缺口清单,不在本文解决——要拆开就得往步记录里加分段
计时,而那是一次持久化结构的改动,得有人先说清楚这个数拿去做什么。
**只有一种事件,看不见「模型答完了、动作还在跑」。** 一个动作耗时很长的项目,进度表在那段时间
里不会更新。等到有第二个消费者真的需要,加一个取值,那时 `Event.step` 要变成可为空并配一条
不变量——这是决策三承认的、加取值那天要付的结构成本。
## 留给后续的
**流式的中间增量还是不透出。** `0012` 文末把这件事指到本文,答案是不加:一次调用之内的增量
不是一步的结果,让它进事件流就要给事件一个「一步之内多条」的形状,而那要求事件带序号和排序
保证——那正是 `0003` 驳回的投递保证的开头。等有消费者再说。
**阶段级的抢救事件在界外。** GovDoc 的 `phase_recovery` 发自阶段执行器,那一层不归本库;它
要发什么事件由它自己定。本库这边它需要的是「这次运行结束了没有、结果是什么」,那是存储的
结束记录在回答。
+22 -7
View File
@@ -96,10 +96,24 @@ GovDoc 有两套 agent,形态完全不同,而两套都不能直接当迁移
阶段级续跑本身在界外,但它依赖一个界内的事实:运行结果必须是可持久化、可读回、可判定的 阶段级续跑本身在界外,但它依赖一个界内的事实:运行结果必须是可持久化、可读回、可判定的
结构,而且「这次运行结束了」要由库自己写下来。这条已经落进 `../design/0002-step-level-resume.md` 结构,而且「这次运行结束了」要由库自己写下来。这条已经落进 `../design/0002-step-level-resume.md`
## 已经有答案的(原缺口登记,`0003` 回答) ## 已经有答案的(原缺口登记,`0003` 与 `0013` 回答)
条曾经登记为「PolyLoop 答不上来」,`../design/0003-public-api-shape.md` 已经定了。 条曾经登记为「PolyLoop 答不上来」,现在定了。留在这里是因为它们是 GovDoc 侧真实的接入
留在这里是因为它们是 GovDoc 侧真实的接入约束,迁移时要照着改代码。 约束,迁移时要照着改代码。
**审计纪律由意图日志承担,不由事件流承担**`../design/0013-event-set-and-callbacks.md`
决策一与决策二)。这条原来登记成「事件流能不能覆盖审计需求,取决于事件里带不带原始响应」,
答案是不带——事件流可丢,一件只存在于可丢通道里的事实撑不起「禁止静默修复」。纪律要留痕的
三样在日志里各有位置:模型原文在模型调用结果记录的回复里,修复后的文本是步记录的
`raw_output`,恢复来源是意图日志本身(哪一步有意图没结果、按哪条重放策略处置过)。
对 GovDoc 的实质影响是**审计出口要包在存储接缝上,不是接在事件出口上**。存储的写是运行的
一部分,写失败运行就停;事件的投递不是。今天那个发一条带类型和载荷的接口迁过来之后,位置
变了。事件流仍然可以接——一步走完发一条,带整条步记录——但它是给进度回写用的,不能当审计。
**它的三类事件里有一类在界外。** `phase_recovery` 发自阶段执行器,而阶段编排不归本库;那
一层要发什么事件由它自己定。本库这边它需要的只是「这次运行结束了没有、结果是什么」,那是
存储的结束记录在回答。
**预算挂在请求上,不是定义上。** 一份定义可以被三个阶段共用,各传一份预算,不会长出三份 **预算挂在请求上,不是定义上。** 一份定义可以被三个阶段共用,各传一份预算,不会长出三份
除预算外完全相同的定义。**不设「定义给默认值、请求可覆盖」**——两处取值意味着「这次到底 除预算外完全相同的定义。**不设「定义给默认值、请求可覆盖」**——两处取值意味着「这次到底
@@ -139,10 +153,11 @@ JSON,不用原生工具调用,所以现在不缺。哪天要换成原生工
个 tools 字段——那是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数,以及原生工具调用的 个 tools 字段——那是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数,以及原生工具调用的
返回怎么进决策解释接缝那三个分支。**这条要在换协议之前定,不能边换边定。** 返回怎么进决策解释接缝那三个分支。**这条要在换协议之前定,不能边换边定。**
**审计出口与事件流的关系。** 生产实现的审计出口是一个「发一条带类型和载荷的事件」的接口, **动作被拒绝那一档,工具单独的耗时拿不到。** 生产实现的 `tool_call` 审计事件记的是工具执行
而 PolyLoop 的方向是「观察走事件流、干预走具名回调」。事件流能不能覆盖审计的需求,取决于 那一段的毫秒数,而 PolyLoop 只记整步墙钟——里面混着模型调用、解释和动作执行三段。迁过去这个
事件里带不带原始响应——审计纪律要求原始输出和修复后的输出都留痕。事件集与回调清单要独立 数的口径会变,迁移前后两批数据不可比。要拆开就得往步记录里加分段计时,那是一次持久化结构的
成一份 design doc,这条在那时候定。 改动,得先说清楚这个数拿去做什么(是给人看慢在哪,还是要进统计)。这条在
`../design/0013-event-set-and-callbacks.md` 的代价一节登记过,那份文档没解决它。
**动作被拒绝之后的重复行为。** 生产实现对无效工具调用不设单独的重试上限,靠总迭代上界收敛。 **动作被拒绝之后的重复行为。** 生产实现对无效工具调用不设单独的重试上限,靠总迭代上界收敛。
PolyLoop 目前的想法一致,但要确认这在长阶段(50 步)上够不够——模型反复调用同一个不存在的 PolyLoop 目前的想法一致,但要确认这在长阶段(50 步)上够不够——模型反复调用同一个不存在的
+3 -3
View File
@@ -8,11 +8,11 @@
import 时把网关连同它的 provider 目录一起拉起来,而不传存储的人不会知道自己这次运行 import 时把网关连同它的 provider 目录一起拉起来,而不传存储的人不会知道自己这次运行
没有恢复能力。 没有恢复能力。
**当前是空骨架。** 目录与依赖契约先于代码存在,形状 分层与依赖方向见 `research-wiki/explanation/architecture.md`,一次运行到底保证什么
`research-wiki/explanation/architecture.md`。 `tests/contract/`。
""" """
#: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。 #: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。
#: 两处双写是因为运行时读不到构建元数据(未安装的源码树里 `importlib.metadata` 查不到), #: 两处双写是因为运行时读不到构建元数据(未安装的源码树里 `importlib.metadata` 查不到),
#: 而下游报 bug 时第一件事就是问版本号。 #: 而下游报 bug 时第一件事就是问版本号。
__version__ = "0.0.0" __version__ = "1.0.1"
+34 -2
View File
@@ -19,6 +19,7 @@
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from polyloop.types import ( from polyloop.types import (
@@ -30,6 +31,7 @@ from polyloop.types import (
RunFinished, RunFinished,
RunStarted, RunStarted,
StepCompleted, StepCompleted,
StepRecord,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -119,14 +121,43 @@ class RunLog:
finished: RunFinished | None = None finished: RunFinished | None = None
class EventKind(StrEnum):
"""事件的种类。
**只有一种取值,但 `Event` 从第一天起就带 `kind`。** 加一个取值在类型上是兼容变更,可是
对一个「假定每条事件都是步事件」写出来的出口来说,新取值会被静默地当成步事件读。带上
`kind` 逼每个出口从第一天起就分发,新取值那天它至少是显式地没被处理。
加取值要走 `CLAUDE.md` §2 那道人类门,和改停止原因的取值同一档。
"""
#: 一步走完,而且是**在这次进程里真的走完的**。从日志里读回来直接跳过的步不发这条。
STEP_FINISHED = "step_finished"
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class Event: class Event:
"""从事件出口发出去的一条事件。 """从事件出口发出去的一条事件。
**字段还没定。** 事件集与具名回调清单要独立成一份 design doc;在那之前这个类型只有名字, 事件集定在 `research-wiki/design/0013-event-set-and-callbacks.md`。**它带的每一条事实在
`EventSink.emit` 的签名不会因为它定下来而改变。 存储里都另有一份**——这条不变量是「投递失败不打断循环」那条契约成立的前提,不然一个连不上
的后端会让一次运行的部分事实静默消失,而运行本身照常返回成功。
**没有 `schema_version`。** 事件不是持久化结构(`CLAUDE.md` §1.4 管的是会被下游存进数据库
或实验数据集的那些);下游把它存下来时,存的是它自己那张表的 schema。真正需要版本的那部分
是步记录,它带着自己的 `schema_version` 一起进来。
""" """
kind: EventKind
#: 一个出口可以被并发的多次运行共用,没有这个标识那些事件在出口那边混成一串。
run_id: str
#: 项目自己那套标识,原样来自请求上的同名字段。**它不能从运行标识倒推**——一次业务会话
#: 可能包含多次运行,两者不是一回事。
model_binding: Mapping[str, str]
#: 整条步记录,不是挑几个字段拼的摘要。摘要是一次投影,而投影会漂移:步记录加一个字段,
#: 事件里自动就有,两边不可能对不上。
step: StepRecord
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 五个接缝 # 五个接缝
@@ -257,6 +288,7 @@ __all__ = [
"Decision", "Decision",
"DecisionParser", "DecisionParser",
"Event", "Event",
"EventKind",
"EventSink", "EventSink",
"FinalAnswer", "FinalAnswer",
"InvalidDecision", "InvalidDecision",
+42 -7
View File
@@ -8,9 +8,9 @@
一起」,与一次运行怎么称呼是两件事——`session` 在业界普遍指一个长期存在、可以来回对话的 一起」,与一次运行怎么称呼是两件事——`session` 在业界普遍指一个长期存在、可以来回对话的
东西,而这里的治理单位是有界的、一次性的(`0006` 决策一)。 东西,而这里的治理单位是有界的、一次性的(`0006` 决策一)。
**事件出口现在不发任何事件。** `Event` 还没有字段,事件集与具名回调清单要独立成一份 design **事件只在一处发出去**:一步走完、`StepCompleted` 原子落地之后。顺序不能倒过来——先发后写的话,
doc;在那之前发一条内容为空的事件既没用又会变成一份要兼容的形状。所以 `event_sink` 这个字段 进程崩在两者之间会让观察者看见一步而存储里没有,而事件流的全部安全性建立在「它带的事实在存储
收下了但没有调用点,`RunResult.event_delivery_failures` 恒为 0 里另有一份」上(`0013` 决策一与决策五)
""" """
import asyncio import asyncio
@@ -38,6 +38,8 @@ from polyloop.ports import (
Action, Action,
ActionExecutor, ActionExecutor,
DecisionParser, DecisionParser,
Event,
EventKind,
EventSink, EventSink,
FinalAnswer, FinalAnswer,
InvalidDecision, InvalidDecision,
@@ -234,9 +236,10 @@ def _project_observation(
得见的东西」,而那种东西必须能进参数快照。执行器每次现造一段文本的话,两次运行之间它可以 得见的东西」,而那种东西必须能进参数快照。执行器每次现造一段文本的话,两次运行之间它可以
变而不会有任何地方报错,于是「同一份配置跑出来的两次运行」在模型看来其实不同。 变而不会有任何地方报错,于是「同一份配置跑出来的两次运行」在模型看来其实不同。
代价是执行器知道的细节丢了(「哪个参数不合法」只有它知道)。接受它,因为另一头的代价更 **执行器那段不进历史,但它没有丢**:「一步走完」那条记录落盘的是动作执行接缝的原样返回
大;要补的话将来靠事件流把执行器原文送出去做审计——**进历史的东西必须可复现,进审计的 值,替换只发生在步记录的这一列上。被拒绝那一档下,它是日志里唯一的拒绝说明(「哪个参数
必**。 合法」只有执行器知道),所以不必再靠事件流把它送出去——事件可丢,而这段文本是审计要的
`0013` 决策六)。
""" """
if outcome.status is ActionStatus.NOT_EXECUTED: if outcome.status is ActionStatus.NOT_EXECUTED:
return synthetic.action_rejected, True, 0 return synthetic.action_rejected, True, 0
@@ -256,13 +259,16 @@ class _Driver:
frozen 的,共享它们没有问题。 frozen 的,共享它们没有问题。
""" """
__slots__ = ("_counters", "_definition", "_request", "_steps") __slots__ = ("_counters", "_definition", "_event_failures", "_request", "_steps")
def __init__(self, definition: AgentDefinition, request: RunRequest) -> None: def __init__(self, definition: AgentDefinition, request: RunRequest) -> None:
self._definition = definition self._definition = definition
self._request = request self._request = request
self._counters = RunCounters() self._counters = RunCounters()
self._steps: list[StepRecord] = [] self._steps: list[StepRecord] = []
#: 投递失败的次数。**住在这里而不是出口上**——出口挂在定义上、可以被并发的多次运行
#: 共用,而这个数属于一次运行的结果。
self._event_failures = 0
# -- 写入 --------------------------------------------------------------- # -- 写入 ---------------------------------------------------------------
@@ -286,6 +292,33 @@ class _Driver:
) )
self._steps.append(step) self._steps.append(step)
self._counters = self._counters.with_step_appended() self._counters = self._counters.with_step_appended()
await self._emit_step_finished(step)
async def _emit_step_finished(self, step: StepRecord) -> None:
"""把这一步发给事件出口。**只有走到这里的步才发**,从日志里读回来直接跳过的不发。
补发已经完成的步等于宣称一件早就发生过的事刚刚发生,而接进度表的那一侧会多出一批
重复行。观察者要补全前半段,从存储里读。
**投递失败接住、计数、继续跑**:事件是观察通道不是控制通道,一次运行不该因为进度回写
的数据库连不上就终止。接的是 `Exception` 不是 `BaseException`——`CancelledError` 必须
原样穿过(`CLAUDE.md` §1.6),在这一下把取消吞掉,取消就会晚一整步才生效。
**失败不转成一条事件从同一个出口再发一次**:那会自我喂食,一个持续失败的出口会让失败
处理路径变成递归,而递归的表现是进程卡住或栈溢出,不是一条错误日志。
"""
try:
await self._definition.event_sink.emit(
Event(
kind=EventKind.STEP_FINISHED,
run_id=self._request.run_id,
model_binding=self._request.model_binding,
step=step,
)
)
except Exception:
self._event_failures += 1
logger.exception("运行 %s%d 步的事件投递失败", self._request.run_id, step.step_idx)
async def _finish(self, stop_reason: StopReason, final_answer: str | None = None) -> RunResult: async def _finish(self, stop_reason: StopReason, final_answer: str | None = None) -> RunResult:
"""写结束标记,然后返回结果。 """写结束标记,然后返回结果。
@@ -300,6 +333,7 @@ class _Driver:
stop_reason=stop_reason, stop_reason=stop_reason,
final_answer=final_answer, final_answer=final_answer,
steps=tuple(self._steps), steps=tuple(self._steps),
event_delivery_failures=self._event_failures,
) )
await self._definition.store.write_run_finished( await self._definition.store.write_run_finished(
RunFinished(run_id=self._request.run_id, result=result) RunFinished(run_id=self._request.run_id, result=result)
@@ -418,6 +452,7 @@ class _Driver:
stop_reason=StopReason.CANCELLED, stop_reason=StopReason.CANCELLED,
final_answer=None, final_answer=None,
steps=tuple(self._steps), steps=tuple(self._steps),
event_delivery_failures=self._event_failures,
) )
await self._drain_within_grace( await self._drain_within_grace(
asyncio.ensure_future( asyncio.ensure_future(
+8 -3
View File
@@ -15,7 +15,7 @@ from collections.abc import Mapping
import pytest import pytest
from polyloop.ports import Action, Event, ToolCall from polyloop.ports import Action, Event, EventKind, ToolCall
from polyloop.stores import JsonlRunStore from polyloop.stores import JsonlRunStore
from polyloop.types import ( from polyloop.types import (
ActionOutcome, ActionOutcome,
@@ -155,8 +155,13 @@ class _Records:
tool_call=None if tool_name is None else ToolCall(name=tool_name, arguments={}), tool_call=None if tool_name is None else ToolCall(name=tool_name, arguments={}),
) )
def event(self) -> Event: def event(self, *, run_id: str = "run-1", step_idx: int = 0) -> Event:
return Event() return Event(
kind=EventKind.STEP_FINISHED,
run_id=run_id,
model_binding={"item": "a"},
step=self.step(step_idx=step_idx),
)
@pytest.fixture @pytest.fixture
+19 -23
View File
@@ -3,10 +3,10 @@
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查 两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。 工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
## 写这份文件时撞出来的`design/0006` 还答不上的问题 ## 写这份文件时撞出来的两个问题,`design/0007` 决策一与决策二答了
1. 三个状态取值分别在什么条件下被赋上,从来没有正面写过。 三个状态取值各自在什么条件下被赋上、返回「未执行」时那段观察由谁给。两条的答案都落在
2. 返回「未执行」时,那段观察是执行器给的还是库合成的——两处都有来源,没说以谁为准 **库这一侧**,所以它们的断言不在这份文件里,见文末那两条说明
""" """
import pytest import pytest
@@ -70,32 +70,28 @@ async def test_cancellation_propagates_and_is_not_swallowed(action_executor, rec
await task await task
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True) def test_status_trigger_conditions_are_asserted_against_the_library_not_here():
def test_status_values_have_defined_trigger_conditions(): """三个状态的触发条件(`design/0007` 决策一)验不到这一层,原因在这里。
"""三个状态取值各自在什么条件下被赋上。
`design/0006` 只列了 `EXECUTED` / `NOT_EXECUTED` / `ENV_ERROR` 三个取值,没有正面写过 触发条件是**执行器自己的判断**:动作真的跑过了记 `EXECUTED`(哪怕它报错),没进执行
触发条件。现在只能从 `SyntheticObservations` 那两个字段名反推——工具不存在或参数不合 记 `NOT_EXECUTED`,环境自己坏了记 `ENV_ERROR`。这套件面对的是一个任意实现,没有办
大概是 `NOT_EXECUTED`,环境故障大概是 `ENV_ERROR`——而「大概」不能写成断言。 逼它进入后两档——拿一个「几乎不可能存在的工具名」去探,会把 dissect 那种动作语言里
根本没有工具名、状态恒为 `EXECUTED` 的合法实现判成不合格。
这条不定下来,两个下游会各自理解一套,而两套都不报错:dissect 的执行器状态恒为 库这一侧的连带后果是能验的,也验了:`ENV_ERROR` 必然导致 `StopReason.ENV_ERROR`、
`EXECUTED`,它撞不到这个分歧;GovDoc 撞得到,但表现是停止原因的分布变了,不是异常 `NOT_EXECUTED` 不终止运行,两条在 `tests/unit/test_session.py` 里
还有一处连带的:`ActionStatus.ENV_ERROR` 与 `StopReason.ENV_ERROR` 同名不同类型,前者 这条留成一个不断言的说明,是为了让下一个想在这儿补断言的人先看到上面那段。
出现是不是必然导致后者,也没写。
""" """
pytest.fail("三个 ActionStatus 取值的触发条件没有定义")
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True) def test_the_observation_substitution_is_asserted_in_the_library_not_here():
def test_who_supplies_the_observation_when_the_action_is_rejected(): """动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
"""动作被拒绝时,那段观察是执行器给的还是库合成的。
两处都有来源:执行器的返回值里有 `observation` 字段,而定义上又挂着 执行器照常填自己的 `observation`——它不该知道库会不会采用,也不必知道:那段文本仍然
`SyntheticObservations.action_rejected`。`design/0006` 没说以谁为准。 随「一步走完」记录原样落盘,被拒绝那一档下它是日志里唯一的拒绝说明
`design/0013` 决策六)。库只是不让它进历史,因为模型看得见的东西必须能进参数快照。
这不是风格问题。如果以库为准,执行器填的那段就被丢掉,而它可能带着「哪个参数不合法」 「库替换了它」是整次运行的行为,断言在 `tests/unit/test_session.py`,不在这个接缝的
这种只有执行器知道的信息;如果以执行器为准,那 `SyntheticObservations` 那个字段永远 契约里。
用不上,它就是个死字段。而 `observation_is_synthetic` 该填什么,取决于这个答案。
""" """
pytest.fail("动作被拒绝时观察的来源没有定义")
+11 -8
View File
@@ -3,13 +3,15 @@
两个已知形态:一个从代码围栏里抽 Python 源码,一个从 JSON 里抽工具名与参数。库不带任何 两个已知形态:一个从代码围栏里抽 Python 源码,一个从 JSON 里抽工具名与参数。库不带任何
默认实现——带了就等于替某一家定了动作语言。 默认实现——带了就等于替某一家定了动作语言。
## 写这份文件时撞出来的`design/0006` 还答不上的问题 ## 写这份文件时撞出来的那个问题,`design/0007` 决策三答了
模型输出完全无法解释时,解释器返回「无效决策」还是抛异常。 模型输出完全无法解释时,解释器返回「无效决策」,不抛异常。下面最后一条断言它。
""" """
import pytest import pytest
from polyloop.ports import InvalidDecision
pytestmark = pytest.mark.contract pytestmark = pytest.mark.contract
@@ -67,15 +69,16 @@ def test_action_carries_its_trace_form(decision_parser, samples):
assert isinstance(parsed.decision.text, str) assert isinstance(parsed.decision.text, str)
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True) def test_unparseable_output_returns_invalid_decision_rather_than_raising(decision_parser, samples):
def test_unparseable_output_returns_invalid_decision_rather_than_raising(): """模型输出完全无法解释时返回「无效决策」,不抛异常(`design/0007` 决策三)。
"""模型输出完全无法解释时,解释器返回「无效决策」还是抛异常。
`design/0006` 定了三个分支——动作、最终回答、无效决策——但没说「解释器可以抛异常吗」。
两条路后果完全不同:返回无效决策,那一步照常留痕、说明文本回喂给模型、循环继续;抛 两条路后果完全不同:返回无效决策,那一步照常留痕、说明文本回喂给模型、循环继续;抛
异常,库要么把它翻译成某个停止原因终止整次运行,要么让它穿出去炸掉调用方。 异常,库要么把它翻译成某个停止原因终止整次运行,要么让它穿出去炸掉调用方。
dissect 的解析器不抛异常,所以它撞不到这个分歧。但契约测试是**任何新适配器的准入 dissect 的解析器不抛异常,所以它撞不到这个分歧。但契约测试是**任何新适配器的准入
标准**一个会抛异常的实现照现在的契约既不算违规也不算合规 标准**所以这条要正面断言,不能靠「反正没人这么写」
""" """
pytest.fail("解释器能不能抛异常、抛了怎么办,没有定义") parsed = decision_parser.parse(samples.yields_invalid)
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation != ""
+17 -18
View File
@@ -3,10 +3,11 @@
两个已知形态差别在可靠性要求上:一个把进度逐步回写业务数据库供前端轮询(要求低延迟、 两个已知形态差别在可靠性要求上:一个把进度逐步回写业务数据库供前端轮询(要求低延迟、
可以丢),一个把审计事件送进日志管道(要求不丢、可以慢)。 可以丢),一个把审计事件送进日志管道(要求不丢、可以慢)。
## 写这份文件时撞出来的`design/0006` 还答不上的问题 ## 写这份文件时撞出来的问题,`design/0013` 答了
`Event` 只有一个名字没有字段,所以这个接缝的契约现在只能验「投递失败不打断循环」这一半 「发出去的事件里有什么」当时验不了,因为 `Event` 只有一个名字没有字段。现在事件集定下来了
验不了「发出去的事件里有什么」。 而答案把这份文件里的两条测试都挪走了——它们要断言的行为都在库那一侧,不在出口这一侧,见文末
那两条说明。
""" """
import pytest import pytest
@@ -19,7 +20,7 @@ async def test_emit_accepts_an_event(event_sink, records):
await event_sink.emit(records.event()) await event_sink.emit(records.event())
def test_a_sink_is_allowed_to_raise_on_delivery_failure(): def test_a_raising_sink_is_compliant_so_this_layer_asserts_nothing():
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。** """**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
契约写的是「投递失败由**库**捕获、记日志、把失败计数加一,然后继续跑」,所以要断言的 契约写的是「投递失败由**库**捕获、记日志、把失败计数加一,然后继续跑」,所以要断言的
@@ -32,28 +33,26 @@ def test_a_sink_is_allowed_to_raise_on_delivery_failure():
""" """
@pytest.mark.xfail(reason="事件集还没定,见 docstring", strict=True) def test_the_no_re_emission_guarantee_is_asserted_in_the_library_not_here():
def test_failure_is_not_re_emitted_through_the_same_sink(): """投递失败不再转成一条事件从同一个出口发出去(`design/0013` 决策七)。
"""投递失败不再转成一条事件从同一个出口发出去。
那会自我喂食:一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或 那会自我喂食:一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或
栈溢出,不是一条错误日志。 栈溢出,不是一条错误日志。
**这条现在验不了**,因为验它要求能识别「这是一条失败事件」,而 `Event` 还没有字段—— **要断言的是库有没有再发一次,那是整次运行的行为**,所以断言在
`design/0006` 里它只有一个名字。方向已经定了(观察走事件流、干预走具名回调),但事件 `tests/unit/test_session.py` 里——那边用一个恒抛异常的出口跑完一次运行,验出口收到的
集与回调清单要独立成一份 design doc,这条要等到那时候 条数恰好等于步数。这个接缝自己看不到「库发了几次」
""" """
pytest.fail("Event 还没有字段,识别不了「失败事件」")
@pytest.mark.xfail(reason="事件集还没定,见 docstring", strict=True) def test_the_audit_trail_is_asserted_against_the_log_not_here():
def test_audit_events_carry_both_raw_and_repaired_model_output(): """审计纪律由存储承担,不由事件流承担(`design/0013` 决策二)。
"""审计事件要同时带模型原文与修复之后的结果。
GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。 GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。
它现有的审计出口是一个「发一条带类型和载荷的事件」的接口,迁移之后这条纪律要由事件流 这条测试原来断言「事件要同时带原文与修复后的文本」,而那个前提是错的——事件流可丢,
承载——能不能承载,取决于事件里带不带这两样 一件只存在于可丢通道里的事实撑不起「禁止静默修复」
这是 `../research-wiki/migrations/govdoc-saas.md` 缺口登记里那一条,同样等事件集定下来。 两份文本在意图日志里各有位置:原文在模型调用结果记录的回复里,修复后的那份是步记录的
`raw_output`。断言落在 `tests/unit/test_session.py`,因为要跑完一次完整运行再把日志读
回来,而这个接缝的契约只看得见一个出口实现。
""" """
pytest.fail("Event 还没有字段,承载不了审计纪律")
+421
View File
@@ -0,0 +1,421 @@
"""打真实模型网关的那一层:一次运行真的从模型走到工具再走回来。
**这一层叫 e2e,因为它连的是真实模型网关**`CLAUDE.md` §1.9 的分层判据是「依赖什么」)。
它会产生真实的模型调用与真实的费用,所以它是唯一一层默认不跑的测试——`pyproject.toml` 的
`addopts` 里有 `-m 'not e2e'`,`make ci` 因此跑不到这里。要跑它得显式写 `pytest -m e2e`。
**两道跳过闸,缺一不可。** 第一道是网关装没装(没装 `polyloop[gateway]` 就整份文件跳过);
第二道是 `POLYLOOP_E2E` 这个开关等不等于 `"1"`。分成两件事是因为**填好密钥不等于同意花钱**:
只看密钥的话,任何人配好 `.env` 之后随手跑一次全套测试就会打出去一串真实调用并产生真实账单,
而他本来只是想看看测试过不过。开关的读法与网关一致——先读当前工作目录下的 `.env`,再让环境
变量覆盖它,所以临时开一次不必改文件。
**这里自带三样真实实现:决策解释器、工具注册表、事件出口。** 库故意不带它们(带了就等于替
某一家定了动作语言),而没有它们循环就走不起来。它们住在测试里,不是库的一部分。
**断言只绑结构不变量,一条都不绑模型输出的文字内容。** 模型是不确定的,绑内容的测试会随机
红,而随机红的测试很快就会被所有人忽略,然后这一层就不再拦得住任何东西。
"""
import asyncio
import json
import os
import re
from collections.abc import Mapping
import pytest
polygateway = pytest.importorskip(
"polygateway", reason="没装 polyloop[gateway],打真实网关这一层跳过"
)
from dotenv import dotenv_values # noqa: E402
from polygateway import GatewayClient, GatewaySettings # noqa: E402
from polyloop.adapters import GatewayModelClient # noqa: E402
from polyloop.ports import ( # noqa: E402
Action,
Event,
FinalAnswer,
InvalidDecision,
ModelCall,
ParsedReply,
ToolCall,
)
from polyloop.session import AgentDefinition, RunRequest, run # noqa: E402
from polyloop.stores import JsonlRunStore # noqa: E402
from polyloop.tools import ToolRegistry, ToolSpec # noqa: E402
from polyloop.types import ( # noqa: E402
ActionStatus,
Budget,
Context,
Message,
ModelReply,
ReplayPolicy,
Role,
StopReason,
SyntheticObservations,
TextBlock,
)
pytestmark = pytest.mark.e2e
#: 开关的读法与网关一致:`.env` 在下、环境变量在上。两份都读是因为密钥本来就在 `.env` 里,
#: 而临时开一次 e2e 不该逼人去改那个文件。
_ENV = {**dotenv_values(".env"), **os.environ}
if _ENV.get("POLYLOOP_E2E") != "1":
pytest.skip(
"POLYLOOP_E2E 不是 1:这一层会打真实模型网关并产生真实费用,默认不跑",
allow_module_level=True,
)
# ---------------------------------------------------------------------------
# 动作协议:模型每一轮只输出一个 JSON 对象
# ---------------------------------------------------------------------------
#: 讲给模型听的那份协议。**写得极其明确并给一个例子**——模型只有照这个格式输出,循环才走得
#: 下去。指望它猜的话,第一轮就会得到一段散文,然后这次运行以连续解析失败收尾,而那个红叉
#: 看起来像是库坏了。
_PROTOCOL = """你在一个自动循环里工作。每一轮你**只能输出一个 JSON 对象**,前后不许有任何别的文字、说明或标点。
要调用工具,输出:
{"tool": "工具名", "args": {"参数名": 参数值}}
要给出最终回答,输出:
{"final": "你的回答"}
可用的工具只有两个:
- add:把两个数相加。参数 a 和 b 都是数字。
- submit:提交结果。参数 answer 是一个字符串。调用它就表示这次工作做完了。
例子——要算 3 加 4,你这一轮就输出:
{"tool": "add", "args": {"a": 3, "b": 4}}
每一轮之后你会收到一条以「观察:」开头的消息,那是上一次工具调用返回的内容。"""
_GOAL = "请先用 add 算出 17 加 25,拿到结果之后用 submit 把那个结果提交上去。"
_OBSERVATION_TEMPLATE = "观察:{observation}"
#: 代码围栏。模型很常把 JSON 包在 ```json ... ``` 里,剥不掉的话每一轮都会解析失败。
_FENCE = re.compile(r"```[A-Za-z0-9_+-]*\n(?P<body>.*?)```", re.DOTALL)
def _json_payload(text: str) -> str:
"""把模型这一轮的输出削到只剩那个 JSON 对象。
两步都是必要的:先剥围栏,再取最外层花括号之间的那一段。只剥围栏的话,模型在 JSON 前后
写一句「好的,我来算一下」就解析不了;只取花括号的话,围栏里带语言标签的那种输出会把
```json 一起吃进去。围栏没有闭合时第一步不匹配,第二步照样能把 JSON 捞出来。
"""
body = text.strip()
fenced = _FENCE.search(body)
if fenced is not None:
body = fenced.group("body")
start = body.find("{")
end = body.rfind("}")
if start != -1 and end > start:
body = body[start : end + 1]
return body.strip()
class _JsonDecisionParser:
"""按上面那份协议解释一次模型回复。
**对任何输入都返回 `ParsedReply`,绝不抛异常**`design/0007` 决策三)。解释不出来是正常
路径的一部分——模型输出不合格式是每天都在发生的事,而抛异常会让库去替它编一个停止原因,
于是「解释器有 bug」被伪装成「这次运行以某某原因结束」,然后进下游的统计。
每一种失败给一条**对症**的说明,因为那段文本就是回喂给模型的观察。压成一句「格式错误」
的话,模型不知道自己错在哪,下一轮多半照错一遍。
"""
def parse(self, reply: ModelReply) -> ParsedReply:
payload = _json_payload(reply.content)
try:
decoded = json.loads(payload)
except json.JSONDecodeError as exc:
return self._invalid(
reply,
f"这一轮的输出不是一个 JSON 对象({exc.msg})。"
'只输出一个 JSON 对象,形如 {"tool": "add", "args": {"a": 1, "b": 2}},前后不要有别的文字。',
)
if not isinstance(decoded, dict):
return self._invalid(
reply,
f"解出来的是 {type(decoded).__name__} 而不是一个 JSON 对象。"
'只输出一个 JSON 对象,形如 {"tool": "add", "args": {"a": 1, "b": 2}}。',
)
if "final" in decoded:
return ParsedReply(
history_text=reply.content, decision=FinalAnswer(text=str(decoded["final"]))
)
if "tool" not in decoded:
return self._invalid(
reply,
'这个 JSON 对象里既没有 "tool" 也没有 "final"。调工具用 '
'{"tool": ..., "args": {...}},给最终回答用 {"final": "..."}。',
)
name = decoded["tool"]
if not isinstance(name, str):
return self._invalid(reply, '"tool" 必须是一个字符串,也就是工具的名字。')
arguments = decoded.get("args", {})
if not isinstance(arguments, dict):
return self._invalid(
reply, '"args" 必须是一个 JSON 对象,键是参数名,例如 {"a": 1, "b": 2}。'
)
return ParsedReply(
history_text=reply.content,
decision=Action(
text=f"{name}({json.dumps(arguments, ensure_ascii=False, sort_keys=True)})",
tool_call=ToolCall(name=name, arguments=arguments),
),
)
def parameters(self) -> Mapping[str, str]:
return {"kind": "json-tool-or-final"}
@staticmethod
def _invalid(reply: ModelReply, explanation: str) -> ParsedReply:
return ParsedReply(
history_text=reply.content, decision=InvalidDecision(explanation=explanation)
)
# ---------------------------------------------------------------------------
# 工具:纯计算,不碰文件系统、网络、子进程
# ---------------------------------------------------------------------------
async def _add(arguments: Mapping[str, object]) -> str:
"""两数相加。
注册表已经按 schema 校验过类型,这里仍然自己判一次:校验的是 JSON Schema 的一个子集,
而一个 `TypeError` 从这里抛出去会被判成「已执行」加一条正常观察,模型看不出该怎么改。
"""
a, b = arguments.get("a"), arguments.get("b")
if not isinstance(a, int | float) or not isinstance(b, int | float):
return "a 和 b 都必须是数字。"
return str(a + b)
async def _submit(arguments: Mapping[str, object]) -> str:
return f"收到:{arguments.get('answer')}"
def _registry() -> ToolRegistry:
"""本次运行可见的两个工具。
`submit` 带完成标记,这样运行有一条确定的收尾路径——没有它的话,这次运行只能靠模型自己
给最终回答或者撞上步数上限收尾,而那两条路一条不确定、一条要多花几次调用。
"""
return ToolRegistry(
(
ToolSpec(
name="add",
description="把两个数相加,返回它们的和。",
parameters={
"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"],
"additionalProperties": False,
},
# 纯计算,重复算一次无害。
replay_policy=ReplayPolicy.SAFE,
handler=_add,
),
ToolSpec(
name="submit",
description="提交最终结果。调用它就表示这次工作做完了。",
parameters={
"type": "object",
# 允许数字:模型很常把算出来的数原样填进来,只认字符串的话那次调用会被
# 判成参数不合法,白花一次调用去纠正一个与本层无关的形式问题。
"properties": {"answer": {"type": ["string", "number"]}},
"required": ["answer"],
"additionalProperties": False,
},
completes_run=True,
handler=_submit,
),
)
)
# ---------------------------------------------------------------------------
# 事件出口
# ---------------------------------------------------------------------------
class _RecordingEventSink:
"""把收到的事件记进列表。一次运行一个实例,不跨运行复用。"""
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": "recording"}
# ---------------------------------------------------------------------------
# 装配
# ---------------------------------------------------------------------------
@pytest.fixture
async def model_client():
"""一个连着真实网关的模型客户端。
客户端持有连接池,用完必须 `aclose()`——不关的话每个用例漏一份连接池,而表现只是事件
循环关闭时的一串告警。
"""
client = GatewayClient.from_env()
try:
yield GatewayModelClient(client=client, settings=GatewaySettings.from_env())
finally:
await client.aclose()
def _text(role: Role, text: str) -> Message:
return Message(role=role, content=(TextBlock(text=text),))
def _definition(model_client, store: JsonlRunStore, sink: _RecordingEventSink) -> AgentDefinition:
return AgentDefinition(
model_client=model_client,
decision_parser=_JsonDecisionParser(),
store=store,
event_sink=sink,
synthetic_observations=SyntheticObservations(
action_rejected="这次工具调用没有执行:工具名或参数不合法。改过之后重新输出一个 JSON 对象。",
env_failed="环境出错了,这次工具调用没有产生结果。",
model_call_failed="上一次模型调用失败了。",
),
)
def _request(run_id: str) -> RunRequest:
"""一次运行的装配。
`max_steps` 取 3:这次运行正常走完是两步(算一次、提交一次),留一步的余量给模型偶尔多说
一轮。**上限压得这么低是为了控制费用**——这一层每跑一次都在花钱,而它要证明的事(链路通不通)
两步就证明完了。
"""
registry = _registry()
return RunRequest(
run_id=run_id,
budget=Budget(
max_steps=3,
max_actions=3,
max_consecutive_parse_failures=2,
max_prompt_chars=20_000,
),
action_executor=registry.executor(),
tools=registry,
context=Context(
run_level=(_text(Role.SYSTEM, _PROTOCOL),),
goal_level=(_text(Role.USER, _GOAL),),
),
injections={},
model_binding={},
model_replay_policy=ReplayPolicy.NEVER,
observation_template=_OBSERVATION_TEMPLATE,
cancel_grace_seconds=5.0,
)
# ---------------------------------------------------------------------------
# 用例一:完整闭环
# ---------------------------------------------------------------------------
#: 这次运行走完之后可以落在的两个停止原因。
#:
#: 两个都算正常终态,因为两者都意味着**循环自己走到了头**`TASK_COMPLETED` 是模型调了带完成
#: 标记的工具,`AGENT_FINISHED` 是模型算完之后直接给了最终回答。只写前一个的话,模型选了后
#: 一条同样合法的路,这条测试就会红。预算耗尽、连续解析失败、模型调用失败都不在里面——那些
#: 是循环没走通。
_CLOSED_LOOP_STOP_REASONS = (StopReason.TASK_COMPLETED, StopReason.AGENT_FINISHED)
async def test_a_real_run_goes_from_model_through_a_tool_and_back(model_client, tmp_path) -> None:
"""一次运行走通「模型 → 解释 → 工具执行 → 观察回填 → 再问模型 → 收尾」。"""
store = JsonlRunStore(directory=tmp_path)
sink = _RecordingEventSink()
request = _request("e2e-closed-loop")
result = await run(_definition(model_client, store, sink), request)
assert result.stop_reason in _CLOSED_LOOP_STOP_REASONS
# 只走一次模型调用不算闭环:那种运行证明的只是「请求发得出去」,证明不了观察回填之后模型
# 还能接着往下走。
assert len(result.steps) >= 2
assert any(step.action_status is ActionStatus.EXECUTED for step in result.steps)
for step in result.steps:
# 调用标识是「真的打出去过」的硬证据:它由网关那边生成,替身给不出来。
assert step.call_id, f"{step.step_idx} 步没有调用标识"
assert step.prompt_chars > 0, f"{step.step_idx} 步的提示词规模是 0"
assert step.step_wall_ms > 0, f"{step.step_idx} 步的墙钟是 0"
# 往返等价:日志读回来的步序列与返回值逐字段相等。不等的话,下游拿轨迹做的分析和拿返回值
# 做的分析会得出不同的结论,而两边都自称是这次运行。
log = await store.read_log(request.run_id)
assert tuple(entry.step for entry in log.steps) == result.steps
assert log.finished is not None
assert log.finished.result.stop_reason is result.stop_reason
assert len(sink.events) == len(result.steps)
# ---------------------------------------------------------------------------
# 用例二:取消穿透
# ---------------------------------------------------------------------------
class _EntryAnnouncingClient:
"""转发给真客户端,并在进入那次调用时打一个信号。
取消要落在**真实的 HTTP 请求途中**才验得到东西。靠 `sleep` 猜时机的话,慢一点就落在解释
或工具执行上、快一点就落在调用发出之前,而两种落空都表现成一条绿的测试。这个信号把时机
收成确定的:它一亮,下一个 await 就是那次真实请求。
"""
def __init__(self, inner) -> None:
self._inner = inner
self.entered = asyncio.Event()
async def call(self, call: ModelCall) -> ModelReply:
self.entered.set()
return await self._inner.call(call)
def parameters(self) -> Mapping[str, str]:
return self._inner.parameters()
async def test_cancellation_passes_through_a_real_model_call(model_client, tmp_path) -> None:
"""取消能穿过真实的网络调用,并且留下一条以「已取消」收尾的结束记录。
结束记录是这条测试的另一半:没有它,恢复读到的是一次没有结束标记的运行,会被当成可以
续跑——而它其实是被人主动叫停的。
"""
store = JsonlRunStore(directory=tmp_path)
sink = _RecordingEventSink()
request = _request("e2e-cancelled")
client = _EntryAnnouncingClient(model_client)
task = asyncio.create_task(run(_definition(client, store, sink), request))
await asyncio.wait_for(client.entered.wait(), timeout=30)
# 信号亮起时那次请求还没被 await。让出一小会儿,取消就确实落在请求飞在网上的那段。
await asyncio.sleep(0.5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
log = await store.read_log(request.run_id)
assert log.finished is not None, "取消之后没有结束记录,这次运行看起来还能续跑"
assert log.finished.result.stop_reason is StopReason.CANCELLED
+232 -2
View File
@@ -14,6 +14,7 @@ import pytest
from polyloop._recovery import CorruptLogError from polyloop._recovery import CorruptLogError
from polyloop.ports import ( from polyloop.ports import (
Action, Action,
EventKind,
FinalAnswer, FinalAnswer,
InvalidDecision, InvalidDecision,
ModelCall, ModelCall,
@@ -46,6 +47,7 @@ from polyloop.types import (
RunFinished, RunFinished,
RunStarted, RunStarted,
StepCompleted, StepCompleted,
StepRecord,
StopReason, StopReason,
SyntheticObservations, SyntheticObservations,
TextBlock, TextBlock,
@@ -191,12 +193,14 @@ def _outcome(
) )
def _definition(store: FakeStore, model: FakeModel, parser: FakeParser) -> AgentDefinition: def _definition(
store: FakeStore, model: FakeModel, parser: FakeParser, sink: object | None = None
) -> AgentDefinition:
return AgentDefinition( return AgentDefinition(
model_client=model, model_client=model,
decision_parser=parser, decision_parser=parser,
store=store, store=store,
event_sink=FakeSink(), event_sink=sink or FakeSink(), # type: ignore[arg-type]
synthetic_observations=SYNTHETIC, synthetic_observations=SYNTHETIC,
) )
@@ -1030,3 +1034,229 @@ def test_an_empty_call_id_is_refused_at_construction() -> None:
"""空串是个看起来合法的键,连表时静默匹配不上,而 None 至少能被显式筛出来。""" """空串是个看起来合法的键,连表时静默匹配不上,而 None 至少能被显式筛出来。"""
with pytest.raises(ValueError, match="call_id"): with pytest.raises(ValueError, match="call_id"):
ModelReply(call_id="", content="hi", thinking="") ModelReply(call_id="", content="hi", thinking="")
# ---------------------------------------------------------------------------
# 事件出口
# ---------------------------------------------------------------------------
class _RaisingSink(FakeSink):
"""按脚本抛异常的出口。抛完仍然把这条记下来,好断言「库有没有再发一次」。"""
def __init__(self, error: BaseException) -> None:
super().__init__()
self._error = error
async def emit(self, event: object) -> None:
self.events.append(event)
raise self._error
async def test_every_step_emits_one_event_carrying_the_whole_record() -> None:
"""一步一条,带的是整条步记录而不是挑几个字段拼的摘要。
摘要是一次投影,而投影会漂移——步记录加一个字段,带整条的话事件里自动就有。
"""
store = FakeStore()
sink = FakeSink()
model = FakeModel([_reply("go"), _reply("go")])
definition = _definition(store, model, FakeParser({"go": ACT}), sink)
result = await run(definition, _request(FakeExecutor([_outcome(), _outcome(completed=True)])))
assert [event.kind for event in sink.events] == [EventKind.STEP_FINISHED] * 2
assert [event.step for event in sink.events] == list(result.steps)
async def test_the_event_carries_the_run_id_and_the_project_binding() -> None:
"""运行标识让共用同一个出口的并发运行分得开;绑定没法从运行标识倒推。"""
store = FakeStore()
sink = FakeSink()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
await run(
definition,
_request(FakeExecutor([_outcome(completed=True)]), binding={"item": "a", "task": "t7"}),
)
(event,) = sink.events
assert event.run_id == "run-1"
assert event.model_binding == {"item": "a", "task": "t7"}
async def test_the_event_goes_out_after_the_step_landed_not_before() -> None:
"""先发后写的话,进程崩在两者之间会让观察者看见一步而存储里没有。
事件流的全部安全性建立在「它带的事实在存储里另有一份」上,而这个顺序是那条不变量在
崩溃点上的兑现方式。
"""
store = FakeStore()
seen_at_emit: list[int] = []
class _OrderSink(FakeSink):
async def emit(self, event: object) -> None:
seen_at_emit.append(len(store.of_type(StepCompleted)))
await super().emit(event)
definition = _definition(
store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), _OrderSink()
)
await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
assert seen_at_emit == [1]
async def test_a_failing_sink_does_not_stop_the_run_and_is_counted() -> None:
"""事件是观察通道不是控制通道:进度回写的数据库连不上,运行照跑完。
计数放在返回值上而不是只记日志,因为日志没人看。
"""
store = FakeStore()
sink = _RaisingSink(ConnectionError("进度库连不上"))
model = FakeModel([_reply("go"), _reply("go")])
definition = _definition(store, model, FakeParser({"go": ACT}), sink)
result = await run(definition, _request(FakeExecutor([_outcome(), _outcome(completed=True)])))
assert result.stop_reason is StopReason.TASK_COMPLETED
assert len(result.steps) == 2
assert result.event_delivery_failures == 2
async def test_a_delivery_failure_is_not_re_emitted_through_the_same_sink() -> None:
"""失败不转成一条事件从同一个出口再发一次——那会自我喂食。
一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或栈溢出,不是一条
错误日志。所以出口收到的条数必须恰好等于步数。
"""
store = FakeStore()
sink = _RaisingSink(ConnectionError("一直连不上"))
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
result = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
assert len(sink.events) == len(result.steps) == 1
async def test_cancellation_during_delivery_is_not_swallowed() -> None:
"""接的是 `Exception` 不是 `BaseException`:在这一下吞掉取消,取消会晚一整步才生效。"""
store = FakeStore()
sink = _RaisingSink(asyncio.CancelledError())
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
with pytest.raises(asyncio.CancelledError):
await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
finished = store.of_type(RunFinished)
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
assert finished[0].result.event_delivery_failures == 0 # type: ignore[attr-defined]
async def test_steps_read_back_from_the_log_are_not_re_emitted() -> None:
"""续跑不给已经完成的步补发事件。
补发等于宣称一件早就发生过的事刚刚发生,而接进度表的那一侧会多出一批重复行。判据是这次
进程里有没有真的执行过,观察者要补全前半段就从存储里读。
"""
sink = FakeSink()
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
request = _request(FakeExecutor([_outcome(completed=True)]))
store._log = _log_with_one_finished_step( # noqa: SLF001
{**definition.parameter_snapshot(), **request.parameter_snapshot()}
)
result = await resume(definition, request)
# 第 0 步是从日志里读回来的,第 1 步是这次进程里真的走的。只有后者发了事件。
assert [step.step_idx for step in result.steps] == [0, 1]
assert [event.step.step_idx for event in sink.events] == [1]
def _log_with_one_finished_step(snapshot: Mapping[str, str]) -> RunLog:
"""一份「第 0 步完整走完、还没写结束记录」的日志。
手工搭而不是先跑一次再续跑:跑出来的那一步要么带完成信号(续跑会当场收尾,走不到第二步),
要么撞预算上限(续跑在预算准入那一档就停了),两种都验不到「读回来的不发、真跑的发」这条
边界。
"""
outcome = _outcome()
return RunLog(
started=RunStarted(run_id="run-1", parameter_snapshot=snapshot),
intents=(
Intent(
run_id="run-1",
kind=IntentKind.MODEL_CALL,
call_index=0,
result_id="run-1#model#0",
replay_policy=ReplayPolicy.NEVER,
),
Intent(
run_id="run-1",
kind=IntentKind.ACTION,
call_index=0,
result_id="run-1#action#0",
replay_policy=ReplayPolicy.NEVER,
),
),
model_results=(
ModelCallResult(
run_id="run-1", result_id="run-1#model#0", reply=_reply("go"), failure=None
),
),
steps=(
StepCompleted(
run_id="run-1",
result_id="run-1#action#0",
action_outcome=outcome,
step=StepRecord(
step_idx=0,
raw_output="go",
content_chars=2,
thinking_chars=0,
action="做点事",
parse_ok=True,
parse_error=None,
observation=outcome.observation,
observation_is_synthetic=False,
observation_truncated_chars=0,
prompt_chars=10,
call_id="c1",
step_wall_ms=1,
action_status=ActionStatus.EXECUTED,
env_reported_completion=False,
),
),
),
)
async def test_the_log_keeps_both_the_raw_and_the_repaired_model_output() -> None:
"""模型原文与解释器改写之后的文本各有位置,两份都不可丢(`design/0013` 决策二)。
GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。
承载它的是意图日志而不是事件流——事件可丢,一件只存在于可丢通道里的事实撑不起「禁止
静默修复」。
两份文本天然分开存,是写入序列决定的:模型调用结算时写结果记录,那时还没解释;解释完、
动作走完之后才写步记录,那里面的文本是解释器交回来的。
"""
class _RewritingParser(FakeParser):
"""把第一个代码围栏之后的内容整段丢掉——模型常在代码块后面编造执行结果。"""
def parse(self, reply: ModelReply) -> ParsedReply:
return ParsedReply(history_text=reply.content.split("|", 1)[0], decision=ACT)
store = FakeStore()
definition = _definition(
store, FakeModel([_reply("真动作|模型编的执行结果")]), _RewritingParser({})
)
result = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
(call_result,) = store.of_type(ModelCallResult)
assert call_result.reply.content == "真动作|模型编的执行结果" # type: ignore[attr-defined,union-attr]
assert result.steps[0].raw_output == "真动作"