优先级 : 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(不受此问题影响)
问题描述
BrowserToolSet存在两层设计缺陷,导致浏览器工具的正常工具级错误(如 JavaScript 执行异常、元素找不到)被错误地升级为沙箱基础设施故障,触发不必要的沙箱销毁与重建:_run_in_sandbox_run_in_sandbox不区分错误类型:对所有 Exception 无条件执行"销毁沙箱 → 重建 → 重试"browser_evaluate的 JS 执行错误此问题直接导致 BrowserToolSet 在生产环境中不可用。 在真实网页爬虫场景中,工具级错误(JS 异常、元素找不到等)极其常见,每次错误都导致沙箱重建,使得浏览器自动化工作流无法正常进行。
根因分析
核心问题:CodeInterpreterToolSet 与 BrowserToolSet 的错误处理不对称
CodeInterpreterToolSet(不触发重建):
sb.context.execute()通过 HTTP API 与沙箱引擎通信,代码级错误(语法错误、运行时异常、超时等)全部被沙箱内部捕获,作为 JSON 结构化数据返回。inner 回调永远不抛异常(除非 HTTP 连接本身断了),所以_run_in_sandbox的重试机制几乎不触发。BrowserToolSet(频繁触发重建):
p.evaluate()调用的是 Playwright 的page.evaluate(),当 JavaScript 表达式出现运行时错误(如空引用document.querySelector('.nonexistent').textContent)时,Playwright 直接抛出 Python 异常。同理,browser_click找不到元素、browser_fill选择器不匹配等场景也都会抛异常。所有浏览器工具方法都有相同的问题模式(以下列举部分):
_run_in_sandbox的无差别处理_run_in_sandbox对Exception基类做 catch-all,完全不区分:由于 CodeInterpreterToolSet 的
execute()API 在内部捕获了代码级错误,这个设计缺陷只在 BrowserToolSet 上暴露。附带问题:每次工具调用重建 Playwright 进程
每个浏览器工具方法都使用
with sb.sync_playwright() as p:模式,而sync_playwright()每次创建全新的BrowserPlaywrightSync实例:这意味着每一次工具调用都要:启动 Playwright 子进程 → 建立 CDP WebSocket 连接 → 执行操作 → 断开连接 → 停止进程。频繁的连接建立/断开增加了 CDP 瞬态错误的概率。
复现步骤
实测日志证据
以下日志来自一次实际的 AI Agent 对话(thread_id 相同),可以看到一轮对话创建了 3 个不同的沙箱:
时间线分析:
browser_navigate→ 成功,创建沙箱 Update issue templates #1browser_snapshot→ 成功(返回 sohu.com 整页 HTML,335K tokens)browser_evaluate(提取页面数据的 JS 表达式)browser_evaluate的 JS 表达式在复杂页面上执行失败,触发重建browser_evaluate→ 在沙箱 #2 上成功(但之前的页面状态已全部丢失)browser_evaluate→ 再次失败沙箱基础设施完全正常,触发重建的仅是 Playwright 的 JS 执行异常。
当前 Workaround(仅缓解,无法根治)
我们 monkey-patch 了
_run_in_sandbox方法,采用保守重试策略(先用同一沙箱重试),但这只是缓解措施:局限性:这个 workaround 只是把"第一次失败就重建"改为"连续两次失败才重建",本质上仍未解决工具级错误不应触发重建的问题。在高错误率的复杂网页场景中仍会频繁重建。
建议修复方案
方案 A(推荐):浏览器工具应在 inner 回调内捕获工具级异常
根本解决方案:让 BrowserToolSet 的各个工具方法在 inner 回调内部 try-catch Playwright 异常,将其转化为结构化错误数据返回,与 CodeInterpreterToolSet 行为保持一致。这样工具级错误不会传播到
_run_in_sandbox:对所有浏览器工具方法统一适用。只有 CDP 连接级别的异常(如
ConnectionError、WebSocketError)才应传播到_run_in_sandbox触发沙箱重建。方案 B:在
BrowserToolSet中重写_run_in_sandbox如果方案 A 改动范围过大,至少应在
BrowserToolSet中 override_run_in_sandbox,实现"先同沙箱重试"策略:方案 C:让用户可配置重试策略
在
SandboxToolSet.__init__中增加retry_recreate: bool = True参数,允许用户控制是否在失败时销毁重建。对浏览器场景默认为False。附带建议:复用 Playwright 进程
当前每次工具调用都启动/停止一个 Playwright 子进程。建议在
BrowserToolSet级别缓存BrowserPlaywrightSync实例,跨工具调用复用 CDP 连接,仅在沙箱重建时重新创建。这将显著减少连接建立开销和瞬态连接错误。环境信息