Talegorithm 3 недель назад
Родитель
Сommit
090a4dd955

+ 1 - 1
.gitignore

@@ -52,7 +52,7 @@ Thumbs.db
 .env
 debug.log
 info.log
-.browser_use_files
+.cache
 output
 
 

+ 37 - 3
agent/core/runner.py

@@ -116,12 +116,15 @@ BUILTIN_TOOLS = [
     "browser_select_dropdown_option",
     "browser_extract_content",
     "browser_read_long_content",
+    "browser_download_direct_url",
     "browser_get_page_html",
-    "browser_get_selector_map",
+    "browser_get_visual_selector_map",
     "browser_evaluate",
     "browser_ensure_login_with_cookies",
     "browser_wait_for_user_action",
     "browser_done",
+    "browser_export_cookies",
+    "browser_load_cookies"
 ]
 
 
@@ -695,6 +698,27 @@ class AgentRunner:
                         }
                     )
 
+                    # --- 支持多模态工具反馈 ---
+                    # execute() 返回 dict{"text","images"} 或 str
+                    if isinstance(tool_result, dict) and tool_result.get("images"):
+                        tool_result_text = tool_result["text"]
+                        # 构建多模态消息格式
+                        tool_content_for_llm = [{"type": "text", "text": tool_result_text}]
+                        for img in tool_result["images"]:
+                            if img.get("type") == "base64" and img.get("data"):
+                                media_type = img.get("media_type", "image/png")
+                                tool_content_for_llm.append({
+                                    "type": "image_url",
+                                    "image_url": {
+                                        "url": f"data:{media_type};base64,{img['data']}"
+                                    }
+                                })
+                        img_count = len(tool_content_for_llm) - 1  # 减去 text 块
+                        print(f"[Runner] 多模态工具反馈: tool={tool_name}, images={img_count}, text_len={len(tool_result_text)}")
+                    else:
+                        tool_result_text = str(tool_result)
+                        tool_content_for_llm = tool_result_text
+
                     tool_msg = Message.create(
                         trace_id=trace_id,
                         role="tool",
@@ -702,11 +726,20 @@ class AgentRunner:
                         goal_id=current_goal_id,
                         parent_sequence=head_seq,
                         tool_call_id=tc["id"],
-                        content={"tool_name": tool_name, "result": tool_result},
+                        content={"tool_name": tool_name, "result": tool_result_text},
                     )
 
                     if self.trace_store:
                         await self.trace_store.add_message(tool_msg)
+                        # 截图单独存为同名 PNG 文件
+                        if isinstance(tool_result, dict) and tool_result.get("images"):
+                            import base64 as b64mod
+                            for img in tool_result["images"]:
+                                if img.get("data"):
+                                    png_path = self.trace_store._get_messages_dir(trace_id) / f"{tool_msg.message_id}.png"
+                                    png_path.write_bytes(b64mod.b64decode(img["data"]))
+                                    print(f"[Runner] 截图已保存: {png_path.name}")
+                                    break  # 只存第一张
 
                     yield tool_msg
                     head_seq = sequence
@@ -716,8 +749,9 @@ class AgentRunner:
                         "role": "tool",
                         "tool_call_id": tc["id"],
                         "name": tool_name,
-                        "content": str(tool_result),
+                        "content": tool_content_for_llm, # 这里传入 list 即可触发模型的视觉能力
                     })
+                    # ------------------------------------------
 
                 continue  # 继续循环
 

+ 6 - 4
agent/memory/skills/core.md

@@ -78,7 +78,7 @@ goal(abandon="方案A需要Redis,环境没有")
 
 1. **页面导航**: 使用 `browser_navigate_to_url` 或 `browser_search_web` 到达目标页面
 2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
-3. **获取元素索引**: 调用 `browser_get_selector_map` 获取可交互元素的索引映射
+3. **获取元素索引**: 调用 `browser_get_visual_selector_map` 获取可交互元素的索引映射和当前界面的截图
 4. **执行交互**: 使用 `browser_click_element`、`browser_input_text` 等工具操作页面
 5. **提取内容**: 使用 `browser_extract_content`, `browser_read_long_content`, `browser_get_page_html` 获取数据
 
@@ -88,7 +88,9 @@ goal(abandon="方案A需要Redis,环境没有")
 - **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_get_selector_map` 获取
 - **高级工具**:优先使用`browser_extract_content`, `browser_read_long_content`等工具获取数据,而不是使用`browser_get_selector_map`获取索引后手动解析
 - **操作后等待**: 任何可能触发页面变化的操作(点击、输入、滚动)后都要调用 `browser_wait`
-- **登录处理**: 需要登录的网站使用 `browser_ensure_login_with_cookies(cookie_type="xhs")` 注入Cookie
+- **登录处理**:
+  - **正常登录**:当遇到需要登录的网页时,使用`browser_load_cookies`来登录
+  - **首次登录**:当没有该网站的cookie时,点击进入登录界面,然后等待人类来登录,登录后使用`browser_export_cookies`将账户信息存储下来
 - **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_evaluate` 执行JavaScript代码
 
 ### 工具分类
