Files
PolyLoop/tests/contract/test_action_executor.py
T
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

98 lines
4.8 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` 决策一与决策二答了
三个状态取值各自在什么条件下被赋上、返回「未执行」时那段观察由谁给。两条的答案都落在
**库这一侧**,所以它们的断言不在这份文件里,见文末那两条说明。
"""
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_who_supplies_the_observation_when_the_action_is_rejected():
"""动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
执行器照常填自己的 `observation`——它不该知道库会不会采用,也不必知道:那段文本仍然
随「一步走完」记录原样落盘,被拒绝那一档下它是日志里唯一的拒绝说明
`design/0013` 决策六)。库只是不让它进历史,因为模型看得见的东西必须能进参数快照。
「库替换了它」是整次运行的行为,断言在 `tests/unit/test_session.py`,不在这个接缝的
契约里。
"""