Files
PolyLoop/src/polyloop/testing/_action_executor.py
T
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

196 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""动作执行接缝的行为契约。
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
## 写这套用例时撞出来的两个问题,`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 就是合规的,"
"「库替换了它」由库自己的测试守着。"
)