@@ -96,5 +98,5 @@ goal(abandon="方案A需要Redis,环境没有")
 **导航**: browser_navigate_to_url, browser_search_web, browser_go_back, browser_wait
 **交互**: browser_click_element, browser_input_text, browser_send_keys, browser_upload_file
 **视图**: browser_scroll_page, browser_find_text, browser_screenshot
-**提取**: browser_extract_content, browser_read_long_content, browser_get_page_html,    browser_get_selector_map
-**高级**: browser_evaluate, browser_ensure_login_with_cookies, browser_wait_for_user_action
+**提取**: browser_extract_content, browser_read_long_content, browser_get_page_html, browser_get_selector_map, browser_get_visual_selector_map
+**高级**: browser_evaluate, browser_load_cookies, browser_export_cookies, browser_wait_for_user_action, browser_download_direct_url

+ 12 - 0
agent/tools/builtin/browser/__init__.py

@@ -40,7 +40,9 @@ from agent.tools.builtin.browser.baseClass import (
     browser_extract_content,
     browser_read_long_content,
     browser_get_page_html,
+    browser_download_direct_url,
     browser_get_selector_map,
+    browser_get_visual_selector_map,
 
     # JavaScript 执行工具
     browser_evaluate,
@@ -51,6 +53,10 @@ from agent.tools.builtin.browser.baseClass import (
 
     # 任务完成
     browser_done,
+
+    # Cookie 持久化
+    browser_export_cookies,
+    browser_load_cookies,
 )
 
 __all__ = [
@@ -88,8 +94,10 @@ __all__ = [
     # 内容提取工具
     'browser_extract_content',
     'browser_read_long_content',
+    'browser_download_direct_url',
     'browser_get_page_html',
     'browser_get_selector_map',
+    'browser_get_visual_selector_map',
 
     # JavaScript 执行工具
     'browser_evaluate',
@@ -100,4 +108,8 @@ __all__ = [
 
     # 任务完成
     'browser_done',
+
+    # Cookie 持久化
+    'browser_export_cookies',
+    'browser_load_cookies',
 ]

+ 373 - 132
agent/tools/builtin/browser/baseClass.py

@@ -37,15 +37,16 @@ Native Browser-Use Tools Adapter
 3. 任务结束时调用 cleanup_browser_session()
 
 文件操作说明:
-- 浏览器专用文件目录:.browser_use_files/ (在当前工作目录下)
+- 浏览器专用文件目录:.cache/.browser_use_files/ (在当前工作目录下)
   用于存储浏览器会话产生的临时文件(下载、上传、截图等)
 - 一般文件操作:请使用 agent.tools.builtin 中的文件工具 (read_file, write_file, edit_file)
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
 """
-
+import logging
 import sys
 import os
 import json
+import httpx
 import asyncio
 import aiohttp
 import re
@@ -229,153 +230,71 @@ async def init_browser_session(
     browser_profile: Optional[BrowserProfile] = None,
     **kwargs
 ) -> tuple[BrowserSession, Tools]:
-    """
-    初始化全局浏览器会话 - 支持三种浏览器类型
-
-    Args:
-        browser_type: 浏览器类型 ("local", "cloud", "container")
-        headless: 是否无头模式
-        url: 初始访问URL(可选)
-             - local/cloud: 初始化后会自动导航到此URL
-             - container: 必需,容器启动时访问的URL
-        profile_name: 配置文件/账户名称(默认 "default")
-                     - local: 用于创建用户数据目录路径
-                     - cloud: 云浏览器配置ID
-                     - container: 容器账户名称
-        user_data_dir: 用户数据目录(仅 local 模式,高级用法)
-                      如果提供则覆盖 profile_name 生成的路径
-        browser_profile: BrowserProfile 对象(通用,高级用法)
-                        用于预设 cookies 等
-        **kwargs: 其他 BrowserSession 参数
-
-    Returns:
-        (BrowserSession, Tools) 元组
-
-    Examples:
-        # 本地浏览器
-        browser, tools = await init_browser_session(
-            browser_type="local",
-            url="https://www.baidu.com"  # 可选
-        )
-
-        # 云浏览器
-        browser, tools = await init_browser_session(
-            browser_type="cloud",
-            profile_name="my_cloud_profile"  # 可选
-        )
-
-        # 容器浏览器
-        browser, tools = await init_browser_session(
-            browser_type="container",
-            url="https://www.xiaohongshu.com",  # 必需
-            profile_name="my_account"  # 可选
-        )
-    """
     global _browser_session, _browser_tools, _file_system
 
     if _browser_session is not None:
         return _browser_session, _browser_tools
 
-    # 验证 browser_type
     valid_types = ["local", "cloud", "container"]
     if browser_type not in valid_types:
-        raise ValueError(f"无效的 browser_type: {browser_type},必须是 {valid_types} 之一")
+        raise ValueError(f"无效的 browser_type: {browser_type}")
+
+    # --- 核心:定义本地统一存储路径 ---
+    save_dir = Path.cwd() / ".cache/.browser_use_files"
+    save_dir.mkdir(parents=True, exist_ok=True)
 
-    # 创建浏览器会话参数
+    # 基础参数配置
     session_params = {
         "headless": headless,
+        # 告诉 Playwright 所有的下载临时流先存入此本地目录
+        "downloads_path": str(save_dir), 
     }
 
-    # === Container 模式 ===
     if browser_type == "container":
         print("🐳 使用容器浏览器模式")
-
-        # container 模式必须提供 URL
-        if not url:
-            url = "about:blank"  # 使用默认空白页
-            print("⚠️  未提供 url 参数,使用默认空白页")
-
-        # 创建容器并获取 CDP URL
-        print(f"📦 正在创建容器...")
-        container_info = await create_container(
-            url=url,
-            account_name=profile_name
-        )
-
+        if not url: url = "about:blank"
+        container_info = await create_container(url=url, account_name=profile_name)
         if not container_info["success"]:
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
-
-        cdp_url = container_info["cdp"]
-        print(f"✅ 容器创建成功")
-        print(f"   CDP URL: {cdp_url}")
-        print(f"   Container ID: {container_info['container_id']}")
-        print(f"   Connection ID: {container_info.get('connection_id')}")
-
-        # 使用容器的 CDP URL 连接
-        session_params["cdp_url"] = cdp_url
-
-        # 等待容器完全启动
-        print("⏳ 等待容器浏览器启动...")
+        session_params["cdp_url"] = container_info["cdp"]
         await asyncio.sleep(3)
 
-    # === Cloud 模式 ===
     elif browser_type == "cloud":
         print("🌐 使用云浏览器模式")
         session_params["use_cloud"] = True
-
-        # profile_name 作为云配置ID
         if profile_name and profile_name != "default":
             session_params["cloud_profile_id"] = profile_name
 
-    # === Local 模式 ===
     else:  # local
         print("💻 使用本地浏览器模式")
         session_params["is_local"] = True
-
-        # 设置用户数据目录(持久化登录状态)
         if user_data_dir is None and profile_name:
             user_data_dir = str(Path.home() / ".browser_use" / "profiles" / profile_name)
             Path(user_data_dir).mkdir(parents=True, exist_ok=True)
-
-        # macOS 上显式指定 Chrome 路径
+            session_params["user_data_dir"] = user_data_dir
+        
+        # macOS 路径兼容
         import platform
-        if platform.system() == "Darwin":  # macOS
+        if platform.system() == "Darwin":
             chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
             if Path(chrome_path).exists():
                 session_params["executable_path"] = chrome_path
 
-        # 只在有值时才添加 user_data_dir
-        if user_data_dir:
-            session_params["user_data_dir"] = user_data_dir
-
-    # 只在有值时才添加 browser_profile (适用于所有模式)
     if browser_profile:
         session_params["browser_profile"] = browser_profile
 
-    # 合并其他参数
     session_params.update(kwargs)
 
-    # 创建浏览器会话
+    # 创建会话
     _browser_session = BrowserSession(**session_params)
-
-    # 启动浏览器
     await _browser_session.start()
 
-    # 创建工具实例
     _browser_tools = Tools()
+    _file_system = FileSystem(base_dir=str(save_dir))
 
-    # 创建文件系统实例(用于浏览器会话产生的文件)
-    # 注意:这个目录仅用于浏览器操作相关的临时文件(下载、上传、截图等)
-    # 对于一般文件读写操作,请使用 agent.tools.builtin 中的文件工具
-    base_dir = Path.cwd() / ".browser_use_files"
-    base_dir.mkdir(parents=True, exist_ok=True)
-    _file_system = FileSystem(base_dir=str(base_dir))
-
-    print("✅ 浏览器会话初始化成功")
+    print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
 
-    # 如果是 local 或 cloud 模式且提供了 URL,导航到该 URL
     if browser_type in ["local", "cloud"] and url:
-        print(f"🔗 导航到: {url}")
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
 
     return _browser_session, _browser_tools
@@ -713,33 +632,132 @@ async def browser_wait(seconds: int = 3) -> ToolResult:
 # 元素交互工具 (Element Interaction Tools)
 # ============================================================
 
-@tool()
-async def browser_click_element(index: int) -> ToolResult:
-    """
-    通过索引点击页面元素
-    Click an element by index
-
-    Args:
-        index: 元素索引(从浏览器状态中获取)
+# 定义一个专门捕获下载链接的 Handler
+class DownloadLinkCaptureHandler(logging.Handler):
+    def __init__(self):
+        super().__init__()
+        self.captured_url = None
+
+    def emit(self, record):
+        # 如果已经捕获到了(通常第一条是最完整的),就不再处理后续日志
+        if self.captured_url:
+            return
+
+        message = record.getMessage()
+        # 寻找包含下载信息的日志
+        if "redirection?filename=" in message or "Failed to download" in message:
+            # 使用更严格的正则,确保不抓取带省略号(...)的截断链接
+            # 排除掉末尾带有三个点的干扰
+            match = re.search(r"https?://[^\s]+(?!\.\.\.)", message)
+            if match:
+                url = match.group(0)
+                # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
+                if "..." not in url:
+                    self.captured_url = url
+                    # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
 
-    Returns:
-        ToolResult: 包含点击操作结果的工具返回对象
+@tool()
+async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
+    save_dir = Path.cwd() / ".cache/.browser_use_files"
+    save_dir.mkdir(parents=True, exist_ok=True)
+    
+    # 提取域名作为 Referer,这能骗过 90% 的防盗链校验
+    from urllib.parse import urlparse
+    parsed_url = urlparse(url)
+    base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
+    
+    # 如果没传 save_name,自动从 URL 获取
+    if not save_name:
+        import unquote
+        # 尝试从 URL 路径获取文件名并解码(处理中文)
+        save_name = Path(urlparse(url).path).name or f"download_{int(time.time())}"
+        save_name = unquote(save_name) 
+
+    target_path = save_dir / save_name
+
+    headers = {
+        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+        "Accept": "*/*",
+        "Referer": base_url,  # 动态设置 Referer
+        "Range": "bytes=0-",  # 有时对大文件下载有奇效
+    }
 
-    Example:
-        click_element(index=5)
+    try:
+        print(f"🚀 开始下载: {url[:60]}...")
+        
+        # 使用 follow_redirects=True 处理链接中的 redirection
+        async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=60.0) as client:
+            async with client.stream("GET", url) as response:
+                if response.status_code != 200:
+                    print(f"❌ 下载失败,HTTP 状态码: {response.status_code}")
+                    return
+                
+                # 获取实际文件名(如果服务器提供了)
+                # 这里会优先使用你指定的 save_name
+                
+                with open(target_path, "wb") as f:
+                    downloaded_bytes = 0
+                    async for chunk in response.aiter_bytes():
+                        f.write(chunk)
+                        downloaded_bytes += len(chunk)
+                        if downloaded_bytes % (1024 * 1024) == 0: # 每下载 1MB 打印一次
+                            print(f"📥 已下载: {downloaded_bytes // (1024 * 1024)} MB")
+
+        print(f"✅ 下载完成!文件已存至: {target_path}")
+        success_msg = f"✅ 下载完成!文件已存至: {target_path}"
+        return ToolResult(
+            title="直链下载成功",
+            output=success_msg,
+            long_term_memory=success_msg,
+            metadata={"path": str(target_path)}
+        )
 
-    Note:
-        需要先通过 get_selector_map 获取页面元素索引
+    except Exception as e:
+        # 异常捕获返回
+        return ToolResult(
+            title="下载异常",
+            output="",
+            error=f"💥 发生错误: {str(e)}",
+            long_term_memory=f"下载任务由于异常中断: {str(e)}"
+        )
+    
+@tool()
+async def browser_click_element(index: int) -> ToolResult:
     """
+    点击页面元素,并自动通过拦截内部日志获取下载直链。
+    """
+    # 1. 挂载日志窃听器
+    capture_handler = DownloadLinkCaptureHandler()
+    logger = logging.getLogger("browser_use") # 拦截整个 browser_use 命名空间
+    logger.addHandler(capture_handler)
+    
     try:
         browser, tools = await get_browser_session()
 
+        # 2. 执行原生的点击动作
         result = await tools.click(
             index=index,
             browser_session=browser
         )
 
-        return action_result_to_tool_result(result, f"点击元素 {index}")
+        # 3. 检查是否有“意外收获”
+        download_msg = ""
+        if capture_handler.captured_url:
+            captured_url = capture_handler.captured_url
+            download_msg = f"\n\n⚠️ 系统检测到浏览器下载被拦截,已自动捕获准确直链:\n{captured_url}\n\n建议:你可以直接使用 browser_download_direct_url 工具下载此链接。"
+            
+            # 如果你想更激进一点,甚至可以在这里直接自动触发本地下载逻辑
+            # await auto_download_file(captured_url)
+
+        # 4. 转换结果并附加捕获的信息
+        tool_result = action_result_to_tool_result(result, f"点击元素 {index}")
+        
+        if download_msg:
+            # 关键:把日志里的信息塞进 output,这样 LLM 就能看到了!
+            tool_result.output = (tool_result.output or "") + download_msg
+            tool_result.long_term_memory = (tool_result.long_term_memory or "") + f" 捕获下载链接: {captured_url}"
+
+        return tool_result
 
     except Exception as e:
         return ToolResult(
@@ -748,6 +766,9 @@ async def browser_click_element(index: int) -> ToolResult:
             error=f"Failed to click element {index}: {str(e)}",
             long_term_memory=f"点击元素 {index} 失败"
         )
+    finally:
+        # 5. 务必移除监听器,防止内存泄漏和日志污染
+        logger.removeHandler(capture_handler)
 
 
 @tool()
@@ -868,20 +889,20 @@ async def browser_upload_file(index: int, path: str) -> ToolResult:
             long_term_memory=f"上传文件 {path} 失败"
         )
 
-
 # ============================================================
 # 滚动和视图工具 (Scroll & View Tools)
 # ============================================================
 @tool()
 async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Optional[int] = None) -> ToolResult:
     try:
