Files
PolyLoop/tests/e2e/test_real_gateway_run.py
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

422 lines
18 KiB
Python
Raw Permalink 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.
"""打真实模型网关的那一层:一次运行真的从模型走到工具再走回来。
**这一层叫 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