Files
iomgaa e590bea70e fix(soak): 环境客户端把传输层失败也翻成 AppWorldError,「连不上」那一档原本没实现
execute 的 docstring 一直写着「只有环境自己坏了——连不上、HTTP 非 2xx、返回体不是约定
形状——才抛 AppWorldError」,但实现里只处理了后两档。连不上、读超时、连接中途断掉时,
httpx 抛的是它自己的异常类型,而上层的动作执行接缝只认 AppWorldError,于是容器一挂整次
运行会以一个未捕获的第三方异常炸出去,正确的行为是记成环境故障、由库合成一段观察、以
env_error 收尾。

这个缺口是压测里真把容器 docker kill 掉之后才暴露的——在那之前它只写在 docstring 里。
顺带覆盖了另一个一直没验过的路径:客户端读超时同属这一类。

CancelledError 继承 BaseException,不在 httpx.HTTPError 的范围内,取消照旧原样穿过去。

修完重跑那一类,七条判据仍然全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:16:08 -04:00

847 lines
40 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.
"""压测 harness 的环境层:一个 AppWorld 容器池,加一个薄 HTTP 客户端。
**这一层完全不碰模型。** 它只提供「起容器 → 实例化一道题 → 执行代码 → 问完成没有 →
评分 → 关闭」这一串动作,谁来决定执行什么代码是上层的事。所以它可以离线验证——
`check_appworld.py` 用一段写死的 Python 就能把整条链路走通,一次模型调用都不用打。
**为什么走 HTTP 而不是直接 import appworld。** appworld 包钉在 pydantic 1.x,而
PolyGateway 要求 pydantic ≥ 2.8,两者装不进同一个解释器。AppWorld 官方把它的
`AppWorld` 类整个包成了一个 environment server 跑在官方镜像里,所以本模块只是个瘦
客户端,本进程一行 appworld 代码都不导入。
**为什么需要容器池而不是一个容器。** environment server 用一个模块级变量存「当前任务」,
请求换题会被直接拒绝,也就是说一个容器同一时刻只能跑一道题。要并发跑 N 道题就得起
N 个容器,各占一个宿主端口,用完归还。
**协议来自 dissect 的 `harness/envs/`,代码是独立写的。** PolyLoop 是被 dissect 依赖的
库,反向 import 下游是硬约束(CLAUDE.md §1.2,由 import-linter 断言)。dissect 那两个
文件在这里只当协议文档看——端点形状、初始化参数取值、两个坑的成因都出自那里,实现则
只保留压测用得着的最小子集,不带 dissect 自己的 Episode/TaskEnv/ScoredEpisode 抽象、
不带评分聚合、不带快照参数。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
import httpx
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
logger = logging.getLogger(__name__)
#: environment server 的官方镜像。用滚动标签而不是 digest:压测只关心「跑得动多少
#: 并发」,不做跨时间可比的成绩,上游换镜像不影响结论。真做实验的那一侧(dissect)
#: 才需要把 digest 钉进快照。
DEFAULT_IMAGE = "ghcr.io/stonybrooknlp/appworld:latest"
#: 起始宿主端口,池占用 [PORT_BASE, PORT_BASE + size)。
#:
#: **刻意避开 8100**dissect 的默认值就是 8100,而这台机器上两边可能同时在跑。撞了
#: 端口的表现不是一句「端口被占用」——先起的那个容器活着,后起的 `docker run` 失败,
#: 于是压测报「容器起不来」,而真正在受害的是另一个项目的实验。
DEFAULT_PORT_BASE = 8200
#: 容器名前缀。名字里带端口号,`docker ps` 的输出就能直接对上出问题的那个端口。
CONTAINER_NAME_PREFIX = "polyloop-soak-appworld"
#: 健康检查路径。返回 2xx 即视为就绪。
_READINESS_PATH = "/"
#: 数据目录的挂载模式。**只读**:这份数据是 dissect 的实验数据,733 个任务目录,压测
#: 没有任何理由改它,而一次误写会污染另一个项目的实验输入且无人察觉。环境自己要写的
#: 东西全部落在输出目录那个挂载点上。
_DATA_MOUNT_MODE = "ro"
#: 连续多少次会话关闭失败就让整轮压测停下来。取 3 而不是 1,是因为一次网络抖动不该
#: 毁掉整批;取 3 而不是 50,是因为泄漏是累积的,等到几十次时环境早就不干净了。
_MAX_CLOSE_FAILURES = 3
#: 允许的挂载模式。docker 会把认不得的模式当成一个额外的挂载选项,拼错了不一定报错,
#: 所以在拼命令行之前先自己拦一道。
_MOUNT_MODES = frozenset({"ro", "rw"})
class ContainerPoolError(RuntimeError):
"""容器池的启动、健康检查或清理失败。
一律带上 docker 的 stderr 或容器日志。容器起不来时只报一句「超时」,调试就只能靠猜。
"""
class AppWorldError(RuntimeError):
"""AppWorld 环境返回了错误,或返回了我们无法解释的内容。
**它不表示「模型写的代码跑挂了」**——那是正常观察,由 `AppWorldSession.execute`
原样返回。这个异常只在环境本身坏了时抛:连不上、HTTP 非 2xx、返回体不是约定的形状。
"""
@dataclass(frozen=True)
class Mount:
"""一条 docker 卷挂载。
模式单独成字段而不是拼进字符串,是为了让「这个挂载是只读的」在调用处看得见——
`-v a:b:ro` 里那两个字母混在路径中间,改错了不会有人发现。
"""
host: Path
container: str
mode: str
def to_arg(self) -> str:
"""拼成 `docker run -v` 的取值。"""
if self.mode not in _MOUNT_MODES:
raise ValueError(f"挂载模式只能是 {sorted(_MOUNT_MODES)},收到 {self.mode!r}")
return f"{self.host}:{self.container}:{self.mode}"
@dataclass(frozen=True)
class TaskScore:
"""官方评测器对一道题的判定。
`success` 是过与不过,AppWorld 没有连续分。`detail` 是评测器返回的完整结构,里面
逐条 requirement 的通过情况在排查「是模型不行还是环境坏了」时是唯一线索。
"""
task_id: str
success: bool
n_executions: int
detail: Mapping[str, Any]
class ContainerPool:
"""一组同构容器与其宿主端口的租借池。
池里第 i 个容器占宿主端口 `port_base + i`,容器内监听同一个端口号——`-p` 的两侧
一致,容器里的服务就不需要知道自己被映射到了哪里。
用法::
async with ContainerPool(...) as pool:
async with pool.lease() as port:
... # 独占这个端口上的服务
池不清理租借者在容器里留下的状态。归还前复位状态是租借者的责任——对 AppWorld
而言就是调 `/close`。
**它刻意不知道容器里跑的是什么。** 启动命令由 `container_args` 给出,健康检查路径
由 `readiness_path` 给出,本类只管容器的生死与端口的租借。
"""
def __init__(
self,
*,
image: str,
size: int,
port_base: int,
name_prefix: str,
container_args: Callable[[int], Sequence[str]],
readiness_path: str,
mounts: Sequence[Mount] = (),
startup_timeout_s: float,
) -> None:
"""构造容器池。不启动任何东西,启动在 `start()`。
Args:
image: 镜像名,需已在本地或可拉取。
size: 容器数量,即并发上限。
port_base: 起始宿主端口。
name_prefix: 容器名前缀,实际名字是 `{prefix}-{端口号}`。
container_args: 端口 → 容器内启动命令。每个容器的命令都要带上自己的端口号,
所以这里是函数而不是一份固定列表。
readiness_path: 健康检查的 HTTP 路径。
mounts: 卷挂载。
startup_timeout_s: 单个容器从 `docker run` 到健康检查通过的时限。
"""
if size < 1:
raise ValueError(f"池大小必须至少为 1,收到 {size}")
if port_base < 1024:
raise ValueError(f"起始端口须在非特权区间,收到 {port_base}")
if startup_timeout_s <= 0:
raise ValueError(f"启动时限必须为正,收到 {startup_timeout_s}")
self._image = image
self._size = size
self._ports = [port_base + i for i in range(size)]
self._name_prefix = name_prefix
self._container_args = container_args
self._readiness_path = readiness_path
self._mounts = tuple(mounts)
self._startup_timeout_s = startup_timeout_s
self._free_ports: asyncio.Queue[int] = asyncio.Queue()
self._started = False
self._stopped = False
self._leased = 0
@property
def ports(self) -> tuple[int, ...]:
"""池占用的全部宿主端口,无论此刻是否空闲。"""
return tuple(self._ports)
async def start(self) -> None:
"""启动全部容器并等它们就绪。任何一个起不来就把已起的全部拆掉再抛错。
留下半个池比干净地失败更难排查:下一次跑会撞上同名容器,而报出来的是
`docker run` 的重名错误,跟真正的起因隔了一整轮。
"""
if self._started:
raise ContainerPoolError("容器池已启动,不要重复调用 start()")
# 池是一次性的:停过就不能再起。允许重启的话,`stop()` 之后迟到归还的租约会把
# 端口 put 进队列,紧接着的 `start()` 再 put 一遍,队列里就有了重复端口——两个
# 任务会同时租到同一个容器,而 environment server 是单例,后者会被「当前活动任务
# 是另一道题」打挂。压测没有重启的用例,就不留这个坑。
if self._stopped:
raise ContainerPoolError("容器池已停止,不支持重启;需要的话新建一个池")
# 上一次运行若被强杀会留下同名容器,`docker run` 会因重名直接失败。先无条件清
# 一遍,让重跑不需要人工干预。
await self._remove_all()
# 用 TaskGroup 而不是 gathergather 在第一个子协程抛错时立刻把异常抛给调用方,
# 但**不取消其余子协程**,它们会一直跑到 startup_timeout_s。那意味着下面的清理
# 可能跑在某些 `docker run` 完成之前,于是那些容器活了下来占着端口——正是「要么
# 全起要么全拆」要防的半个池。TaskGroup 会取消其余任务并等它们收敛之后才抛。
try:
async with asyncio.TaskGroup() as group:
for port in self._ports:
group.create_task(self._start_one(port))
except BaseExceptionGroup as failures:
await self._remove_all()
# TaskGroup 把子任务的异常打包成组。这里拆开只抛第一个,让调用方拿到一个
# 平常的异常而不是需要 `except*` 才能接的组;其余的记进日志,不然多个容器
# 同时出问题时只看得见一个。
first, *rest = failures.exceptions
for extra in rest:
logger.error("容器池启动时的另一处失败:%s", extra)
# 不写 `from ...``first` 自己的 __cause__ 已经指着真正的故障原因(见
# `_start_one` 的 `from exc`),再显式挂一个 from 会把它顶掉,异常链上就只剩
# 一句「启动过程中出错」。B904 要防的是丢掉上下文,这里上下文本来就在。
raise first # noqa: B904
except BaseException:
# 取消之类不经由 TaskGroup 打包的路径,同样要保证不留下半个池。
await self._remove_all()
raise
for port in self._ports:
self._free_ports.put_nowait(port)
self._started = True
logger.info("容器池就绪:%d%s 实例,端口 %s", self._size, self._image, self._ports)
async def stop(self) -> None:
"""停止并删除全部容器。幂等,但停过之后不能再 `start()`。"""
if self._leased:
# 容器马上就没了,那些租约手里的端口随后会连不上。这说明调用方在还有任务在跑
# 的时候就退出了 `async with`,属于调用方的 bug;但沉默地让它表现成一堆网络
# 错误更糟——那时看到的是「AppWorld 连不上」,查的方向完全错了。
logger.error("停止容器池时仍有 %d 个租约在途,它们的请求将会失败", self._leased)
# 状态更新必须放 finally`docker rm` 也会失败(docker 二进制不在时
# create_subprocess_exec 直接抛 FileNotFoundError)。失败之后如果状态还停在
# 「已启动」、队列里还剩着端口,接下来的 lease() 会把端口发给一批已经不确定还在
# 不在的容器,故障从「停不掉」变成「跑在幽灵容器上」。
try:
await self._remove_all()
finally:
self._started = False
self._stopped = True
while not self._free_ports.empty():
self._free_ports.get_nowait()
@contextlib.asynccontextmanager
async def lease(self) -> AsyncIterator[int]:
"""租借一个空闲容器的宿主端口,退出时归还。
池满时阻塞等待,**不设排队超时**——排队时间是正常的并发背压,把它变成异常只会
让上层写一堆重试逻辑,而重试改变不了「容器就这么多」这个事实。
"""
# 先判已停止:停过之后 `_started` 也是 False,只报「尚未启动,先调用 start()」
# 会把人引去调一个必然报「不支持重启」的方法。
if self._stopped:
raise ContainerPoolError("容器池已停止,它的容器都没了;需要的话新建一个池")
if not self._started:
raise ContainerPoolError("容器池尚未启动,先调用 start() 或用 async with")
port = await self._free_ports.get()
self._leased += 1
try:
yield port
finally:
self._leased -= 1
self._free_ports.put_nowait(port)
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.stop()
# -- 内部 ----------------------------------------------------------------
def _container_name(self, port: int) -> str:
return f"{self._name_prefix}-{port}"
async def _start_one(self, port: int) -> None:
"""起一个容器并等它健康。"""
name = self._container_name(port)
command = ["docker", "run", "-d", "--name", name, "-p", f"{port}:{port}"]
for mount in self._mounts:
command += ["-v", mount.to_arg()]
command += [self._image, *self._container_args(port)]
code, _, stderr = await _run(command)
if code != 0:
raise ContainerPoolError(f"容器 {name} 启动失败(docker run 退出码 {code}):{stderr}")
try:
await self._await_ready(port, name)
except asyncio.CancelledError:
# 取消必须原样传播。转成普通异常会让这个任务不再处于「已取消」状态,事件循环
# 随后报一次 unhandled exception,而现场看起来像是一次超时——真正发生的事
# (有人按了 Ctrl-C 或上层撤销了这一批)就此消失。
await self._remove_noisily(name)
raise
except Exception as exc:
# 健康检查失败时容器日志是唯一的线索,**必须先抓出来再删**。删掉之后
# `docker logs` 就没有东西可读了,而这类失败往往只在负载高的时候偶发。
# 保留 `from exc`:能进来的不止超时,readiness_path 写错导致的 httpx.InvalidURL
# 之类也会走到这儿,抹掉原因就只能靠猜。
reason = "未就绪(超时)" if isinstance(exc, TimeoutError) else "启动过程中出错"
logs = await self._container_logs(name)
await self._remove_noisily(name)
raise ContainerPoolError(
f"容器 {name} {reason}(等待上限 {self._startup_timeout_s} 秒)。容器日志:\n{logs}"
) from exc
async def _await_ready(self, port: int, name: str) -> None:
"""轮询健康检查端点直到返回 2xx 或超时。"""
url = f"http://127.0.0.1:{port}{self._readiness_path}"
deadline = asyncio.get_running_loop().time() + self._startup_timeout_s
# trust_env=False 是必须的:这台机器设了 http_proxy,而 httpx 默认会读环境变量,
# 于是连 127.0.0.1 的请求也被送去代理。表现是 HTTP 502——看起来像容器里的服务
# 出错,实际上请求根本没到过容器。
async with httpx.AsyncClient(timeout=5.0, trust_env=False) as client:
while asyncio.get_running_loop().time() < deadline:
# 容器刚起时连接被拒是常态,只有超时才算失败,所以这里不区分具体的网络
# 错误类型;非网络异常(配置错误之类)仍会照常冒泡。
try:
response = await client.get(url)
except httpx.TransportError:
await asyncio.sleep(0.5)
continue
if response.is_success:
logger.debug("容器 %s 就绪", name)
return
await asyncio.sleep(0.5)
raise TimeoutError(f"容器 {name} 健康检查超时")
async def _container_logs(self, name: str) -> str:
"""取容器日志用于报错。取不到就说明取不到,不返回空串冒充「日志为空」。"""
code, stdout, stderr = await _run(["docker", "logs", "--tail", "50", name])
if code != 0:
return f"<无法读取容器日志,docker logs 退出码 {code}{stderr}>"
return (stdout + stderr).strip() or "<容器未输出任何日志>"
async def _remove_noisily(self, name: str) -> None:
"""尽力删掉一个容器;删不掉只记日志,不改变正在传播的异常。
这里不用 `contextlib.suppress`:清理失败意味着一个容器占着端口活了下来,下一次
跑会以「docker run 重名」的形式失败,而那时已经没人记得是这一次没删干净。
"""
try:
code, _, stderr = await _run(["docker", "rm", "-f", name])
except OSError as exc:
logger.error("清理容器 %s 时连 docker 都没跑起来:%s", name, exc)
return
if code != 0:
logger.error("清理容器 %s 失败(退出码 %d):%s", name, code, stderr.strip())
async def _remove_all(self) -> None:
"""强删池内全部容器。
不检查退出码:绝大多数非零都是「容器本来就不存在」,而这正是启动前清残留时的常态。
真正的故障(docker 守护进程没了)会在紧接着的 `docker run` 上以更清楚的形式暴露。
"""
names = [self._container_name(port) for port in self._ports]
await _run(["docker", "rm", "-f", *names])
def container_args_for_port(port: int) -> Sequence[str]:
"""官方镜像的启动参数:第一个位置参数选服务类型,然后是端口。
单独提出来是为了不必起一个真容器就能核对这串参数。
"""
return ["environment", "--port", str(port), "--no-show-usage"]
class AppWorldSession:
"""一道题的一次会话,绑定在池里的某一个容器上。
生命周期由 `AppWorldPool.session()` 管理,不要直接构造——直接构造出来的会话没有
对应的 `/close`,容器里的资源会一直挂着。
"""
def __init__(
self,
*,
client: httpx.AsyncClient,
base_url: str,
task_id: str,
instruction: str,
supervisor: Mapping[str, str],
datetime: str,
) -> None:
self._client = client
self._base_url = base_url
self._task_id = task_id
self._instruction = instruction
self._supervisor = dict(supervisor)
self._datetime = datetime
self._n_executions = 0
@property
def task_id(self) -> str:
"""本题的题目 ID。"""
return self._task_id
@property
def base_url(self) -> str:
"""本次会话所在容器的地址。排查时用来定位是哪个容器出的事。"""
return self._base_url
@property
def instruction(self) -> str:
"""题面:主管交给 agent 的自然语言指令。"""
return self._instruction
@property
def supervisor(self) -> Mapping[str, str]:
"""主管的身份信息(姓名、邮箱、电话)。agent 要靠它调 API。"""
return dict(self._supervisor)
@property
def datetime(self) -> str:
"""任务发生的虚拟时间,题目的一部分。"""
return self._datetime
@property
def n_executions(self) -> int:
"""到目前为止执行过多少次代码。"""
return self._n_executions
async def execute(self, code: str) -> str:
"""在环境里执行一段 Python 代码,返回它的输出。
这是 AppWorld 唯一的动作接口——它不是 JSON 形式的工具调用,agent 通过写代码调
``apis.<应用>.<接口>(...)`` 来操作各个 app,通过 ``apis.supervisor.complete_task()``
声明做完了。执行器是有状态的(环境侧是一个常驻 IPython shell),变量、import、
打开的句柄都跨步存活。
**代码本身报错、超时、语法错,都不抛异常。** 环境会把 traceback 或超时提示放在
输出里返回,那是给模型看的正常观察,压成异常就等于把「模型写错了」和「环境坏了」
混成同一件事,而上层对这两者的处理完全不同。只有环境自己坏了——连不上、HTTP 非
2xx、返回体不是约定形状——才抛 `AppWorldError`。
"""
payload = await self._post("/execute", {"task_id": self._task_id, "code": code})
self._n_executions += 1
if not isinstance(payload, str):
raise AppWorldError(f"execute 期望返回字符串,收到 {type(payload).__name__}")
return payload
async def is_done(self) -> bool:
"""环境是否看到了完成信号,即 agent 调过 `apis.supervisor.complete_task()`。
这只是停机条件之一。步数耗尽、连续解析失败之类由上层自己判断,不经过环境。
"""
payload = await self._post("/task_completed", {"task_id": self._task_id})
return bool(payload)
async def evaluate(self) -> TaskScore:
"""跑官方评测器给这道题打分。**必须在会话关闭之前调用**,关闭之后环境状态就没了。
`suppress_errors=True` 是正常打分模式,不是「把错误藏起来」。AppWorld 用异常表达
「这一条 requirement 没通过」:评测脚本把每条 requirement 包在一个上下文管理器里,
断言失败时 `__exit__` 把它记进 failures 再返回 `suppress_errors` 决定是否抑制。
取 False 的话第一条没过的 requirement 就会中断整个评测,连分数都拿不到。
代价是它无差别地吞掉所有异常,包括评测基础设施自己的故障——两者都表现成 failure,
区别只在 `detail` 里那条 trace 的内容。所以成绩集体为零时要去看 trace 是断言失败
还是别的东西,别把环境坏了当成模型不行。
"""
payload = await self._post(
"/evaluate",
{"task_id": self._task_id, "suppress_errors": True, "report": False},
)
if not isinstance(payload, dict):
raise AppWorldError(f"evaluate 期望返回对象,收到 {type(payload).__name__}")
return TaskScore(
task_id=self._task_id,
success=_success_of(payload, self._task_id),
n_executions=self._n_executions,
detail=payload,
)
async def close(self) -> None:
"""关闭会话,释放环境侧资源。由 `AppWorldPool.session()` 在退出时调用。"""
await self._post("/close", {"task_id": self._task_id})
async def _post(self, path: str, body: Mapping[str, Any]) -> Any:
return await _post_json(self._client, self._base_url, path, body)
class AppWorldPool:
"""AppWorld 环境的入口:管容器池、管 HTTP 连接、开会话。
用法::
pool = AppWorldPool(data_root=Path(...), size=2)
async with pool:
task_id = pool.list_task_ids("train")[0]
async with pool.session(task_id) as session:
print(await session.execute("print(1 + 1)"))
score = await session.evaluate()
"""
def __init__(
self,
*,
data_root: Path | str,
size: int,
outputs_dir: Path | str | None = None,
experiment_name: str = "polyloop-soak",
image: str = DEFAULT_IMAGE,
port_base: int = DEFAULT_PORT_BASE,
max_interactions: int = 40,
execution_timeout_s: int = 100,
startup_timeout_s: float = 180.0,
) -> None:
"""构造入口。不启动容器也不建连接,那些都在 `start()`。
Args:
data_root: AppWorld 的数据根目录,即 `appworld download data --root` 指定的
那个。它下面应有 `data/datasets/` 与 `data/tasks/`。**这个目录以只读方式
挂进容器**,压测不会改它。
size: 容器数量,即并发上限。
outputs_dir: 环境侧写日志的目录,挂到容器的 `/run/experiments/outputs`。
默认落在系统临时目录下我们自己的一个位置——不复用数据根目录下的
`experiments/outputs`,因为那是别的项目的实验产物,压测不该往里掺东西。
experiment_name: 环境用它作为输出子目录名。
image: environment server 的镜像。
port_base: 容器池的起始宿主端口。
max_interactions: 环境侧允许的最大执行次数,当成上层步数预算的双保险——
上层循环失控时环境会兜住。40 与 dissect 的取值一致(官方默认是 1000)。
execution_timeout_s: 单次代码执行的超时,传给环境侧。
startup_timeout_s: 单个容器启动并就绪的时限。镜像已在本地时通常几秒就够,
留 180 秒是为了容得下一次冷拉取。
"""
self._data_root = Path(data_root).resolve()
self._datasets_dir = self._data_root / "data" / "datasets"
self._tasks_dir = self._data_root / "data" / "tasks"
self._outputs_dir = (
Path(outputs_dir).resolve()
if outputs_dir is not None
else Path(tempfile.gettempdir()) / "polyloop-soak-appworld-outputs"
)
self._experiment_name = experiment_name
self._max_interactions = max_interactions
self._execution_timeout_s = execution_timeout_s
self._client: httpx.AsyncClient | None = None
#: 每个容器端口各自的连续关闭失败次数。**必须按端口分开数**:池里有多个容器,
#: 用一个全局计数器的话,坏掉那个容器每次失败都会被其他容器的成功清零,阈值
#: 永远到不了,安全网等于不存在——而资源泄漏恰恰是发生在单个容器上的。
self._close_failures: dict[int, int] = {}
self._pool = ContainerPool(
image=image,
size=size,
port_base=port_base,
name_prefix=CONTAINER_NAME_PREFIX,
container_args=container_args_for_port,
readiness_path=_READINESS_PATH,
mounts=[
Mount(host=self._data_root / "data", container="/run/data", mode=_DATA_MOUNT_MODE),
Mount(
host=self._outputs_dir,
container="/run/experiments/outputs",
mode="rw",
),
],
startup_timeout_s=startup_timeout_s,
)
@property
def ports(self) -> tuple[int, ...]:
"""池占用的宿主端口。"""
return self._pool.ports
@property
def outputs_dir(self) -> Path:
"""环境侧日志落在宿主上的哪里。
**容器里的进程是 root,写出来的文件也是 root 的**,所以这个目录事后不由本模块
删除——删不掉,而一次删不掉的清理会以「压测收尾报错」的形式盖住真正的结果。
"""
return self._outputs_dir
async def start(self) -> None:
"""检查数据、建连接、启动容器池。
任何一步失败都要把已经建起来的东西收回去——`async with` 在 `__aenter__` 抛错时
**不会**调用 `__aexit__`,不自己收就是一次泄漏。
"""
# 重入守卫。没有它的话第二次 start() 会先覆盖掉 self._client(旧连接就此泄漏),
# 再被容器池的「已启动」守卫打回,然后 except 里关掉刚建的新连接并置 None——结果
# 是容器还在跑、旧连接漏着、而对外声称尚未启动。
if self._client is not None:
raise AppWorldError("环境已启动,不要重复调用 start()")
self._verify_data_layout()
self._outputs_dir.mkdir(parents=True, exist_ok=True)
# HTTP 超时要盖过环境侧的执行超时,否则代码还在跑我们就先断了连接,表现成一次
# 假的网络故障。留 30 秒余量给序列化与调度。
#
# trust_env=False 同样是必须的,理由见 ContainerPool._await_ready。
self._client = httpx.AsyncClient(timeout=self._execution_timeout_s + 30.0, trust_env=False)
try:
await self._pool.start()
except BaseException:
await self._client.aclose()
self._client = None
raise
async def stop(self) -> None:
"""停止容器池并关闭 HTTP 连接。
try/finally 保证连接一定被关掉:`pool.stop()` 也会失败(docker 二进制不在时
`create_subprocess_exec` 直接抛 FileNotFoundError),那时连接不能跟着漏掉。
"""
try:
await self._pool.stop()
finally:
if self._client is not None:
await self._client.aclose()
self._client = None
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.stop()
def list_task_ids(self, split: str) -> list[str]:
"""列出某个数据划分下的全部题目 ID。
纯本地读文件,不需要容器——挑题、数题的场合不该为了拿一串 ID 去起一堆容器。
"""
dataset_file = self._datasets_dir / f"{split}.txt"
if not dataset_file.exists():
available = sorted(p.stem for p in self._datasets_dir.glob("*.txt"))
raise FileNotFoundError(f"找不到划分文件 {dataset_file};现有划分:{available}")
task_ids: list[str] = []
for line in dataset_file.read_text(encoding="utf-8").splitlines():
entry = line.strip()
if not entry:
continue
# 划分文件里的条目可能带 ":标签" 后缀,标签不是题目 ID 的一部分。
task_ids.append(entry.split(":")[0])
return task_ids
@contextlib.asynccontextmanager
async def session(self, task_id: str) -> AsyncIterator[AppWorldSession]:
"""开一次会话:租一个容器、实例化这道题,退出时关闭会话并归还容器。
**评测必须在退出这个上下文之前做**,退出后环境状态就销毁了。
"""
client = self._require_client()
async with self._pool.lease() as port:
base_url = f"http://127.0.0.1:{port}"
payload = await _post_json(
client,
base_url,
"/initialize",
{"task_id": task_id, **self._init_params()},
)
if not isinstance(payload, dict):
raise AppWorldError(f"initialize 期望返回对象,收到 {type(payload).__name__}")
session = AppWorldSession(
client=client,
base_url=base_url,
task_id=task_id,
instruction=_require(payload, "instruction", task_id),
supervisor=_require(payload, "supervisor", task_id),
datetime=_require(payload, "datetime", task_id),
)
try:
yield session
finally:
await self._close_quietly(session, port)
# -- 内部 ----------------------------------------------------------------
async def _close_quietly(self, session: AppWorldSession, port: int) -> None:
"""关闭会话;失败只记账不抛,但同一个容器连续失败到阈值就让整轮压测停下来。
不抛的理由:走到这里时这道题的结果通常已经拿到手了,为一次清理失败丢掉整道题的
数据不划算。
但「不抛」需要一张真的安全网,因为**这个故障不会自己暴露**。AppWorld 的
`/initialize` 拿到请求后第一件事就是覆盖全局的 world 变量,既不检查旧的还开着
没有、也不关它。所以下一个租户不会报错,只会让上一个 world 的资源永久留在容器
里。一个容器在压测里要连续跑成百上千个会话,这是会累积到 fd 耗尽的慢性故障,而
唯一的线索只有一行日志。
计数按端口分开,理由见 `_close_failures` 的定义。
"""
try:
await session.close()
except asyncio.CancelledError:
# 取消要原样传播:它不是「关闭失败」,记进失败计数会让一次 Ctrl-C 把安全网
# 的阈值推高,而真正的泄漏还没发生。
raise
except Exception as exc:
failures = self._close_failures.get(port, 0) + 1
self._close_failures[port] = failures
logger.error(
"题目 %s 的会话关闭失败(容器端口 %d,该容器已连续失败 %d 次):%s",
session.task_id,
port,
failures,
exc,
)
if failures >= _MAX_CLOSE_FAILURES:
raise AppWorldError(
f"端口 {port} 上的容器会话关闭已连续失败 {failures} 次。它内部的资源"
f"正在泄漏(AppWorld 的 /initialize 不会替我们清理旧会话),继续跑下去,"
f"落在这个容器上的任务都会产出在一个不健康的环境里"
) from exc
else:
self._close_failures.pop(port, None)
def _init_params(self) -> dict[str, Any]:
"""传给 `/initialize` 的参数。
**会影响 agent 行为的开关全部显式给值**,不沿用 AppWorld 的默认——默认值会随它
的版本变化,而这些开关直接改变 agent 能做什么。除 `max_interactions` 外,其余取值
都等于 0.1.3 的默认值;写出来是为了防版本漂移,不是为了偏离官方设置。
官方标注「仅供测试、不应改动」的六个参数(`raise_on_extra_parameters`、
`import_utils`、`parse_datetimes`、`allow_datetime_change`、`add_login_shortcut`、
`munchify_response`)不传,沿用它们的默认值。
**刻意不传 `ground_truth_mode`。** AppWorld 0.1.3 的 `AppWorldInitDefaults` 里那个
字段写的是 ``Literal["full" "minimal"]``,两个字面量之间漏了逗号,被 Python 拼接成
单一取值 ``"fullminimal"``。默认值 ``"minimal"`` 并不在这个 Literal 里,只是因为
pydantic 不校验默认值才没暴露;一旦显式传 ``"minimal"``,请求体就会走校验然后被拒。
"""
return {
"experiment_name": self._experiment_name,
"max_interactions": self._max_interactions,
"max_api_calls_per_interaction": 1000,
"raise_on_unsafe_syntax": True,
"null_patch_unsafe_execution": True,
"load_ground_truth": True, # evaluate 需要它
"raise_on_failure": True,
# 环境自身的随机种子固定不动。它控制的是环境初始状态与 API 响应里的随机成分,
# 也就是「题目本身」——跟着别的什么东西变的话,两次压测面对的就不是同一道题,
# 而「同一道题这次慢了」正是压测要看的东西。100 是 AppWorld 的默认值。
"random_seed": 100,
"timeout_seconds": self._execution_timeout_s,
"show_api_response_schemas": True,
"gc_threshold": 500000,
}
def _require_client(self) -> httpx.AsyncClient:
"""取 HTTP 连接;没 start 过就用是调用方的顺序错误,直接说清楚。"""
if self._client is None:
raise AppWorldError("环境尚未启动,先调用 start() 或用 async with")
return self._client
def _verify_data_layout(self) -> None:
"""启动前确认数据在位,免得每个容器各自失败一次才发现是数据没下。"""
for path in (self._datasets_dir, self._tasks_dir):
if not path.is_dir():
raise FileNotFoundError(
f"AppWorld 数据缺失:{path} 不存在。在装有 appworld 的环境里跑:"
f"appworld install && appworld download data --root {self._data_root}"
)
def _success_of(tracker: Mapping[str, Any], task_id: str) -> bool:
"""从评测结果里取这道题过没过。
官方评测器先看 `success`、再回落到 `passes_fully`(字段迁移中的兼容写法)。两个都没有
就报错而不是当成失败——把「读不出分数」记成「没做对」会让一次接口变更表现成成绩暴跌,
而且查不出原因。
"""
for key in ("success", "passes_fully"):
if key in tracker:
return bool(tracker[key])
raise AppWorldError(
f"题目 {task_id} 的评测结果里既没有 success 也没有 passes_fully"
f"实有字段:{sorted(tracker)}"
)
def _require(payload: Mapping[str, Any], key: str, task_id: str) -> Any:
"""从环境返回里取一个必需字段,缺了就报错。"""
if key not in payload:
raise AppWorldError(f"题目 {task_id} 的环境返回缺字段 {key!r},实有字段:{sorted(payload)}")
return payload[key]
async def _post_json(
client: httpx.AsyncClient,
base_url: str,
path: str,
body: Mapping[str, Any],
) -> Any:
"""打一个 POST,检查状态码,剥掉 environment server 统一的 output 包装。
**传输层的失败也要翻成 `AppWorldError`。** 连不上、读超时、连接中途断掉,httpx 抛的是
它自己的异常类型,而上层的动作执行接缝只认 `AppWorldError`——不翻的话,容器挂掉会让整
次运行以一个未捕获的第三方异常炸出去,而正确的行为是记成环境故障、由库合成一段观察、
以 `env_error` 收尾。这个缺口是压测里真把容器 `docker kill` 掉之后才暴露的:在那之前
「连不上」这一档只写在上面那个 docstring 里,没有实现。
`asyncio.CancelledError` 不在 `httpx.HTTPError` 的范围内(它继承 `BaseException`),
所以取消照旧原样穿过去。
"""
try:
response = await client.post(f"{base_url}{path}", json=dict(body))
except httpx.HTTPError as exc:
raise AppWorldError(f"{path} 的请求没能完成:{type(exc).__name__}: {exc}") from exc
if response.is_error:
raise AppWorldError(f"{path} 返回 HTTP {response.status_code}{response.text[:2000]}")
payload = response.json()
if not isinstance(payload, dict) or "output" not in payload:
raise AppWorldError(f"{path} 的返回不是 {{'output': ...}} 的形态:{str(payload)[:500]}")
return payload["output"]
async def _run(command: Sequence[str]) -> tuple[int, str, str]:
"""跑一条 docker 命令,返回 (退出码, stdout, stderr)。"""
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
assert process.returncode is not None # communicate() 返回后必然已退出
return process.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace")
__all__ = [
"CONTAINER_NAME_PREFIX",
"DEFAULT_IMAGE",
"DEFAULT_PORT_BASE",
"AppWorldError",
"AppWorldPool",
"AppWorldSession",
"ContainerPool",
"ContainerPoolError",
"Mount",
"TaskScore",
"container_args_for_port",
]