+        # 限制单次滚动幅度,避免 agent 一次滚 100 页
+        MAX_PAGES = 10
+        if pages > MAX_PAGES:
+            pages = MAX_PAGES
+
         browser, tools = await get_browser_session()
-        
-        # --- 核心修复 1: 必须先 await 拿到 session 实例 ---
         cdp_session = await browser.get_or_create_cdp_session()
-        
-        # 这里的执行方式建议参考你已有的 cdp 调用逻辑
-        # 如果 cdp_session 没有直接封装 .eval(),使用 Runtime.evaluate
+
         before_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
             params={'expression': 'window.scrollY'},
             session_id=cdp_session.session_id
@@ -890,25 +911,36 @@ async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Opti
 
         # 执行滚动
         result = await tools.scroll(down=down, pages=pages, index=index, browser_session=browser)
-        
-        # 等待渲染并检查偏移
-        await asyncio.sleep(1)
-        
+
+        # 等待渲染(懒加载页面需要更长时间)
+        await asyncio.sleep(2)
+
         after_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
             params={'expression': 'window.scrollY'},
             session_id=cdp_session.session_id
         )
         after_y = after_y_result.get('result', {}).get('value', 0)
 
-        # 3. 验证是否真的动了
+        # 如果第一次检测没动,再等一轮(应对懒加载触发后的延迟滚动)
         if before_y == after_y and index is None:
