Files
PolyLoop/tools/soak/tests/test_appworld_scenario.py
iomgaa c4e5732587 docs: 回写全仓库对契约套件的指向,以及 README、架构与 CHANGELOG
套件从 tests/contract/ 搬进 polyloop.testing 之后,全仓库 28 处引用要重新指过。修了 12 处,
其余在 design/(只增不改)与 scratch/(由人清理)里。

**CLAUDE.md 改了四处事实**:§0 权威表里行为契约的权威、§0 那句依赖规则的条数、§5 目录树与
模块数、§1.8 那句「谁断言公共 Protocol 的签名」。§1 的其余硬约束与 §2 的人类门一条没动。

**architecture.md**:分层图第 4 层加一格,装配层从三个变四个;代码地图加一行;第十节按代码
逐项重写——那笔「工具段渲染样式」的欠账**没有被数字对上盖掉**,加了 fingerprints 之后请求
的字段数恰好还是十一,而组成已经换过,所以那一节正面写着它仍然欠着;新增第十条依赖规则
(pytest 只在 testing 那个 extra 里,别处 import 它会让下游的生产环境一 import 本库就
ModuleNotFoundError),带静态与运行时两半;删掉「src/ 下一行代码都没有」那段过期状态说明;
决策索引补齐 0008 到 0016,其中四行原描述说的不是那份文档真正定的东西。

**migrations/dissect.md** 那笔「内存实现不存在」的欠账还掉了。

**压测那边**三条测试守的是一条已经撤销的公共契约,改名并写清它们现在守的是场景自己的选择。
AppWorld 那处刻意的偏离(不补三个反引号)留着不恢复——那条路径要模型输出被 stop 序列截断才
触发,而压测不配 stop 序列,恢复的收益不抵重跑一次压测的成本。但注释的理由改对了:它现在是
一笔有出处的欠账,不是一个决定。

CHANGELOG 攒在「未发布」段,版本号不提前写(§1.10)。
2026-08-27 03:59:39 -04:00

