-
Notifications
You must be signed in to change notification settings - Fork 1
Description
优先级: P0(致命缺陷,阻塞功能上线,无完善 workaround)
agentrun-sdk 版本: 0.0.21(PyPI 最新)
GitHub: https://github.com/Serverless-Devs/agentrun-sdk-python
问题描述
BrowserToolSet 存在两层设计缺陷,导致浏览器工具的正常工具级错误(如 JavaScript 执行异常、元素找不到)被错误地升级为沙箱基础设施故障,触发不必要的沙箱销毁与重建:
- 浏览器工具没有捕获工具级错误:所有 Playwright 异常直接抛出到
_run_in_sandbox _run_in_sandbox不区分错误类型:对所有 Exception 无条件执行"销毁沙箱 → 重建 → 重试"- 浏览器沙箱启动需要 30-60 秒(内部健康检查轮询),每次重建代价极高,且丢失全部浏览器状态(已打开页面、Cookie、登录态、DOM 状态)
- 实测一轮对话中创建了 3 个沙箱实例,仅因
browser_evaluate的 JS 执行错误
此问题直接导致 BrowserToolSet 在生产环境中不可用。 在真实网页爬虫场景中,工具级错误(JS 异常、元素找不到等)极其常见,每次错误都导致沙箱重建,使得浏览器自动化工作流无法正常进行。
根因分析
核心问题:CodeInterpreterToolSet 与 BrowserToolSet 的错误处理不对称
CodeInterpreterToolSet(不触发重建):
# CodeInterpreterToolSet.run_code 的 inner 回调
def inner(sb: Sandbox):
result = sb.context.execute(code=code, timeout=timeout)
# execute() 是 HTTP API 调用,所有代码级错误被沙箱引擎捕获
# 返回结构化数据:{"stdout": "", "stderr": "SyntaxError: ...", "exitCode": 1}
return {"stdout": result.get("stdout", ""), "stderr": result.get("stderr", ""), ...}sb.context.execute() 通过 HTTP API 与沙箱引擎通信,代码级错误(语法错误、运行时异常、超时等)全部被沙箱内部捕获,作为 JSON 结构化数据返回。inner 回调永远不抛异常(除非 HTTP 连接本身断了),所以 _run_in_sandbox 的重试机制几乎不触发。
BrowserToolSet(频繁触发重建):
# BrowserToolSet.browser_evaluate 的 inner 回调
def inner(sb: Sandbox):
with sb.sync_playwright() as p:
result = p.evaluate(expression, arg=arg)
# ← p.evaluate() 是 Playwright 原生调用
# ← JS 错误直接抛出 playwright._impl._errors.Error
return {"result": result}p.evaluate() 调用的是 Playwright 的 page.evaluate(),当 JavaScript 表达式出现运行时错误(如空引用 document.querySelector('.nonexistent').textContent)时,Playwright 直接抛出 Python 异常。同理,browser_click 找不到元素、browser_fill 选择器不匹配等场景也都会抛异常。
所有浏览器工具方法都有相同的问题模式(以下列举部分):
# browser_click
def inner(sb):
with sb.sync_playwright() as p:
p.click(selector, ...) # 元素不存在 → 抛 Error
# browser_snapshot
def inner(sb):
with sb.sync_playwright() as p:
html = p.html_content() # 页面未加载完 → 可能抛 Error
# browser_fill
def inner(sb):
with sb.sync_playwright() as p:
p.fill(selector, value) # 元素不可编辑 → 抛 Error_run_in_sandbox 的无差别处理
# agentrun/integration/builtin/sandbox.py line 69-87
def _run_in_sandbox(self, callback: Callable[[Sandbox], Any]):
sb = self._ensure_sandbox()
try:
return callback(sb)
except Exception as e: # ← 捕获所有异常,不区分类型
try:
logger.debug("run in sandbox failed, due to %s, try to re-create sandbox", e)
self.sandbox = None # ← 直接销毁沙箱引用
sb = self._ensure_sandbox() # ← 创建全新沙箱(30-60s)
return callback(sb)
except Exception as e2:
logger.debug("re-created sandbox run failed, due to %s", e2)
return {"error": f"{e!s}"}_run_in_sandbox 对 Exception 基类做 catch-all,完全不区分:
- 基础设施错误(沙箱崩溃、HTTP/WebSocket 连接断开)→ 重建合理
- 工具级错误(JS 执行失败、CSS 选择器不匹配、元素不可见)→ 应返回错误给调用方,不应重建
由于 CodeInterpreterToolSet 的 execute() API 在内部捕获了代码级错误,这个设计缺陷只在 BrowserToolSet 上暴露。
附带问题:每次工具调用重建 Playwright 进程
每个浏览器工具方法都使用 with sb.sync_playwright() as p: 模式,而 sync_playwright() 每次创建全新的 BrowserPlaywrightSync 实例:
# BrowserPlaywrightSync.open()
def open(self):
self._playwright_instance = self._playwright.start() # 启动新 Playwright 子进程
self._browser = self._playwright_instance.chromium.connect_over_cdp(self.url, ...) # 新 CDP WebSocket 连接
return self
# BrowserPlaywrightSync.close()
def close(self):
if self._playwright_instance:
self._playwright_instance.stop() # 停止 Playwright 子进程这意味着每一次工具调用都要:启动 Playwright 子进程 → 建立 CDP WebSocket 连接 → 执行操作 → 断开连接 → 停止进程。频繁的连接建立/断开增加了 CDP 瞬态错误的概率。
复现步骤
from agentrun.integration.builtin.sandbox import BrowserToolSet
toolset = BrowserToolSet(
template_name="sandbox-browser-zmahTC",
config=None,
sandbox_idle_timeout_seconds=600,
)
# 1. 导航到任意页面
toolset.browser_navigate(url="https://example.com", wait_until="load")
sandbox_id_1 = toolset.sandbox_id
print(f"沙箱 #1: {sandbox_id_1}")
# 2. 执行会抛 JS 错误的表达式
result = toolset.browser_evaluate(
expression="document.querySelector('.nonexistent').textContent"
)
sandbox_id_2 = toolset.sandbox_id
print(f"沙箱 #2: {sandbox_id_2}") # 预期相同,实际不同
assert sandbox_id_1 == sandbox_id_2, f"沙箱被不必要地重建: {sandbox_id_1} → {sandbox_id_2}"实测日志证据
以下日志来自一次实际的 AI Agent 对话(thread_id 相同),可以看到一轮对话创建了 3 个不同的沙箱:
09:30:48 - 沙箱实例已记录: sandbox_id=01KJEBG74890PMS433ATHNFMBT, template=sandbox-browser-zmahTC
09:32:42 - 沙箱实例已记录: sandbox_id=01KJEBKXETDG3N0PWC8F7JBWSR, template=sandbox-browser-zmahTC
09:33:36 - 沙箱实例已记录: sandbox_id=01KJEBNK6C6P1D50HT5R06HP1T, template=sandbox-browser-zmahTC
时间线分析:
- 09:30:37 - LLM 调用
browser_navigate→ 成功,创建沙箱 Update issue templates #1 - 09:31:54 - LLM 调用
browser_snapshot→ 成功(返回 sohu.com 整页 HTML,335K tokens) - 09:32:36 - LLM 调用
browser_evaluate(提取页面数据的 JS 表达式) - 09:32:42 - 沙箱 #2 被创建 →
browser_evaluate的 JS 表达式在复杂页面上执行失败,触发重建 - 09:32:53 - LLM 调用
browser_evaluate→ 在沙箱 #2 上成功(但之前的页面状态已全部丢失) - 09:33:32 - LLM 调用
browser_evaluate→ 再次失败 - 09:33:36 - 沙箱 Anycodes patch 1 #3 被创建 → 同一轮对话中第 3 个沙箱
沙箱基础设施完全正常,触发重建的仅是 Playwright 的 JS 执行异常。
当前 Workaround(仅缓解,无法根治)
我们 monkey-patch 了 _run_in_sandbox 方法,采用保守重试策略(先用同一沙箱重试),但这只是缓解措施:
def patched_run_in_sandbox(callback):
sb = toolset._ensure_sandbox()
try:
return callback(sb)
except Exception as first_err:
# 第一次重试:使用同一沙箱(处理瞬态错误)
try:
return callback(sb)
except Exception as retry_err:
# 同一沙箱连续两次失败,才销毁重建
toolset.sandbox = None
try:
sb = toolset._ensure_sandbox()
return callback(sb)
except Exception as final_err:
return {"error": f"{first_err!s}"}局限性:这个 workaround 只是把"第一次失败就重建"改为"连续两次失败才重建",本质上仍未解决工具级错误不应触发重建的问题。在高错误率的复杂网页场景中仍会频繁重建。
建议修复方案
方案 A(推荐):浏览器工具应在 inner 回调内捕获工具级异常
根本解决方案:让 BrowserToolSet 的各个工具方法在 inner 回调内部 try-catch Playwright 异常,将其转化为结构化错误数据返回,与 CodeInterpreterToolSet 行为保持一致。这样工具级错误不会传播到 _run_in_sandbox:
# 修复前
def browser_evaluate(self, expression, arg=None):
def inner(sb):
with sb.sync_playwright() as p:
result = p.evaluate(expression, arg=arg) # JS 错误直接抛异常
return {"result": result}
return self._run_in_sandbox(inner)
# 修复后
def browser_evaluate(self, expression, arg=None):
def inner(sb):
with sb.sync_playwright() as p:
try:
result = p.evaluate(expression, arg=arg)
return {"result": result}
except PlaywrightError as e:
return {"error": f"JavaScript evaluation failed: {e}"}
return self._run_in_sandbox(inner)对所有浏览器工具方法统一适用。只有 CDP 连接级别的异常(如 ConnectionError、WebSocketError)才应传播到 _run_in_sandbox 触发沙箱重建。
方案 B:在 BrowserToolSet 中重写 _run_in_sandbox
如果方案 A 改动范围过大,至少应在 BrowserToolSet 中 override _run_in_sandbox,实现"先同沙箱重试"策略:
class BrowserToolSet(SandboxToolSet):
def _run_in_sandbox(self, callback):
sb = self._ensure_sandbox()
try:
return callback(sb)
except Exception as e:
try:
return callback(sb) # 先用同一沙箱重试
except Exception as e2:
try:
self.sandbox = None
sb = self._ensure_sandbox()
return callback(sb)
except Exception as e3:
return {"error": f"{e!s}"}方案 C:让用户可配置重试策略
在 SandboxToolSet.__init__ 中增加 retry_recreate: bool = True 参数,允许用户控制是否在失败时销毁重建。对浏览器场景默认为 False。
附带建议:复用 Playwright 进程
当前每次工具调用都启动/停止一个 Playwright 子进程。建议在 BrowserToolSet 级别缓存 BrowserPlaywrightSync 实例,跨工具调用复用 CDP 连接,仅在沙箱重建时重新创建。这将显著减少连接建立开销和瞬态连接错误。
环境信息
| 项目 | 值 |
|---|---|
| agentrun-sdk 版本 | 0.0.21(PyPI 最新) |
| Python 版本 | 3.12 |
| OS | macOS (darwin 23.6.0) |
| 影响的 ToolSet | BrowserToolSet |
| 影响的沙箱类型 | BROWSER |
| 对比参照 | CodeInterpreterToolSet(不受此问题影响) |