+            await asyncio.sleep(2)
+            retry_result = await cdp_session.cdp_client.send.Runtime.evaluate(
+                params={'expression': 'window.scrollY'},
+                session_id=cdp_session.session_id
+            )
+            after_y = retry_result.get('result', {}).get('value', 0)
+
+        if before_y == after_y and index is None:
+            direction = "下" if down else "上"
             return ToolResult(
-                title="滚动无效", 
-                output="页面已到达边界或滚动被拦截", 
+                title="滚动无效",
+                output=f"页面已到达{direction}边界,无法继续滚动",
                 error="No movement detected"
             )
 
-        return action_result_to_tool_result(result, f"已滚动")
+        delta = abs(after_y - before_y)
+        direction = "下" if down else "上"
+        return action_result_to_tool_result(result, f"已向{direction}滚动 {delta}px")
 
     except Exception as e:
         # --- 核心修复 2: 必须补全 output 参数,否则框架会报错 ---
@@ -919,6 +951,7 @@ async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Opti
         )
 
 
+
 @tool()
 async def browser_find_text(text: str) -> ToolResult:
     """
@@ -954,7 +987,103 @@ async def browser_find_text(text: str) -> ToolResult:
             long_term_memory=f"查找文本 '{text}' 失败"
         )
 
+@tool()
+async def browser_get_visual_selector_map() -> ToolResult:
+    """
+    获取当前页面的视觉快照和交互元素索引映射。
+    Get visual snapshot and selector map of interactive elements.
 
