Files
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

622 lines
30 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 场景:把 `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 消息形态完整;这里不补。
# 这处不同当初是为了迁就公共契约里一条「history_text 不长于模型原文」的断言,那条
# 断言已经撤销(`polyloop.testing.DecisionParserContract` 里对应那条用例的 docstring
# 写着理由),所以它现在没有存在的必要,是一笔记在案的欠账,不是一条长期决定。
# 留着不恢复是因为收益不抵成本:补回去要重跑一次打真实网关的压测,才能继续说
# 2026-08-11 那批轨迹是当前这版场景跑出来的;而这条路径要模型输出被 stop 序列截断
# 才走得到,压测不给模型配 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",
]