Webwright 中央 agent 循环
本章讲什么:
DefaultAgent那条while True到底怎么转——动作长什么样、什么时候退出、 上下文怎么不爆。看完你能改动循环、退出条件、压缩策略。
代码就一个文件:src/webwright/agents/default.py(≈468 行),类 DefaultAgent。
1. 循环的形状(先看骨架)
它要解决的小问题: 让模型和环境一来一回地把 任务推进到完成,同时管好步数、退出、落盘。
思路: 循环体只有一行——「查模型,执行它给的动作」。所有复杂性(退出、压缩)都挂在循环外围。
主循环在 DefaultAgent.run(default.py:341)里,核心就是:
# 示意,非源码:run() 的核心骨架
while True:
try:
self.step() # = execute_actions(query())
except InterruptAgentFlow as exc:
self.add_messages(*exc.messages) # 格式错误/超限等,注入消息后继续
finally:
self.save(self.config.output_path) # 每步都落盘 trajectory.json
if self.messages[-1].get("role") == "exit":
break # 只有 exit 消息能终止循环
# 每 N 步压缩一次历史
重点看两件事:
- 退出的唯一信号是一条
role == "exit"的消息(default.py:372)。不是异常、不是返回值—— 而是往消息列表尾部塞一条特殊角色的消息。谁塞?见 §3。 - 每一步都
save(default.py:371的finally)。哪怕中途崩了,轨迹也已经落盘。
step()(default.py:383)就是 execute_actions(self.query()):先问模型,再执行。下面拆这两半。
2. 动作协议:一段严格 JSON
它要解决的小问题: 模型的自由文本没法直接执行,得有个稳定、可解析的「动作格式」。
思路: 强制模型每turn只吐一个 JSON 对象,字段固定。这样解析零歧义,动作可直接落地。
模型每turn必须产出这样一个对象(引自 base.yaml 的 system 提示词,config/base.yaml:95):
{
"thought": "<观察、推理、下一步>",
"bash_command": "<恰好一条 shell 命令,声明完成时为空串>",
"done": false,
"final_response": "<done 时的最终答案,否则为空>"
}
实时浏览器模式把 bash_command 换成 python_code(config/local_browser.yaml:36 把 action_field
设为 python_code)。这个「动作字段名」是可配置的,由模型层 _response_schema 决定(见 03 章)。
query()(default.py:386)做三件事:
- 先查步数上限。
0 < step_limit <= n_calls时抛LimitsExceeded,它携带一条exit消息 (default.py:388),循环随即结束。base.yaml把step_limit设成 100(config/base.yaml:88;AgentConfig默认只有 15,见default.py:36)。 self.model.query(self.messages)拿到已解析好的 assistant 消息(解析在模型层做,见 03 章)。n_calls += 1,消息入列。
3. 执行动作与退出:done 不一定算数
execute_actions(message)(default.py:400)是循环里最有戏的一段。它先看模型有没有声明 done:
message.extra.done ?
│
├─ true ──▶ 门禁检查 _self_reflection_gate_error()
│ │
│ ├─ 返回错误串 ──▶ 把 done 改回 false,注入一条纠错 user 消息,继续循环
│ │
│ └─ None(通过) ──▶ 造一条 role="exit" 消息(带 final_response)→ 循环终止
│
└─ false ──▶ 对每个 action 调 env.execute() → 收集观察
→ format_observation_messages() 转成 user 消息(可带截图)
→ 入列,进入下一轮
「done 不一定算数」是关键设计。 在工作区模式,require_self_reflection_success=true
(config/base.yaml:89),于是 _self_reflection_gate_error(default.py:202)会转调
_tool_gate_error(default.py:208)去磁盘上核对:最新的 final_runs/run_<id>/self_reflect_result.json
是否存在且 predicted_label == 1。不满足就返回一段具体的纠错文字(比如「没有 final_runs 目录」
「predicted_label 不是 1,去诊断失败原因、改脚本、重跑」),Agent 把它当成一条新的 user 消息注入,
模型只能继续干。门禁的完整逻辑见 04 章。
实时浏览器模式把这个开关关掉(config/local_browser.yaml:78 设 require_self_reflection_success: false),
所以那边是模型自己说了算。
执行动作走的是 self.env.execute(action)(default.py:425)——注意 Agent 完全不关心环境是跑
shell 还是 exec Python,它只拿回一个带 observation 的字典。这层解耦让两种模式共用同一个循环。
4. 观察怎么喂回:文字 + 可选截图
它要解决的小问题: 环境执行完得让模型「看见」结果,才能决定下一步。
动作执行后,self.model.format_observation_messages(...)(在 base.py:376)把每个观察渲染成一条
user 消息。渲染用的是配置里的 observation_template(Jinja2),不同模式模板不同:
- 工作区模式(
config/base.yaml:27)展示:状态、工作区、cwd、命令、返回码、异常、命令输出、final_script.py路径。 - 实时模式(
config/local_browser.yaml:37)展示:状态、URL、标题、Python 输出、控制台输出、ARIA 快照、截图路径。
ARIA 快照是实时模式里模型的主要「视力」——它是页面 body 的无障碍树文本(元素角色+名字),
比截图省 token 又足够定位控件(local_browser.py:480 的 page.locator("body").aria_snapshot(...))。
截图默认不作为图像附给模型:base.yaml 设 attach_observation_screenshot: false
(config/base.yaml:26)。要视觉判断时,工作区模式让模型自己去调 image_qa 工具(见 04 章);
实时模式可在配置里打开 attach_observation_screenshot: true 真把 PNG 送进去(更贵更慢)。
可选地,观察后还能追加两条消息:重新贴一遍任务模板(attach_instance_template_after_observation)、
或把当前 plan.md 内容贴回来(attach_plan_md_after_observation,default.py:190 的 _plan_md_message)。
5. 让上下文不爆:两招
浏览器 agent 的观察(尤其 ARIA 快照,常 10–20k 字符)会把上下文迅速撑爆。Webwright 有两招控住它。
5.1 ARIA 裁剪:只留最近 N 条快照
思路: 旧观察里的 ARIA 快照没用了,原地替换成占位符,保留 URL/标题/输出。
_prune_old_observation_aria_snapshots(default.py:275)每次 add_messages 后跑一遍:找出所有带
observation 的消息,若超过 keep_last_n_observations 条,就把靠前那些消息里的 ARIA 文本替换成
(ARIA snapshot pruned; ...)。实时模式设 keep_last_n_observations: 1(config/local_browser.yaml:85),
即只保留最新一步的完整 ARIA。工作区模式默认 -1(不裁剪,default.py:45)。
5.2 历史压缩:让模型自己写摘要
思路: 每跑 N 步,让模型把「到目前为止的全部进展」总结成一段话,然后把历史清成 [系统消息, 摘要]。
_compact_history(default.py:303)在 run 循环里按 summary_every_n_steps(base.yaml 设 20,
config/base.yaml:90)触发。它:
- 保留原始 system 消息。
- 把当前所有消息 + 一条「请写完整摘要」的 user 提示,发给模型做一次额外调用。
- 用返回的摘要文本,把
self.messages重置为[system_message, summary_message](default.py:339)。
压缩用的提示词也分模式:base.yaml 的默认摘要提示让模型记住工作区路径、plan.md、已满足/未满足的
critical points(default.py:16 的 DEFAULT_SUMMARY_USER_PROMPT);实时模式覆盖成记住页面 URL、
已开的抽屉、已选的筛选 chip 等浏览器状态(config/local_browser.yaml:92)。压缩调用若抛异常,
静默放弃、绝不弄崩整个 run(default.py:322 的 except Exception: return)。
6. 巧妙之处
- 退出用「消息」而非异常/返回码。 超限、格式错、正常完成,统一表现为往消息尾塞一条不同
exit_status的exit消息(default.py:388/default.py:416)。循环只认role=="exit",逻辑极干净。 - done 门禁把「模型自评」变成「另一个模型签字」。 模型再自信也过不了磁盘上那个
predicted_label==1的硬检查(default.py:259)——这是它在基准上稳的重要原因。 - 每步落盘 + 消息落盘前脱敏。
_sanitize_message_for_disk(default.py:49)把 base64 图像 data-url 换成<omitted:data-url>再写trajectory.json,轨迹文件不会被图片撑爆。 - debug 产物是「人可读的复盘材料」。
_write_debug_step_artifact(default.py:98)每步写debug/steps/step_NNNN.json和一份追加式debug/steps.md(带 thought、生成的代码、观察), 方便事后按 Markdown 翻。
7. 边界与局限
- 单线程、同步循环。 一次跑一个任务,一步接一步,没有并发 agent、没有图引擎(这正是卖点,
见
README.md「Why Webwright」)。 - 压缩是有损的。
_compact_history把历史整段换成模型摘要,漏掉的细节就找不回来了。 - 步数上限是硬墙。 到
step_limit直接LimitsExceeded退出,可能任务没完成(default.py:388)。 - 门禁只看「最新一次 run」。
_tool_gate_error取run_<id>里 id 最大的那个(default.py:242), 历史 run 的成功不算数。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 主循环 / 退出条件 | src/webwright/agents/default.py | DefaultAgent.run |
| 单步 = 查模型+执行 | src/webwright/agents/default.py | DefaultAgent.step / DefaultAgent.query |
| done 处理 + 观察喂回 | src/webwright/agents/default.py | DefaultAgent.execute_actions |
| done 门禁(截图裁判) | src/webwright/agents/default.py | DefaultAgent._tool_gate_error / _self_reflection_gate_error |
| 历史压缩 | src/webwright/agents/default.py | DefaultAgent._compact_history |
| ARIA 裁剪 | src/webwright/agents/default.py | DefaultAgent._prune_old_observation_aria_snapshots |
| 落盘脱敏 | src/webwright/agents/default.py | _sanitize_message_for_disk |
| debug 产物 | src/webwright/agents/default.py | DefaultAgent._write_debug_step_artifact |
| Agent 配置字段 | src/webwright/agents/default.py | AgentConfig |
| 提示词/步数/开关 | src/webwright/config/base.yaml | agent: 块 |
下一步: 动作到底被谁、怎么执行?看 02-environments.md。