+    该工具会同时执行两个操作:
+    1. 捕捉当前页面的截图,并用 browser-use 内置方法在截图上标注元素索引号。
+    2. 生成页面所有可交互元素的索引字典(含 href、type 等属性信息)。
+
+    Returns:
+        ToolResult: 包含高亮截图(在 images 中)和元素列表的工具返回对象。
+    """
+    try:
+        browser, _ = await get_browser_session()
+
+        # 1. 构造同时包含 DOM 和 截图 的请求
+        from browser_use.browser.events import BrowserStateRequestEvent
+        from browser_use.browser.python_highlights import create_highlighted_screenshot_async
+
+        event = browser.event_bus.dispatch(
+            BrowserStateRequestEvent(
+                include_dom=True,
+                include_screenshot=True,
+                include_recent_events=False
+            )
+        )
+
+        # 2. 等待浏览器返回完整状态
+        browser_state = await event.event_result(raise_if_none=True, raise_if_any=True)
+
+        # 3. 提取 Selector Map
+        selector_map = browser_state.dom_state.selector_map if browser_state.dom_state else {}
+
+        # 4. 提取截图并生成带索引标注的高亮截图(通过 CDP 获取精确 DPI 和滚动偏移)
+        screenshot_b64 = browser_state.screenshot or ""
+        highlighted_b64 = ""
+        if screenshot_b64 and selector_map:
+            try:
+                cdp_session = await browser.get_or_create_cdp_session()
+                highlighted_b64 = await create_highlighted_screenshot_async(
+                    screenshot_b64, selector_map,
+                    cdp_session=cdp_session,
+                    filter_highlight_ids=False
+                )
+            except Exception:
+                highlighted_b64 = screenshot_b64  # fallback to raw screenshot
+        else:
+            highlighted_b64 = screenshot_b64
+
+        # 5. 构建供 Agent 阅读的完整元素列表,包含丰富的属性信息
+        elements_info = []
+        for index, node in selector_map.items():
+            tag = node.tag_name
+            attrs = node.attributes or {}
+            desc = attrs.get('aria-label') or attrs.get('placeholder') or attrs.get('title') or node.get_all_children_text(max_depth=1) or ""
+            # 收集有用的属性片段
+            extra_parts = []
+            if attrs.get('href'):
+                extra_parts.append(f"href={attrs['href'][:60]}")
+            if attrs.get('type'):
+                extra_parts.append(f"type={attrs['type']}")
+            if attrs.get('role'):
+                extra_parts.append(f"role={attrs['role']}")
+            if attrs.get('name'):
+                extra_parts.append(f"name={attrs['name']}")
+            extra = f" ({', '.join(extra_parts)})" if extra_parts else ""
+            elements_info.append(f"Index {index}: <{tag}> \"{desc[:50]}\"{extra}")
+
+        output = f"页面截图已捕获(含元素索引标注)\n找到 {len(selector_map)} 个交互元素\n\n"
+        output += "元素列表:\n" + "\n".join(elements_info)
+
+        # 6. 将高亮截图存入 images 字段,metadata 保留结构化数据
+        images = []
+        if highlighted_b64:
+            images.append({"type": "base64", "media_type": "image/png", "data": highlighted_b64})
+
+        return ToolResult(
+            title="视觉元素观察",
+            output=output,
+            long_term_memory=f"在页面观察到 {len(selector_map)} 个元素并保存了截图",
+            images=images,
+            metadata={
+                "selector_map": {k: str(v) for k, v in list(selector_map.items())[:100]},
+                "url": browser_state.url,
+                "title": browser_state.title
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="视觉观察失败",
+            output="",
+            error=f"Failed to get visual selector map: {str(e)}",
+            long_term_memory="获取视觉元素映射失败"
+        )
+    
 @tool()
 async def browser_screenshot() -> ToolResult:
     """
