"""打真实模型网关的那一层:一次运行真的从模型走到工具再走回来。 **这一层叫 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.*?)```", 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