跳到主要内容

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 步压缩一次历史

重点看两件事:

  1. 退出的唯一信号是一条 role == "exit" 的消息(default.py:372)。不是异常、不是返回值—— 而是往消息列表尾部塞一条特殊角色的消息。谁塞?见 §3。
  2. 每一步都 save(default.py:371finally)。哪怕中途崩了,轨迹也已经落盘。

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:36action_field 设为 python_code)。这个「动作字段名」是可配置的,由模型层 _response_schema 决定(见 03 章)。

query()(default.py:386)做三件事:

  1. 先查步数上限。 0 < step_limit <= n_calls 时抛 LimitsExceeded,它携带一条 exit 消息 (default.py:388),循环随即结束。base.yamlstep_limit 设成 100(config/base.yaml:88; AgentConfig 默认只有 15,见 default.py:36)。
  2. self.model.query(self.messages) 拿到已解析好的 assistant 消息(解析在模型层做,见 03 章)。
  3. 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:78require_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:480page.locator("body").aria_snapshot(...))。

截图默认不作为图像附给模型:base.yamlattach_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)触发。它:

  1. 保留原始 system 消息。
  2. 把当前所有消息 + 一条「请写完整摘要」的 user 提示,发给模型做一次额外调用。
  3. 用返回的摘要文本,把 self.messages 重置为 [system_message, summary_message](default.py:339)。

压缩用的提示词也分模式:base.yaml 的默认摘要提示让模型记住工作区路径、plan.md、已满足/未满足的 critical points(default.py:16DEFAULT_SUMMARY_USER_PROMPT);实时模式覆盖成记住页面 URL、 已开的抽屉、已选的筛选 chip 等浏览器状态(config/local_browser.yaml:92)。压缩调用若抛异常, 静默放弃、绝不弄崩整个 run(default.py:322except Exception: return)。


6. 巧妙之处

  • 退出用「消息」而非异常/返回码。 超限、格式错、正常完成,统一表现为往消息尾塞一条不同 exit_statusexit 消息(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_errorrun_<id> 里 id 最大的那个(default.py:242), 历史 run 的成功不算数。

8. 代码地图(导航索引)

主题文件路径符号名
主循环 / 退出条件src/webwright/agents/default.pyDefaultAgent.run
单步 = 查模型+执行src/webwright/agents/default.pyDefaultAgent.step / DefaultAgent.query
done 处理 + 观察喂回src/webwright/agents/default.pyDefaultAgent.execute_actions
done 门禁(截图裁判)src/webwright/agents/default.pyDefaultAgent._tool_gate_error / _self_reflection_gate_error
历史压缩src/webwright/agents/default.pyDefaultAgent._compact_history
ARIA 裁剪src/webwright/agents/default.pyDefaultAgent._prune_old_observation_aria_snapshots
落盘脱敏src/webwright/agents/default.py_sanitize_message_for_disk
debug 产物src/webwright/agents/default.pyDefaultAgent._write_debug_step_artifact
Agent 配置字段src/webwright/agents/default.pyAgentConfig
提示词/步数/开关src/webwright/config/base.yamlagent:

下一步: 动作到底被谁、怎么执行?看 02-environments.md