@@ -1325,7 +1454,7 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
         pdf_bytes = base64.b64decode(base64_data)
 
         # 保存到本地
-        save_dir = Path.cwd() / ".browser_use_files"
+        save_dir = Path.cwd() / ".cache/.browser_use_files"
         save_dir.mkdir(parents=True, exist_ok=True)
 
         filename = Path(parsed.path).name if parsed.path else ""
@@ -1787,6 +1916,112 @@ async def browser_done(text: str, success: bool = True,
         )
 
 
+# ============================================================
+# Cookie 持久化工具
+# ============================================================
+
+_COOKIES_DIR = Path(__file__).parent.parent.parent.parent.parent / ".cache/.cookies"
+
+@tool()
+async def browser_export_cookies(name: str = "", account: str = "") -> ToolResult:
+    """
+    导出当前浏览器的所有 Cookie 到本地 .cookies/ 目录。
+    文件命名格式:{域名}_{账号名}.json,如 bilibili.com_zhangsan.json
+    登录成功后调用此工具,下次可通过 browser_load_cookies 恢复登录态。
+
+    Args:
+        name: 自定义文件名(可选,提供则忽略自动命名)
+        account: 账号名称(可选,用于区分同一网站的不同账号)
+    """
+    try:
+        browser, _ = await get_browser_session()
+
+        # 获取所有 Cookie(CDP 格式)
+        all_cookies = await browser._cdp_get_cookies()
+        if not all_cookies:
+            return ToolResult(title="Cookie 导出", output="当前浏览器没有 Cookie", long_term_memory="无 Cookie 可导出")
+
+        # 获取当前域名,用于过滤和命名
+        from urllib.parse import urlparse
+        current_url = await browser.get_current_page_url() or ''
+        domain = urlparse(current_url).netloc.replace("www.", "") or "default"
+
+        if not name:
+            name = f"{domain}_{account}" if account else domain
+
+        # 只保留当前域名的 cookie(过滤第三方)
+        cookies = [c for c in all_cookies if domain in c.get("domain", "").lstrip(".")]
+
+        # 保存
+        _COOKIES_DIR.mkdir(parents=True, exist_ok=True)
+        cookie_file = _COOKIES_DIR / f"{name}.json"
+        cookie_file.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
+
+        return ToolResult(
+            title="Cookie 已导出",
+            output=f"已保存 {len(cookies)} 条 Cookie 到 .cookies/{name}.json(从 {len(all_cookies)} 条中过滤当前域名)",
+            long_term_memory=f"导出 {len(cookies)} 条 Cookie 到 .cookies/{name}.json"
+        )
+    except Exception as e:
+        return ToolResult(title="Cookie 导出失败", output="", error=str(e), long_term_memory="导出 Cookie 失败")
+
+
+@tool()
+async def browser_load_cookies(url: str, name: str = "") -> ToolResult:
+    """
+    根据目标 URL 自动查找本地 Cookie 文件,注入浏览器并导航到目标页面恢复登录态。
+    重要:此工具会自动完成导航,调用前不需要先调用 browser_navigate_to_url。
+
+    Args:
+        url: 目标 URL(必须提供,同时用于自动匹配 Cookie 文件)
+        name: Cookie 文件名(可选,不传则根据 URL 域名自动查找)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        if not url.startswith("http"):
+            url = f"https://{url}"
+
+        # 根据域名自动查找 Cookie 文件
+        if not name:
+            from urllib.parse import urlparse
+            domain = urlparse(url).netloc.replace("www.", "")
+            if _COOKIES_DIR.exists():
+                matches = list(_COOKIES_DIR.glob(f"{domain}*.json"))
+                if matches:
+                    cookie_file = matches[0]  # 取第一个匹配的
+                else:
+                    available = [f.stem for f in _COOKIES_DIR.glob("*.json")]
+                    return ToolResult(title="未找到 Cookie", output=f"没有匹配 {domain} 的文件,可用: {available}", error=f"无 {domain} 的 Cookie 文件")
+            else:
+                return ToolResult(title="未找到 Cookie", output=".cookies 目录不存在", error="Cookie 目录不存在")
+        else:
+            cookie_file = _COOKIES_DIR / f"{name}.json"
+            if not cookie_file.exists():
+                available = [f.stem for f in _COOKIES_DIR.glob("*.json")] if _COOKIES_DIR.exists() else []
+                return ToolResult(title="文件不存在", output=f"可用: {available}", error=f"未找到 .cookies/{name}.json")
+
+        cookies = json.loads(cookie_file.read_text(encoding="utf-8"))
+
+        # 直接注入(export 和 load 使用相同的 CDP 格式,无需标准化)
+        await browser._cdp_set_cookies(cookies)
+
+        # 导航到目标页面(带上刚注入的 Cookie)
+        if url:
+            if not url.startswith("http"):
+                url = f"https://{url}"
+            await tools.navigate(url=url, browser_session=browser)
+            await tools.wait(seconds=3, browser_session=browser)
+
+        return ToolResult(
+            title="Cookie 注入并导航完成",
+            output=f"从 {cookie_file.name} 注入 {len(cookies)} 条 Cookie,已导航到 {url}",
+            long_term_memory=f"已从 {cookie_file.name} 注入 Cookie 并导航到 {url},登录态已恢复"
+        )
+    except Exception as e:
+        return ToolResult(title="Cookie 加载失败", output="", error=str(e), long_term_memory="加载 Cookie 失败")
+
+
 # ============================================================
 # 导出所有工具函数(供外部使用)
 # ============================================================
@@ -1827,7 +2062,9 @@ __all__ = [
     'browser_extract_content',
     'browser_get_page_html',
     'browser_read_long_content',
+    'browser_download_direct_url',
     'browser_get_selector_map',
+    'browser_get_visual_selector_map',
 
     # JavaScript 执行工具
     'browser_evaluate',
@@ -1838,4 +2075,8 @@ __all__ = [
 
     # 任务完成
     'browser_done',
+
+    # Cookie 持久化
+    'browser_export_cookies',
+    'browser_load_cookies',
 ]

+ 4 - 1
agent/tools/registry.py

@@ -234,13 +234,16 @@ class ToolRegistry:
 			duration = time.time() - start_time
 			stats.total_duration += duration
 
-			# 返回 JSON 字符串或文本
+			# 返回结果:ToolResult 转为可序列化格式
 			if isinstance(result, str):
 				return result
 
 			# 处理 ToolResult 对象
 			from agent.tools.models import ToolResult
 			if isinstance(result, ToolResult):
+				# 有图片时返回 dict 以便 runner 构建多模态消息
+				if result.images:
+					return {"text": result.to_llm_message(), "images": result.images}
 				return result.to_llm_message()
 
 			return json.dumps(result, ensure_ascii=False, indent=2)

+ 190 - 0
examples/research/run.py

@@ -0,0 +1,190 @@
+"""
+浏览器调研示例 (增强版)
+
+功能:
+1. 使用 Agent 模式进行网络调研
+2. 任务结束自动关闭浏览器并杀掉进程
+3. 异常安全:即使程序崩溃也能清理环境
+"""
+
+import os
+import sys
+import asyncio
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+import logging
+# 配置感知日志
+logging.basicConfig(level=logging.WARNING)  # 默认 WARNING
+logging.getLogger("agent.core.message_manager").setLevel(logging.INFO)  # 开启感知日志
+logging.getLogger("tools").setLevel(logging.INFO)  # 开启工具日志
+
+from agent.llm.prompts import SimplePrompt
+from agent.core.runner import AgentRunner, RunConfig
+from agent.trace import (
+    FileSystemTraceStore,
+    Trace,
+    Message,
+)
+from agent.llm import create_openrouter_llm_call
+
+# 导入浏览器清理工具
+from agent.tools.builtin.browser.baseClass import get_browser_session,kill_browser_session,init_browser_session 
+
+async def main():
+    # 路径配置
+    base_dir = Path(__file__).parent
+    project_root = base_dir.parent.parent
+    trace_dir = project_root / ".trace"
+    prompt_path = base_dir / "test.prompt"
+    output_dir = base_dir / "output"
+    output_dir.mkdir(exist_ok=True)
+
+    # Skills 目录
+    skills_dir = None 
+
+    print("=" * 60)
+    print("🚀 浏览器调研任务 (Agent 模式)")
+    print("=" * 60)
+    print()
+
+    # 1. 加载 prompt
+    print("1. 加载 prompt...")
+    prompt = SimplePrompt(prompt_path)
+
+    # 提取配置
+    system_prompt = prompt._messages.get("system", "")
+    user_task = prompt._messages.get("user", "")
+    model_name = prompt.config.get('model', 'gemini-2.5-flash')
+    temperature = float(prompt.config.get('temperature', 0.3))
+
+    print(f"   - 任务: {user_task[:80]}...")
+    print(f"   - 模型: {model_name}")
+    
+    # 2. 构建消息
+    print("2. 构建任务消息...")
+    messages = prompt.build_messages()
+
+    # 3. 创建 Agent Runner
+    print("3. 创建 Agent Runner...")
+    runner = AgentRunner(
+        trace_store=FileSystemTraceStore(base_path=str(trace_dir)),
+        llm_call=create_openrouter_llm_call(model=f"google/{model_name}"),
+        skills_dir=skills_dir,
+        debug=True 
+    )
+
+    final_response = ""
+    current_trace_id = None
+
+    # 4. Agent 模式执行(使用 try...finally 确保清理)
+    try:
+        print(f"4. 初始化云浏览器...")                              
+        await init_browser_session(browser_type="cloud", headless=True)                                                          
+
+        print(f"5. 启动 Agent 模式执行...")    
+        print()
+
+        async for item in runner.run(
+            messages=messages,
+            config=RunConfig(
+                system_prompt=system_prompt,
+                model=f"google/{model_name}",
+                temperature=temperature,
+                max_iterations=20,
+                name=user_task[:50],
+            ),
+        ):
+            # 处理 Trace 对象(整体状态变化)
+            if isinstance(item, Trace):
+                current_trace_id = item.trace_id
+                if item.status == "running":
+                    print(f"[Trace] 开始: {item.trace_id[:8]}")
+                elif item.status == "completed":
+                    print(f"[Trace] 完成")
+                    print(f"  - Total tokens: {item.total_tokens}")
+                    print(f"  - Total cost: ${item.total_cost:.4f}")
+                elif item.status == "failed":
+                    print(f"[Trace] 失败: {item.error_message}")
+
+            # 处理 Message 对象(执行过程)
+            elif isinstance(item, Message):
+                if item.role == "assistant":
+                    content = item.content
+                    if isinstance(content, dict):
+                        text = content.get("text", "")
+                        tool_calls = content.get("tool_calls")
+
+                        if text and not tool_calls:
+                            final_response = text
+                            print(f"[Response] Agent 给出最终回复")
+                        elif text:
+                            # 增加打印长度到 300,方便观察
+                            print(f"[Assistant] {text[:300]}...")
+
+                        if tool_calls:
+                            for tc in tool_calls:
+                                tool_name = tc.get("function", {}).get("name", "unknown")
+                                print(f"[Tool Call] 🛠️ {tool_name}")
+
+                elif item.role == "tool":
+                    content = item.content
+                    if isinstance(content, dict):
+                        tool_name = content.get("tool_name", "unknown")
+                        print(f"[Tool Result] ✅ {tool_name}")
+                    if item.description:
+                        desc = item.description[:80] if len(item.description) > 80 else item.description
+                        print(f"  {desc}...")
+
+        # 5. 输出结果
+        print()
+        print("=" * 60)
+        print("Final Agent Response:")
+        print("=" * 60)
+        print(final_response)
+        print("=" * 60)
+        print()
+
+        # 6. 保存结果
+        output_file = output_dir / "research_result.txt"
+        with open(output_file, 'w', encoding='utf-8') as f:
+            f.write(final_response)
+        print(f"✓ 结果已保存到: {output_file}")
+
+    except Exception as e:
+        print(f"\n❌ 程序运行崩溃: {str(e)}")
+        import traceback
+        traceback.print_exc()
+
+    finally:
+        # --- 核心逻辑:无论成功失败,必须关闭浏览器进程 ---
+        print("\n" + "·" * 40)
+        print("🧹 正在清理浏览器环境,关闭 CDP 会话并终止进程...")
+        try:
+            # 强制杀掉浏览器进程,释放容器或本地端口
+            await kill_browser_session()
+            print("✅ 浏览器已安全关闭。")
+        except Exception as cleanup_err:
+            print(f"⚠️ 清理浏览器时出现错误: {cleanup_err}")
+        print("·" * 40 + "\n")
+
+    # 7. 可视化提示
+    if current_trace_id:
+        print("=" * 60)
+        print("可视化 Step Tree:")
+        print("=" * 60)
+        print("1. 启动 API Server: python3 api_server.py")
+        print(f"2. 访问: http://localhost:8000/api/traces")
+        print(f"3. Trace ID: {current_trace_id}")
+        print("=" * 60)
+
+if __name__ == "__main__":
+    try:
+        asyncio.run(main())
+    except KeyboardInterrupt:
+        print("\n🛑 用户手动终止 (KeyboardInterrupt),正在强制退出...")

+ 10 - 0
examples/research/test.prompt

@@ -0,0 +1,10 @@
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+你是最顶尖的AI助手,可以拆分并调用工具逐步解决复杂问题。
+
+$user$
+去zh.zlib.li网页找一些构图相关的书(可以用load_cookies登录),并下载下来。