576 lines
22 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.
"""AppWorld 场景适配器的测试。
分三块:解析器、提示词装配、动作执行器。
解析器那块的前五条照着 `polyloop.testing.DecisionParserContract` 那份公共契约写。契约套件
本身是给下游继承基类、在自己的子类里覆盖必需 fixture 用的,压测这边不接那套装配、只把五条
断言照着写一遍——它是任何新适配器的准入标准,压测的适配器也是适配器。其中一条另外多守了一件
公共契约没要求的事:`history_text` 不长于模型原文。公共契约不断言长度(解析器有权改写那段
文本,改写既可能截短也可能补写),那条断言守的是压测这一侧自己的选择——解析器一律只截不补。
执行器那块用一个假会话,不起容器:这里要验的是「环境返回什么 → 结果对象怎么填」这个映射,
而那个映射与容器里发生了什么无关。真起容器的那条链路由 `tools/soak/check_appworld.py` 走。
"""
from __future__ import annotations
import asyncio
import pytest
from polyloop.ports import Action, InvalidDecision
from polyloop.types import ActionStatus, ModelReply, ReplayPolicy, Role
from tools.soak.appworld import AppWorldError
from tools.soak.scenarios.appworld import (
OBSERVATION_TEMPLATE,
AppWorldExecutor,
AppWorldParser,
AppWorldScenarioError,
build_budget,
build_context,
build_run_request,
build_synthetic_observations,
render_to_messages,
split_by_role,
)
def _reply(content: str) -> ModelReply:
return ModelReply(call_id="call-1", content=content, thinking="")
#: 一条正常的、能解析出动作的回复。
_ACTION_REPLY = _reply(
"先看看有哪些 app。\n\n```python\nprint(apis.api_docs.show_app_descriptions())\n```\n"
)
#: 一条解析不出任何东西的回复。
_INVALID_REPLY = _reply("我觉得应该先登录 spotify,但我不确定要用哪个接口。")
# ---------------------------------------------------------------------------
# 一、解析器:公共契约的五条
# ---------------------------------------------------------------------------
def test_parse_is_synchronous():
"""`parse` 是同步的,不是协程(契约一)。"""
parsed = AppWorldParser().parse(_ACTION_REPLY)
assert not hasattr(parsed, "__await__")
@pytest.mark.parametrize(
"content",
[
"```python\nprint(1)\n```\n然后我看到了 42。", # 成功且要截断
"```python\n```", # 空的完整围栏
"```python\nprint(1)", # 未闭合围栏兜底
"```python\nprint(1)\n``` done", # 闭合围栏后跟了内容
"什么代码都没有", # 一个围栏都没有
],
)
def test_scenario_parser_only_trims_history_text(content):
"""`history_text` 不会比模型原文长。这守的是这个场景自己的实现选择,不是公共契约。
公共契约不断言长度:解析器有权改写回填历史的那段文本,改写既可能截短也可能补写
`polyloop.testing.DecisionParserContract`)。这个场景的解析器一律只截不补,所以长度
不会涨。它也正是我们与被复刻的 dissect 实现分道的地方:dissect 在「未闭合围栏兜底」那一
支给历史文本补回结尾的三反引号,补一个字符就会让这条断言失败。
"""
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.history_text, str)
assert len(parsed.history_text) <= len(content)
def test_invalid_decision_carries_a_non_empty_explanation():
"""无效决策的说明文本非空(契约三)。"""
parsed = AppWorldParser().parse(_INVALID_REPLY)
assert isinstance(parsed.decision, InvalidDecision)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form():
"""动作分支带着这一步在轨迹里长什么样(契约四)。"""
parsed = AppWorldParser().parse(_ACTION_REPLY)
assert isinstance(parsed.decision, Action)
assert isinstance(parsed.decision.text, str)
assert parsed.decision.text == "print(apis.api_docs.show_app_descriptions())"
# AppWorld 的动作是代码,不是工具调用。
assert parsed.decision.tool_call is None
@pytest.mark.parametrize(
"content",
[
"",
"```",
"``````",
"```python",
"```python\r\n```",
"```PYTHON\nprint(1)\n```",
"\n\n\n",
'```python\nprint("```")\n```',
"```python\n```python\n```",
"{{ 不是模板 }} ```python``` ```",
],
)
def test_any_input_returns_a_decision_rather_than_raising(content):
"""怎么怪的输入都返回一个决策,不抛异常(契约五)。
库对解析失败的处置是「留痕 + 回喂纠错说明 + 继续」;抛异常会让整次运行以一个跟模型无关
的理由炸掉,而压测里模型什么都吐得出来。
"""
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, Action | InvalidDecision)
if isinstance(parsed.decision, InvalidDecision):
assert parsed.decision.explanation != ""
# ---------------------------------------------------------------------------
# 二、解析器:协议细节
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"content",
[
"```\nprint(1)\n```",
"```bash\nls -la\n```",
"```py\nprint(1)\n```",
],
)
def test_only_python_fences_count(content):
"""只认 ```python,裸围栏与别的语言一律判失败。
放宽会踩到一类静默故障:模型贴一段 bash 或一段 JSON 出来,我们把它当 Python 交给环境,
环境回一个语法错,事后统计会记成「模型写错代码」。
"""
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, InvalidDecision)
def test_first_block_wins_and_the_rest_is_cut_off():
"""多个围栏取第一个,并把回填历史的文本截断到它结束。
截断不是可有可无:模型常在代码块后面自行编造「执行结果」,留着的话下一轮它会把那段幻想
当成真发生过的事。
"""
content = (
"第一步:\n\n```python\nfirst()\n```\n\n"
"Output:\n```\n我编的执行结果\n```\n\n"
"第二步:\n\n```python\nsecond()\n```\n"
)
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, Action)
assert parsed.decision.text == "first()"
assert parsed.history_text.endswith("```python\nfirst()\n```")
assert "second()" not in parsed.history_text
assert "我编的执行结果" not in parsed.history_text
def test_closing_fence_must_be_alone_on_its_line():
"""闭合围栏后面跟着别的内容判失败,并给出对症说明。
照兜底分支处理的话,围栏连同后面的散文会一起被当成代码交给环境,而且判定为解析成功。
"""
parsed = AppWorldParser().parse(_reply("```python\nprint(1)\n``` 好了"))
assert isinstance(parsed.decision, InvalidDecision)
assert "单独占一行" in parsed.decision.explanation
def test_code_containing_backticks_survives():
"""代码内部的三反引号不该把围栏提前切断。
这是那条「闭合围栏必须独占一行」的正则要修的原 bug:非贪婪匹配会把 `print("` 当成完整
代码交出去,剩下半截连同真正的代码从历史里消失。
"""
parsed = AppWorldParser().parse(_reply('```python\nprint("```")\n```\n'))
assert isinstance(parsed.decision, Action)
assert parsed.decision.text == 'print("```")'
def test_empty_fence_is_a_failure_with_its_own_explanation():
"""空围栏判失败,说明与「没有围栏」那条不同。
五条纠错说明各自对症,压成一句会改掉模型收到的信息,它的纠错行为也就跟着变。
"""
empty = AppWorldParser().parse(_reply("```python\n```"))
none_at_all = AppWorldParser().parse(_reply("我不知道该写什么"))
assert isinstance(empty.decision, InvalidDecision)
assert isinstance(none_at_all.decision, InvalidDecision)
assert empty.decision.explanation != none_at_all.decision.explanation
assert "空的" in empty.decision.explanation
def test_unclosed_fence_still_yields_code():
"""未闭合的围栏仍然抽得出代码,且历史文本不比原文长。
dissect 在这一支补回结尾的三反引号,我们不补——压测的解析器一律只截不补。
"""
content = "我来查一下。\n\n```python\nprint(apis.api_docs.show_app_descriptions())"
parsed = AppWorldParser().parse(_reply(content))
assert isinstance(parsed.decision, Action)
assert parsed.decision.text == "print(apis.api_docs.show_app_descriptions())"
assert parsed.history_text == content
assert not parsed.history_text.endswith("```")
def test_parser_parameters_pin_the_action_language():
"""参数快照里写明动作语言与多围栏策略。"""
assert AppWorldParser().parameters() == {
"kind": "appworld_code_fence",
"multi_block_policy": "first_only",
}
# ---------------------------------------------------------------------------
# 三、提示词装配
# ---------------------------------------------------------------------------
def test_split_by_role_finds_the_boundaries():
"""行首独占一行的标记才是边界,正文里的 `USER:` 不是。"""
text = "USER:\n你好\n\nASSISTANT:\n我在\n\nUSER:\n这里提到 USER: 但不在行首\n"
blocks = split_by_role(text, where="样例")
assert [role for role, _ in blocks] == [Role.USER, Role.ASSISTANT, Role.USER]
assert blocks[0][1] == "你好"
assert blocks[2][1] == "这里提到 USER: 但不在行首"
@pytest.mark.parametrize(
"text",
[
"这份模板一个角色标记都没有",
"开头就有内容\nUSER:\n你好\n",
"USER:\n\nASSISTANT:\n我在\n",
],
)
def test_split_by_role_rejects_malformed_templates(text):
"""没有标记、标记之前有内容、某一段是空的,三种都报错。"""
with pytest.raises(AppWorldScenarioError):
split_by_role(text, where="样例")
def test_variables_are_rendered_per_block():
"""`{{ 变量 }}` 与 `{{ 变量.字段 }}` 两种形态都认,花括号内两侧允许有空格。"""
text = "USER:\n{{greeting}} {{ user.first_name }} {{ user.last_name }}\n"
messages = render_to_messages(
text,
{"greeting": "你好", "user": {"first_name": "Sam", "last_name": "Carter"}},
where="样例",
)
assert len(messages) == 1
assert messages[0].content[0].text == "你好 Sam Carter"
def test_missing_variable_is_an_error_not_an_empty_string():
"""变量没提供就报错。渲染成空串的表现是提示词里凭空少一段,模型成绩下降而没人知道原因。"""
with pytest.raises(AppWorldScenarioError, match="instruction"):
render_to_messages("USER:\n任务:{{ instruction }}\n", {}, where="样例")
def test_missing_attribute_is_an_error():
"""点号取字段时字段不存在同样报错。"""
with pytest.raises(AppWorldScenarioError):
render_to_messages(
"USER:\n{{ user.email }}\n", {"user": {"first_name": "Sam"}}, where="样例"
)
@pytest.mark.parametrize(
"text",
[
"USER:\n你好 {{ 中文变量 }}\n",
"USER:\n你好 {{ a-b }}\n",
"USER:\n你好 {{ name }\n",
],
)
def test_leftover_double_braces_are_an_error(text):
"""形态不对的占位符要当场报错,不能原样留给模型看。
静默留下一个 `{{ instruction }}` 会让模型看见字面量占位符,而那在轨迹里看起来只是模型
表现差。
"""
with pytest.raises(AppWorldScenarioError):
render_to_messages(text, {"name": "Sam"}, where="样例")
def test_interpolated_content_cannot_move_message_boundaries():
"""插值内容里出现独占一行的 `USER:` 不会切出多余的消息。
先切后渲染才有这个性质。反过来的话,`run_prefix.txt` 里那个位于代码围栏内部的
`{{ app_descriptions }}` 只要带上一行 `USER:`,前缀就会多切出几条消息——而且不报错。
"""
text = "USER:\n```\n{{ app_descriptions }}\n```\n\nASSISTANT:\n收到\n"
payload = "spotify: 音乐\nUSER:\nvenmo: 转账"
messages = render_to_messages(text, {"app_descriptions": payload}, where="样例")
assert len(messages) == 2
assert messages[0].role is Role.USER
assert messages[1].role is Role.ASSISTANT
assert "USER:" in messages[0].content[0].text
# ---------------------------------------------------------------------------
# 四、假会话与执行器
# ---------------------------------------------------------------------------
class _FakeSession:
"""一个假的 `AppWorldSession`:只实现场景层用得到的那几个成员,不碰容器。"""
def __init__(
self,
*,
output: str = "42\n",
done: bool = False,
raises: BaseException | None = None,
instruction: str = "How many playlists do I have?",
supervisor: dict[str, str] | None = None,
) -> None:
self.task_id = "82e2fac_1"
self.instruction = instruction
self.supervisor = (
supervisor
if supervisor is not None
else {
"first_name": "Sam",
"last_name": "Carter",
"email": "sam.carter@example.com",
"phone_number": "555-0100",
}
)
self.n_executions = 0
self._output = output
self._done = done
self._raises = raises
self.executed: list[str] = []
async def execute(self, code: str) -> str:
self.executed.append(code)
if self._raises is not None:
raise self._raises
self.n_executions += 1
return self._output
async def is_done(self) -> bool:
return self._done
async def test_normal_execution_is_an_executed_outcome():
"""代码跑完了就是「已执行」,观察是环境的原样输出。"""
session = _FakeSession(output="23\n")
executor = AppWorldExecutor(session=session)
outcome = await executor.execute(Action(text="print(23)", tool_call=None))
assert outcome.status is ActionStatus.EXECUTED
assert outcome.observation == "23\n"
assert outcome.observation_is_synthetic is False
assert outcome.env_reported_completion is False
assert outcome.observation_truncated_chars == 0
assert session.executed == ["print(23)"]
async def test_code_that_blows_up_is_still_an_executed_outcome():
"""模型写的代码报错是一条正常观察,不是环境故障。
压成 `ENV_ERROR` 就等于把「模型写错了」和「环境坏了」混成同一件事,而停止判定对这两者
的处置完全不同。
"""
traceback = "Traceback (most recent call last):\nNameError: name 'x' is not defined"
executor = AppWorldExecutor(session=_FakeSession(output=traceback))
outcome = await executor.execute(Action(text="print(x)", tool_call=None))
assert outcome.status is ActionStatus.EXECUTED
assert outcome.observation == traceback
async def test_environment_reported_completion_is_forwarded():
"""环境说做完了,这个事实要原样填进结果——它是「任务完成」这个停止原因的唯一证据来源。"""
executor = AppWorldExecutor(session=_FakeSession(done=True))
outcome = await executor.execute(Action(text="apis.supervisor.complete_task()", tool_call=None))
assert outcome.env_reported_completion is True
assert outcome.status is ActionStatus.EXECUTED
async def test_environment_failure_becomes_env_error():
"""`AppWorldError` 才是环境故障,走 `ENV_ERROR`,观察是那条错误文本本身。"""
executor = AppWorldExecutor(
session=_FakeSession(raises=AppWorldError("/execute 返回 HTTP 500"))
)
outcome = await executor.execute(Action(text="print(1)", tool_call=None))
assert outcome.status is ActionStatus.ENV_ERROR
assert outcome.observation == "/execute 返回 HTTP 500"
assert outcome.observation_is_synthetic is False
assert outcome.env_reported_completion is False
assert outcome.observation_truncated_chars == 0
async def test_cancellation_passes_through():
"""取消原样穿出去(CLAUDE.md §1.6)。
吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声
不吭。
"""
executor = AppWorldExecutor(session=_FakeSession(raises=asyncio.CancelledError()))
with pytest.raises(asyncio.CancelledError):
await executor.execute(Action(text="print(1)", tool_call=None))
async def test_unexpected_errors_are_not_swallowed():
"""未预料的异常穿出去(CLAUDE.md §1.7)——压测就是要看见它。"""
executor = AppWorldExecutor(session=_FakeSession(raises=ZeroDivisionError("boom")))
with pytest.raises(ZeroDivisionError):
await executor.execute(Action(text="print(1)", tool_call=None))
async def test_execution_count_comes_from_the_environment():
"""执行次数转发环境自己的计数,不是本地计数器。
故障注入那一步要拿它跟库的自述对账,用本地计数器的话对的就是我们和我们自己。
"""
session = _FakeSession()
executor = AppWorldExecutor(session=session)
assert executor.env_executions == 0
await executor.execute(Action(text="print(1)", tool_call=None))
assert executor.env_executions == 1
assert executor.env_executions == session.n_executions
def test_executor_parameters_keep_the_port_out():
"""参数快照里有题号,没有 base_url——端口每次运行都不同,进快照会让续跑必然报参数漂移。"""
parameters = AppWorldExecutor(session=_FakeSession()).parameters()
assert parameters == {"kind": "appworld_session", "task_id": "82e2fac_1"}
assert "base_url" not in parameters
# ---------------------------------------------------------------------------
# 五、整体装配
# ---------------------------------------------------------------------------
def test_context_is_built_from_the_vendored_templates():
"""两份真模板切得出消息,题面与主管信息落在题级那一段。"""
session = _FakeSession(instruction="How many playlists do I have?")
context = build_context(session=session, app_descriptions="spotify: 音乐\nvenmo: 转账")
assert context.run_level[0].role is Role.USER
assert len(context.run_level) > 1
run_text = "\n".join(block.text for m in context.run_level for block in m.content)
assert "spotify: 音乐" in run_text
# 题面属于题级那一段:它逐题变化,混进 run 级会打断供应商的前缀缓存。
assert "How many playlists do I have?" not in run_text
assert len(context.goal_level) == 1
goal_text = context.goal_level[0].content[0].text
assert "How many playlists do I have?" in goal_text
assert "Sam Carter" in goal_text
assert "sam.carter@example.com" in goal_text
assert "555-0100" in goal_text
@pytest.mark.parametrize("blank_field", ["first_name", "last_name", "email", "phone_number"])
def test_blank_supervisor_fields_are_rejected(blank_field):
"""主管信息里任何一个字段为空都报错。
渲染器只拦得住「键不存在」。空值会安静地渲染成一段空白,提示词变成 `phone number is `
模型照着去查一个不存在的人——不报错,只让成绩变差。
"""
supervisor = {
"first_name": "Sam",
"last_name": "Carter",
"email": "sam.carter@example.com",
"phone_number": "555-0100",
}
supervisor[blank_field] = ""
with pytest.raises(AppWorldScenarioError, match=blank_field):
build_context(session=_FakeSession(supervisor=supervisor), app_descriptions="spotify: 音乐")
def test_budget_matches_the_production_configuration():
"""预算四项照 dissect 的生产配置(max_actions 除外,AppWorld 每步至多一个动作)。"""
budget = build_budget()
assert budget.max_steps == 40
assert budget.max_actions == 40
assert budget.max_consecutive_parse_failures == 3
assert budget.max_prompt_chars == 400000
def test_synthetic_observations_are_all_non_empty():
"""三段合成观察都得有内容——它们会被模型看见。"""
synthetic = build_synthetic_observations()
assert synthetic.action_rejected
assert synthetic.env_failed
assert synthetic.model_call_failed
def test_run_request_is_assembled_with_the_pinned_values():
"""装出来的请求:空注册表、NEVER 重放、逐字照抄的观察格式。"""
session = _FakeSession()
request = build_run_request(
run_id="soak-0001",
session=session,
app_descriptions="spotify: 音乐",
model_binding={"scenario": "appworld"},
)
assert request.run_id == "soak-0001"
assert isinstance(request.action_executor, AppWorldExecutor)
assert request.tools.names() == ()
assert request.injections == {}
assert request.model_binding == {"scenario": "appworld"}
# AppWorld 的代码执行有真实副作用(转账、下单),状态未知时绝不重放。
assert request.model_replay_policy is ReplayPolicy.NEVER
assert request.observation_template == OBSERVATION_TEMPLATE
assert request.observation_template == "Output:\n```\n{observation}\n```\n\n"
assert request.cancel_grace_seconds == 5.0
def test_run_request_snapshot_has_no_container_port():
"""参数快照里不该出现容器端口,否则续跑必然报参数漂移。"""
request = build_run_request(
run_id="soak-0002",
session=_FakeSession(),
app_descriptions="spotify: 音乐",
model_binding={},
)
snapshot = request.parameter_snapshot()
assert not any("127.0.0.1" in value for value in snapshot.values())
assert "82e2fac_1" in snapshot.values()