feat(soak): AppWorld 场景适配器——代码围栏解释器与容器执行器
复刻 dissect 的动作协议当压测负载:两条正则、first_only 策略、五条纠错说明逐字照抄, 提示词模板连同出处一起收进 tools/soak/prompts/。有两处刻意不同,都写在代码注释里—— 未闭合围栏那一支不补回三反引号(公共契约要求回填历史的文本不长于模型原文),空围栏 那一支不截断。 执行器在 execute 内部顺带问一次 is_done 填 env_reported_completion,并把环境自己数的 执行次数透出来。那个数字是故障注入的判据来源:验「声明绝不重放的动作没有被执行两次」 必须数环境侧,数库自己的计数器等于我们和我们自己对账。 端到端真跑过一道题(82e2fac_1):8 步 task_completed,环境判分通过,事件数与步数与 环境执行次数三者相等,耗时 30 秒。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""压测场景:把某一个具体 benchmark 接到 PolyLoop 的公共接缝上。
|
||||
|
||||
一个「场景」要交的东西是固定的四样:一个决策解释器(模型输出 → 动作)、一个动作执行器
|
||||
(动作 → 观察)、一份上下文(提示词装配成消息序列)、以及一份装好的 `RunRequest`。压测
|
||||
的驱动侧只认这四样,换一个 benchmark 就是换一个本包下的模块。
|
||||
|
||||
**这一层允许知道 benchmark 的领域细节**,`src/polyloop/` 不允许(CLAUDE.md §1.1)。库里
|
||||
一个业务词都不能有,而「AppWorld 的动作是一段 Python 代码」这种话必须写在某个地方,写在
|
||||
这里。
|
||||
"""
|
||||
@@ -0,0 +1,617 @@
|
||||
"""AppWorld 场景:把 `tools/soak/appworld.py` 的环境层包成 PolyLoop 认得的四样东西。
|
||||
|
||||
交出去的是一个决策解释器(`AppWorldParser`)、一个动作执行器(`AppWorldExecutor`)、一份
|
||||
上下文(`build_context`)和一份装好的运行请求(`build_run_request`)。库本体只认这几个接缝,
|
||||
对「AppWorld 的动作是一段 Python 代码」「主管的名字要写进提示词」一无所知,那些全在这里。
|
||||
|
||||
**解析协议逐字复刻 dissect 的 `harness/agent/parser.py`,但一行都不 import 它。** PolyLoop
|
||||
是被 dissect 依赖的库,反向 import 下游是硬约束(CLAUDE.md §1.2,由 import-linter 断言)。
|
||||
那个文件在这里只当协议文档看:两条正则、判定顺序、五条纠错说明的中文原文都照抄,实现是
|
||||
独立写的。复刻而不是复用的代价是它会漂移,收益是压测负载与 dissect 的生产解析行为逐位一致,
|
||||
于是压出来的解析失败率、步数分布对 dissect 有参考价值。
|
||||
|
||||
**提示词模板来自 AppWorld 官方(Apache-2.0),经 dissect 转手拷进 `tools/soak/prompts/`。**
|
||||
它用行首独占一行的 `USER:` / `ASSISTANT:` 标记切分消息,不是消息数组——三百多行、十来个
|
||||
来回的示例演示当成一篇连续文本读和改,比在结构化配置里写数组可维护得多,而且能与官方模板
|
||||
逐行 diff。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from polyloop.ports import Action, InvalidDecision, ParsedReply
|
||||
from polyloop.session import RunRequest
|
||||
from polyloop.tools import ToolRegistry
|
||||
from polyloop.types import (
|
||||
ActionOutcome,
|
||||
ActionStatus,
|
||||
Budget,
|
||||
Context,
|
||||
Message,
|
||||
ReplayPolicy,
|
||||
Role,
|
||||
SyntheticObservations,
|
||||
TextBlock,
|
||||
)
|
||||
from tools.soak.appworld import AppWorldError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from polyloop.types import ModelReply
|
||||
from tools.soak.appworld import AppWorldPool, AppWorldSession
|
||||
|
||||
|
||||
class AppWorldScenarioError(RuntimeError):
|
||||
"""这个场景装配不出来:模板缺变量、格式切不出消息、或环境给的必需信息是空的。
|
||||
|
||||
只有一个错误类型,因为调用方对这几种情形的处置完全一样——压测跑不起来,人得去看一眼。
|
||||
分成三个类只会让每一处 `except` 都要写三个名字。
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 一、决策解释:从模型输出里抽出要执行的代码
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: 完整的围栏:```python 到配对的 ```,且**闭合围栏必须独占一行**。逐字照抄 dissect 的
|
||||
#: `harness/agent/parser.py:44-46`。
|
||||
#:
|
||||
#: 「独占一行」这个约束是在修一个真 bug。写成非贪婪的 ```` ```python\s*\n(.*?)``` ```` 时,
|
||||
#: 匹配遇到代码内部的三反引号就提前收尾——`print("```")` 会被截成 `print("`,一段语法错的
|
||||
#: 残码被判成「解析成功」交给环境,同时模型输出被截断到那个伪结尾,后半段真正的代码从历史里
|
||||
#: 消失。模型下一轮看到自己被腰斩的输出加一个语法错误,通常重写同一段代码,于是稳定循环到
|
||||
#: 步数耗尽。AppWorld 里 agent 写邮件正文、生成 markdown 报告、打印带反引号的 API 文档摘录
|
||||
#: 时都会踩到。
|
||||
#:
|
||||
#: 中间那段 `(?:\r?\n[ \t]*)?` 是可选的,为的是让空块 ```` ```python\n``` ```` 也能被识别成
|
||||
#: 「块存在但内容为空」,从而给出对症的纠错说明。每处空白类都带上 `\r`,因为模型的输出可能
|
||||
#: 是 Windows 换行——漏掉它的话 ```` ```python\r\n ```` 匹配不上,整条输出退到「未闭合」分支
|
||||
#: 去,`\r` 还会留在代码末尾。
|
||||
_FULL_BLOCK = re.compile(r"```python[ \t\r]*\n(.*?)(?:\r?\n[ \t]*)?```[ \t\r]*(?=\n|$)", re.DOTALL)
|
||||
|
||||
#: 未闭合的围栏:```python 之后一直到文本结束。逐字照抄 `parser.py:52`。
|
||||
#:
|
||||
#: 它是给「模型配了 stop 序列」那种配置兜底的:AppWorld 官方 CI 配的 stop 是 "```\n",那样
|
||||
#: 模型的输出会正好停在闭合围栏之前,看起来就是缺了结尾。没有这条兜底,那种配置每一步都会
|
||||
#: 解析失败。压测这一路不配 stop 序列,所以它基本不会被触发。
|
||||
_PARTIAL_BLOCK = re.compile(r"```python[ \t\r]*\n(.*)", re.DOTALL)
|
||||
|
||||
#: 追加给纠错说明的一句提示。逐字照抄 `parser.py:59`。
|
||||
#:
|
||||
#: 多围栏策略取 first_only,模型输出会被截断到第一个围栏结束,它下一轮看到的是自己被腰斩的
|
||||
#: 回复。不说明原因的话,它多半会以为输出被网络截断了而原样重发,白烧一步。
|
||||
_ONLY_FIRST_BLOCK_HINT = "另外请注意:每一步只写一个代码块,我只会执行第一个。"
|
||||
|
||||
#: 未闭合围栏里什么都没有。逐字照抄 `parser.py:114-117`。
|
||||
_EMPTY_PARTIAL_BLOCK = (
|
||||
"你的回复里有一个 ```python 代码块,但它是空的。请在代码块里写出这一步要执行的 Python 代码。"
|
||||
)
|
||||
|
||||
#: 闭合围栏后面还跟着别的东西。逐字照抄 `parser.py:128-131`。
|
||||
_FENCE_NOT_ON_ITS_OWN_LINE = (
|
||||
"你的代码块结尾的 ``` 后面还跟了别的内容。请让结尾的 ``` 单独占一行,"
|
||||
"后面直接换行。" + _ONLY_FIRST_BLOCK_HINT
|
||||
)
|
||||
|
||||
#: 一个围栏都没有。逐字照抄 `parser.py:141-144`。
|
||||
_NO_CODE_BLOCK = (
|
||||
"你的回复里没有可执行的代码块。请把这一步要执行的 Python 代码放进一个 "
|
||||
"```python 开头、``` 结尾的代码块里;每一步只写一个代码块。"
|
||||
)
|
||||
|
||||
#: 第一个完整围栏是空的。逐字照抄 `parser.py:161-165`。
|
||||
_EMPTY_FIRST_BLOCK = (
|
||||
"你的回复里第一个 ```python 代码块是空的。"
|
||||
"请在代码块里写出这一步要执行的 Python 代码。" + _ONLY_FIRST_BLOCK_HINT
|
||||
)
|
||||
|
||||
|
||||
class AppWorldParser:
|
||||
"""把模型输出解释成一段要在 AppWorld 里执行的 Python 代码。满足 `polyloop.ports.DecisionParser`。
|
||||
|
||||
**多围栏策略固定为 first_only**,不做成构造参数。dissect 把它做成显式配置项是因为那是
|
||||
它要扫动的实验因子(对照组 smolagents 把所有代码块拼起来执行);压测只需要一份确定的
|
||||
负载,多留一个开关只会让「这次压的是哪一路」多一个变量。
|
||||
|
||||
**不显式继承那个 Protocol**:结构化子类型不需要继承,继承会让这个模块 import 一个只用来
|
||||
做名义基类的东西。
|
||||
"""
|
||||
|
||||
def parameters(self) -> Mapping[str, str]:
|
||||
"""上报可复现参数:动作语言与多围栏策略。
|
||||
|
||||
两者都是「模型看得见的东西」的一部分——换一种策略续跑,前几步与后几步对同一份模型
|
||||
输出的解释就不一样了,而两段轨迹在文件里看起来是同一次运行。
|
||||
"""
|
||||
return {"kind": "appworld_code_fence", "multi_block_policy": "first_only"}
|
||||
|
||||
def parse(self, reply: ModelReply) -> ParsedReply:
|
||||
"""抽出要执行的代码。**同步,且对任何输入都不抛异常。**
|
||||
|
||||
判定顺序照 dissect 的 `parse_code_action`(`parser.py:90-145`):先找全部完整围栏,
|
||||
没有就退到未闭合围栏,再没有就判失败。三条支路里只有第一条会截断回填历史的那段文本。
|
||||
|
||||
解释不出来时返回「无效决策」而不是抛异常(`design/0007` 决策三):那一步照常留痕、
|
||||
纠错说明回喂给模型、循环继续。
|
||||
"""
|
||||
output = reply.content
|
||||
|
||||
matches = list(_FULL_BLOCK.finditer(output))
|
||||
if matches:
|
||||
first = matches[0]
|
||||
code = first.group(1).strip()
|
||||
if not code:
|
||||
return ParsedReply(
|
||||
history_text=output,
|
||||
decision=InvalidDecision(explanation=_EMPTY_FIRST_BLOCK),
|
||||
)
|
||||
# 截断到第一个围栏结束:模型常在代码块后面自行编造「执行结果」,留着的话下一轮
|
||||
# 它会把那段幻想当成真发生过的事。
|
||||
return ParsedReply(
|
||||
history_text=output[: first.end()],
|
||||
decision=Action(text=code, tool_call=None),
|
||||
)
|
||||
|
||||
partial = _PARTIAL_BLOCK.search(output)
|
||||
if partial:
|
||||
code = partial.group(1).strip()
|
||||
if not code:
|
||||
return ParsedReply(
|
||||
history_text=output,
|
||||
decision=InvalidDecision(explanation=_EMPTY_PARTIAL_BLOCK),
|
||||
)
|
||||
if "```" in code:
|
||||
# 走到这一支说明:文本里有闭合围栏,但它不满足「独占一行」,例如
|
||||
# ```` ```python\nprint(1)\n``` done ````。这不是「被 stop 序列截断的未闭合
|
||||
# 块」,是格式不规范。若照兜底分支处理,围栏连同后面的散文会一起被当成代码
|
||||
# 交给环境执行,而且判定为解析成功——环境返回一个语法错误,事后统计会把它
|
||||
# 算成「模型写错代码」,而实际是我们解析错了。
|
||||
return ParsedReply(
|
||||
history_text=output,
|
||||
decision=InvalidDecision(explanation=_FENCE_NOT_ON_ITS_OWN_LINE),
|
||||
)
|
||||
# **这里与被复刻的 dissect 实现有一处刻意的不同**:dissect 在这一支给历史文本补回
|
||||
# 结尾的三反引号(`parser.py:133-136`),让那条 assistant 消息形态完整;我们不补,
|
||||
# 因为公共契约要求 `len(history_text) <= len(reply.content)`
|
||||
# (`tests/contract/test_decision_parser.py:30`),补一个字符就违约。代价可以接受:
|
||||
# 压测不给模型配 stop 序列,这条路径基本不触发。代码抽取本身照旧兜底。
|
||||
return ParsedReply(
|
||||
history_text=output,
|
||||
decision=Action(text=code, tool_call=None),
|
||||
)
|
||||
|
||||
return ParsedReply(
|
||||
history_text=output,
|
||||
decision=InvalidDecision(explanation=_NO_CODE_BLOCK),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 二、动作执行:把代码交给容器里的 AppWorld 环境
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AppWorldExecutor:
|
||||
"""在一个已经开好的 AppWorld 会话里执行代码。满足 `polyloop.ports.ActionExecutor`。
|
||||
|
||||
一个实例绑定一道题的一次会话,跟 `RunRequest` 一样是每次运行一个。
|
||||
"""
|
||||
|
||||
def __init__(self, *, session: AppWorldSession) -> None:
|
||||
"""Args:
|
||||
session: 已经实例化好一道题的会话,由 `AppWorldPool.session()` 产出。
|
||||
"""
|
||||
self._session = session
|
||||
|
||||
@property
|
||||
def env_executions(self) -> int:
|
||||
"""环境自己数的实际执行次数。
|
||||
|
||||
**它必须来自环境,不能是本地计数器。** 故障注入那一步要验的是「库说走了几步」与
|
||||
「环境真的被执行了几次」对不对得上,用本地计数器的话两边都出自我们自己,验的就成了
|
||||
我们和我们自己一致。
|
||||
"""
|
||||
return self._session.n_executions
|
||||
|
||||
def parameters(self) -> Mapping[str, str]:
|
||||
"""上报可复现参数:动作接口的种类与这次做的是哪道题。
|
||||
|
||||
**刻意不放 `base_url`。** 容器端口是从池里租来的,每次运行、甚至同一次运行的每道题
|
||||
都不同;进了参数快照,续跑时的逐字段比对必然报参数漂移,而那是一次假故障。
|
||||
"""
|
||||
return {"kind": "appworld_session", "task_id": self._session.task_id}
|
||||
|
||||
async def execute(self, action: Action) -> ActionOutcome:
|
||||
"""执行一段代码,顺带问一次环境「做完了没有」。
|
||||
|
||||
**代码报错、超时、语法错都不是异常**——环境把 traceback 或超时提示原样放在返回文本里,
|
||||
那是给模型看的正常观察,所以这一路是 `EXECUTED` 而不是 `ENV_ERROR`。只有环境本身坏了
|
||||
(连不上、HTTP 非 2xx、返回体不是约定形状)才会抛 `AppWorldError`。这条分界照
|
||||
`polyloop.tools.RegistryExecutor` 的同名分支填。
|
||||
|
||||
**`CancelledError` 不捕获**(CLAUDE.md §1.6):`AppWorldError` 是 `RuntimeError` 的
|
||||
子类,下面那个 `except` 接不到继承自 `BaseException` 的取消。别的未预料异常同样不接——
|
||||
压测就是要看见它们,吞掉只会让故障变成一条内容古怪的观察(CLAUDE.md §1.7)。
|
||||
"""
|
||||
try:
|
||||
observation = await self._session.execute(action.text)
|
||||
# 完成信号只有环境给得出(agent 调没调过 `apis.supervisor.complete_task()`),
|
||||
# 而它每一步都可能翻转,所以每一步都问一次。
|
||||
env_reported_completion = await self._session.is_done()
|
||||
except AppWorldError as exc:
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.ENV_ERROR,
|
||||
observation=str(exc),
|
||||
# 这段文本是环境(或与环境通信的那一层)给的,不是库合成的占位。
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.EXECUTED,
|
||||
observation=observation,
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=env_reported_completion,
|
||||
# 这一层不截断观察。AppWorld 的输出由环境侧自己限长,我们原样转交。
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 三、提示词装配:模板文本 → 消息序列
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: 提示词模板所在目录。两个文件来自 AppWorld 官方(Apache-2.0),经 dissect 转手,内容不改。
|
||||
PROMPTS_DIR = Path(__file__).resolve().parents[1] / "prompts" / "appworld"
|
||||
|
||||
#: 行首独占一行的角色标记。逐字照抄 dissect 的 `harness/agent/context.py:31`。
|
||||
#: 官方模板只用 USER / ASSISTANT 两种(ReAct 不使用 system 角色),SYSTEM 一并认得。
|
||||
_ROLE_MARK = re.compile(r"^(USER|ASSISTANT|SYSTEM):\s*$", re.MULTILINE)
|
||||
|
||||
_ROLE_OF = {"USER": Role.USER, "ASSISTANT": Role.ASSISTANT, "SYSTEM": Role.SYSTEM}
|
||||
|
||||
#: 模板里的变量占位符:`{{ name }}` 与 `{{ name.attr }}` 两种形态,花括号内两侧允许有空格。
|
||||
#:
|
||||
#: **刻意不用 jinja2**:当前环境里没有它,而这两个模板一共只用到 `app_descriptions`、
|
||||
#: `main_user` 的四个字段和 `instruction`。为一个五行的替换规则拉一个模板引擎进来,压测就多
|
||||
#: 了一个装不上就跑不起来的依赖。
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\s*\}\}")
|
||||
|
||||
#: 主管信息里提示词要用到的四个字段。
|
||||
_SUPERVISOR_FIELDS = ("first_name", "last_name", "email", "phone_number")
|
||||
|
||||
|
||||
def split_by_role(text: str, *, where: str) -> list[tuple[Role, str]]:
|
||||
"""按行首角色标记把整段模板切成 `(角色, 正文)` 列表。
|
||||
|
||||
Args:
|
||||
text: 模板原文。
|
||||
where: 出错信息里用来指认是哪份模板。
|
||||
|
||||
Raises:
|
||||
AppWorldScenarioError: 没有任何角色标记、第一个标记之前有内容、或某一段是空的。
|
||||
"""
|
||||
marks = list(_ROLE_MARK.finditer(text))
|
||||
if not marks:
|
||||
raise AppWorldScenarioError(f"{where} 里没有任何 USER:/ASSISTANT: 行首标记,切不出消息列表")
|
||||
if text[: marks[0].start()].strip():
|
||||
raise AppWorldScenarioError(f"{where} 的第一个角色标记之前有内容,无法归属到某个角色")
|
||||
|
||||
blocks: list[tuple[Role, str]] = []
|
||||
for index, mark in enumerate(marks):
|
||||
end = marks[index + 1].start() if index + 1 < len(marks) else len(text)
|
||||
content = text[mark.end() : end].strip()
|
||||
if not content:
|
||||
raise AppWorldScenarioError(f"{where} 里第 {index + 1} 个消息块是空的")
|
||||
blocks.append((_ROLE_OF[mark.group(1)], content))
|
||||
return blocks
|
||||
|
||||
|
||||
def render_template(text: str, variables: Mapping[str, object], *, where: str) -> str:
|
||||
"""把 `{{ 变量 }}` 换成取值。
|
||||
|
||||
**变量缺失显式报错,不当空串。** 渲染成空串的表现是提示词里凭空少一段,模型成绩下降,
|
||||
而没有任何地方会告诉你原因。
|
||||
|
||||
**残留的 `{{` 也报错。** 静默留下一个没替换的占位符会让模型看见字面量 `{{ instruction }}`,
|
||||
而那在轨迹里看起来只是模型表现差。这道检查落在**模板**上而不是渲染结果上:合法占位符
|
||||
先从模板里抠掉,剩下的文本里还有 `{{` 就说明写了一个形态不对的占位符(`{{ a-b }}`、
|
||||
少一个右花括号之类)。查渲染结果的话,插值内容里恰好带 `{{` 就会误报一次。
|
||||
|
||||
Raises:
|
||||
AppWorldScenarioError: 有形态不对的占位符、变量没提供、或取值不是字符串。
|
||||
"""
|
||||
residual = _PLACEHOLDER.sub("", text)
|
||||
if "{{" in residual:
|
||||
raise AppWorldScenarioError(
|
||||
f"{where} 里有形态不对的占位符:抠掉全部合法的 {{{{ 变量 }}}} 之后仍残留 '{{{{'。"
|
||||
f"渲染器只认 {{{{ name }}}} 与 {{{{ name.attr }}}} 两种形态"
|
||||
)
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
return _lookup(match.group(1), match.group(2), variables, where=where)
|
||||
|
||||
return _PLACEHOLDER.sub(replace, text)
|
||||
|
||||
|
||||
def render_to_messages(
|
||||
text: str, variables: Mapping[str, object], *, where: str
|
||||
) -> tuple[Message, ...]:
|
||||
"""先按角色标记切分**模板原文**,再逐段渲染。
|
||||
|
||||
**顺序不能反。** 反过来会开一个「内容能改变结构」的口子:`run_prefix.txt` 里的
|
||||
`{{ app_descriptions }}` 位于一个代码围栏内部,如果先整段渲染再切分,那么只要环境返回的
|
||||
文本里出现独占一行的 `USER:`,前缀就会被切出多余的消息——而且不报错。先切后渲染,插值
|
||||
内容永远只能落在某一条消息**内部**,改不了消息边界。
|
||||
|
||||
Raises:
|
||||
AppWorldScenarioError: 切不出消息、渲染出问题、或某一段渲染后是空的。
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
for index, (role, content) in enumerate(split_by_role(text, where=where)):
|
||||
rendered = render_template(content, variables, where=f"{where} 第 {index + 1} 个消息块")
|
||||
rendered = rendered.strip()
|
||||
if not rendered:
|
||||
raise AppWorldScenarioError(f"{where} 里第 {index + 1} 个消息块渲染后是空的")
|
||||
messages.append(Message(role=role, content=(TextBlock(text=rendered),)))
|
||||
return tuple(messages)
|
||||
|
||||
|
||||
def build_context(*, session: AppWorldSession, app_descriptions: str) -> Context:
|
||||
"""把两份模板渲染成 PolyLoop 的上下文。
|
||||
|
||||
分两段是因为供应商按前缀缓存计费:`run_prefix.txt` 整个压测里逐字节不变,`item_suffix.txt`
|
||||
每道题都不同。把逐题变化的东西排到前面会让缓存静默失效。
|
||||
|
||||
Args:
|
||||
session: 已开好的会话,题面与主管信息从它取。
|
||||
app_descriptions: 全 run 恒定的可用 app 清单,见 `load_app_descriptions`。
|
||||
|
||||
Raises:
|
||||
AppWorldScenarioError: 模板文件缺失、切不出消息、或主管信息有空字段。
|
||||
"""
|
||||
run_prefix, item_suffix = _read_templates()
|
||||
supervisor = _verified_supervisor(session)
|
||||
return Context(
|
||||
run_level=render_to_messages(
|
||||
run_prefix,
|
||||
{"app_descriptions": app_descriptions},
|
||||
where="run_prefix.txt",
|
||||
),
|
||||
goal_level=render_to_messages(
|
||||
item_suffix,
|
||||
{"main_user": supervisor, "instruction": session.instruction},
|
||||
where="item_suffix.txt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_templates() -> tuple[str, str]:
|
||||
"""读两份模板原文。缺文件直接报错,不兜底。"""
|
||||
texts: list[str] = []
|
||||
for name in ("run_prefix.txt", "item_suffix.txt"):
|
||||
path = PROMPTS_DIR / name
|
||||
if not path.is_file():
|
||||
raise AppWorldScenarioError(f"提示词模板缺文件:{path}")
|
||||
texts.append(path.read_text(encoding="utf-8"))
|
||||
return texts[0], texts[1]
|
||||
|
||||
|
||||
def _verified_supervisor(session: AppWorldSession) -> dict[str, str]:
|
||||
"""取主管信息,并确认提示词要用的四个字段都非空。
|
||||
|
||||
单独校验是因为渲染器只拦得住「键不存在」,拦不住「键存在但值是 None 或空串」——后者会
|
||||
被安静地渲染成字面量 `None` 或一段空白,于是提示词变成 `My name is: Jose None.`、
|
||||
`phone number is `,而模型会照着这个去调 API 查一个不存在的人。这类故障不报错,只让
|
||||
成绩变差。
|
||||
"""
|
||||
supervisor = dict(session.supervisor)
|
||||
missing = [key for key in _SUPERVISOR_FIELDS if not supervisor.get(key)]
|
||||
if missing:
|
||||
raise AppWorldScenarioError(
|
||||
f"题目 {session.task_id} 的主管信息缺字段 {missing}(值为空或 None)。"
|
||||
f"提示词要用它们介绍任务委托人,缺了会误导模型去查一个不存在的人"
|
||||
)
|
||||
return supervisor
|
||||
|
||||
|
||||
def _lookup(
|
||||
name: str, attribute: str | None, variables: Mapping[str, object], *, where: str
|
||||
) -> str:
|
||||
"""取一个占位符的值。缺任何一环都报错。"""
|
||||
if name not in variables:
|
||||
raise AppWorldScenarioError(
|
||||
f"{where} 用到了变量 {name!r},但没有提供它;已提供的是 {sorted(variables)}"
|
||||
)
|
||||
value = variables[name]
|
||||
|
||||
if attribute is None:
|
||||
if not isinstance(value, str):
|
||||
raise AppWorldScenarioError(
|
||||
f"{where} 的变量 {name!r} 要直接插进文本,取值必须是字符串,"
|
||||
f"收到 {type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
if not isinstance(value, Mapping):
|
||||
raise AppWorldScenarioError(
|
||||
f"{where} 取 {name}.{attribute},但 {name!r} 不是一份映射,收到 {type(value).__name__}"
|
||||
)
|
||||
if attribute not in value:
|
||||
raise AppWorldScenarioError(
|
||||
f"{where} 取 {name}.{attribute},但 {name!r} 里没有这个键;实有 {sorted(value)}"
|
||||
)
|
||||
item = value[attribute]
|
||||
if not isinstance(item, str):
|
||||
raise AppWorldScenarioError(
|
||||
f"{where} 的 {name}.{attribute} 取值必须是字符串,收到 {type(item).__name__}"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 四、run 级变量:可用 app 清单
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: 问环境要 app 清单的那段代码,出自官方提示词自己教的三个 API 之一。
|
||||
_APP_DESCRIPTIONS_CODE = "print(apis.api_docs.show_app_descriptions())"
|
||||
|
||||
#: 缓存文件的默认位置。它进 `.gitignore`——这份文本是环境的产物,不是源码。
|
||||
DEFAULT_APP_DESCRIPTIONS_CACHE = (
|
||||
Path(__file__).resolve().parents[1] / ".cache" / "app_descriptions.txt"
|
||||
)
|
||||
|
||||
|
||||
async def load_app_descriptions(
|
||||
pool: AppWorldPool,
|
||||
*,
|
||||
task_id: str,
|
||||
cache_path: Path | str = DEFAULT_APP_DESCRIPTIONS_CACHE,
|
||||
) -> str:
|
||||
"""取可用 app 的清单,优先读磁盘缓存。
|
||||
|
||||
它是 run 级变量:整个压测里所有任务共用同一份,而且它是提示词固定前缀的一部分,逐题重取
|
||||
只会白占一个容器。第一次要开一个会话去问环境,之后直接读缓存。
|
||||
|
||||
Args:
|
||||
pool: 已经 `start()` 过的环境入口。只有缓存不存在时才会用到它。
|
||||
task_id: 借哪道题来开这个会话。问的是全局的 app 文档,跟具体是哪道题无关。
|
||||
cache_path: 缓存文件。
|
||||
|
||||
Raises:
|
||||
AppWorldError: 环境返回了空文本。空的 app 列表会让整批任务全部失败,而表现是
|
||||
「模型不会用 API」——静默用空串的话,这个故障永远查不出来。
|
||||
AppWorldScenarioError: 缓存文件存在但内容是空的。
|
||||
"""
|
||||
path = Path(cache_path)
|
||||
if path.exists():
|
||||
cached = path.read_text(encoding="utf-8")
|
||||
if not cached.strip():
|
||||
raise AppWorldScenarioError(
|
||||
f"app 清单的缓存文件 {path} 是空的。删掉它重新取;"
|
||||
f"空清单会让整批任务全部失败,而表现是「模型不会用 API」"
|
||||
)
|
||||
return cached
|
||||
|
||||
async with pool.session(task_id) as session:
|
||||
descriptions = await session.execute(_APP_DESCRIPTIONS_CODE)
|
||||
if not descriptions.strip():
|
||||
raise AppWorldError(
|
||||
f"环境对 {_APP_DESCRIPTIONS_CODE} 返回了空文本。它是提示词里的 app 清单,"
|
||||
f"空的话整批任务都会失败,而表现是「模型不会用 API」"
|
||||
)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(descriptions, encoding="utf-8")
|
||||
return descriptions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 五、装配:预算、合成观察、运行请求
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: 环境输出回喂给模型时的包装格式。逐字照抄 dissect 的
|
||||
#: `config/agent/appworld.yaml:31`,它本身照抄 AppWorld 官方——提示词里那整段十来个来回的
|
||||
#: 示例演示都按这个格式写,改一个字符就会让示例与实况对不上,而那正是这份提示词最想避免的事。
|
||||
OBSERVATION_TEMPLATE = "Output:\n```\n{observation}\n```\n\n"
|
||||
|
||||
#: 取消进来之后留给库写结束记录的秒数。
|
||||
_CANCEL_GRACE_SECONDS = 5.0
|
||||
|
||||
|
||||
def build_budget() -> Budget:
|
||||
"""AppWorld 这一路的预算。四个数字的来历各不相同,都写在下面。"""
|
||||
return Budget(
|
||||
# 40 而不是 AppWorld 官方当前实验配置的 50,照 dissect 的生产配置
|
||||
# (`config/agent/appworld.yaml:19`):它对齐的是 ASSAY 公开的裸 ReAct 配置。它必须
|
||||
# 小于等于环境侧的 max_interactions(`AppWorldPool` 的构造参数,默认也是 40),两者
|
||||
# 构成双保险。
|
||||
max_steps=40,
|
||||
# dissect 没有这一维。AppWorld 每步至多一个动作,所以取值等于 max_steps 在行为上无害:
|
||||
# 动作数永远追不上步数,这一维不会先于步数耗尽。
|
||||
max_actions=40,
|
||||
# 3 而不是 1,是因为偶发一次格式失手不该毁掉整道题——纠错说明回喂之后模型通常能自己
|
||||
# 纠正;3 而不是 10,是因为真陷进去之后每多一步都是白烧钱,40 步的预算经不起这种消耗。
|
||||
# 照 `appworld.yaml:36`。
|
||||
max_consecutive_parse_failures=3,
|
||||
# 提示词字符数的硬上限,**不是截断阈值,是安全网**。压测不做上下文截断,但也不能让
|
||||
# 提示词无限增长到撞上模型的上下文窗口。取值宽松,让它极少触发。照 `appworld.yaml:43`。
|
||||
max_prompt_chars=400000,
|
||||
)
|
||||
|
||||
|
||||
def build_synthetic_observations() -> SyntheticObservations:
|
||||
"""库合成、回填给模型看的那三段观察。
|
||||
|
||||
前两段照 dissect 的 `harness/agent/loop.py:35,38` 逐字。第三段 dissect 没有对应物:它的
|
||||
循环里没有「动作被拒绝」这一档,因为那一档只在工具注册表分发时才可能出现,而 AppWorld
|
||||
的动作是代码不是工具调用。`AppWorldExecutor` 永远不返回 `NOT_EXECUTED`,所以这段文本在
|
||||
这条路上不会被用到;字段是必填的,写一段与另外两段同样形态的话,比起随手填个空串,出现
|
||||
在轨迹里时至少还看得懂。
|
||||
"""
|
||||
return SyntheticObservations(
|
||||
action_rejected="[这一步的动作被拒绝,没有交给环境执行]",
|
||||
env_failed="[环境故障,这一步的动作没有被执行]",
|
||||
model_call_failed="[模型调用失败,这一步没有产出]",
|
||||
)
|
||||
|
||||
|
||||
def build_run_request(
|
||||
*,
|
||||
run_id: str,
|
||||
session: AppWorldSession,
|
||||
app_descriptions: str,
|
||||
model_binding: Mapping[str, str],
|
||||
) -> RunRequest:
|
||||
"""把一道题装配成一次运行的请求。
|
||||
|
||||
Args:
|
||||
run_id: 这次运行的标识,同时是日志主键。
|
||||
session: 已经实例化好这道题的会话。
|
||||
app_descriptions: run 级的 app 清单,见 `load_app_descriptions`。
|
||||
model_binding: 项目自己的标识,库不解释、原样透传给每次模型调用。
|
||||
|
||||
Raises:
|
||||
AppWorldScenarioError: 上下文装配不出来。
|
||||
"""
|
||||
return RunRequest(
|
||||
run_id=run_id,
|
||||
budget=build_budget(),
|
||||
action_executor=AppWorldExecutor(session=session),
|
||||
# 空注册表:AppWorld 的动作是一段代码,不走工具调用这条路。它与 `action_executor`
|
||||
# 同时存在不是重复——不注册工具的项目就是传一个空注册表加一个环境句柄。
|
||||
tools=ToolRegistry(),
|
||||
context=build_context(session=session, app_descriptions=app_descriptions),
|
||||
# 压测不注入 Skill 条目:注入是另一条正交的路,混进来会让「这次压的是什么」变多一维。
|
||||
injections={},
|
||||
model_binding=model_binding,
|
||||
# **NEVER,不可改成 SAFE。** AppWorld 的代码执行有真实副作用(转账、下单、发消息),
|
||||
# 而恢复时我们并不知道被打断的那次调用有没有真的执行到环境里。状态未知时重放一次
|
||||
# 转账,损坏的是环境状态本身,事后从轨迹里分辨不出来。这条是故障注入那一步要验的
|
||||
# 核心不变量。
|
||||
model_replay_policy=ReplayPolicy.NEVER,
|
||||
observation_template=OBSERVATION_TEMPLATE,
|
||||
cancel_grace_seconds=_CANCEL_GRACE_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_APP_DESCRIPTIONS_CACHE",
|
||||
"OBSERVATION_TEMPLATE",
|
||||
"PROMPTS_DIR",
|
||||
"AppWorldExecutor",
|
||||
"AppWorldParser",
|
||||
"AppWorldScenarioError",
|
||||
"build_budget",
|
||||
"build_context",
|
||||
"build_run_request",
|
||||
"build_synthetic_observations",
|
||||
"load_app_descriptions",
|
||||
"render_template",
|
||||
"render_to_messages",
|
||||
"split_by_role",
|
||||
]
|
||||
Reference in New Issue
Block a user