"""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", ]