Skip to content

BrowserToolSet 工具级异常触发沙箱销毁重建,导致浏览器沙箱不可用 #55

@XeonYang

Description

@XeonYang

优先级: P0(致命缺陷,阻塞功能上线,无完善 workaround)
agentrun-sdk 版本: 0.0.21(PyPI 最新)
GitHub: https://github.com/Serverless-Devs/agentrun-sdk-python


问题描述

BrowserToolSet 存在两层设计缺陷,导致浏览器工具的正常工具级错误(如 JavaScript 执行异常、元素找不到)被错误地升级为沙箱基础设施故障,触发不必要的沙箱销毁与重建:

  1. 浏览器工具没有捕获工具级错误:所有 Playwright 异常直接抛出到 _run_in_sandbox
  2. _run_in_sandbox 不区分错误类型:对所有 Exception 无条件执行"销毁沙箱 → 重建 → 重试"
  3. 浏览器沙箱启动需要 30-60 秒(内部健康检查轮询),每次重建代价极高,且丢失全部浏览器状态(已打开页面、Cookie、登录态、DOM 状态)
  4. 实测一轮对话中创建了 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_sandboxException 基类做 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 连接级别的异常(如 ConnectionErrorWebSocketError)才应传播到 _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(不受此问题影响)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions