Просмотр исходного кода

Merge branch 'main' of https://git.yishihui.com/howard/Agent

max_liu 1 месяц назад
Родитель
Сommit
f4fd7765c5
53 измененных файлов с 6002 добавлено и 485 удалено
  1. 1 0
      .gitignore
  2. 3 0
      .gitmodules
  3. 3 1
      agent/__init__.py
  4. 55 11
      agent/llm/providers/gemini.py
  5. 6 0
      agent/prompts/__init__.py
  6. 190 0
      agent/prompts/loader.py
  7. 168 0
      agent/prompts/wrapper.py
  8. 2 10
      agent/runner.py
  9. 15 0
      agent/skills/browser_use/__init__.py
  10. 218 0
      agent/skills/browser_use/browser-use.md
  11. 174 0
      agent/skills/browser_use/setup.py
  12. 356 0
      agent/storage/skill_loader.py
  13. 137 0
      agent/subagents/default.json
  14. 1 0
      agent/tools/__init__.py
  15. 13 0
      agent/tools/adapters/__init__.py
  16. 62 0
      agent/tools/adapters/base.py
  17. 120 0
      agent/tools/adapters/opencode-wrapper.ts
  18. 138 0
      agent/tools/adapters/opencode_bun_adapter.py
  19. 15 0
      agent/tools/advanced/__init__.py
  20. 52 0
      agent/tools/advanced/lsp.py
  21. 60 0
      agent/tools/advanced/webfetch.py
  22. 27 0
      agent/tools/builtin/__init__.py
  23. 152 0
      agent/tools/builtin/bash.py
  24. 533 0
      agent/tools/builtin/edit.py
  25. 110 0
      agent/tools/builtin/glob.py
  26. 218 0
      agent/tools/builtin/grep.py
  27. 229 0
      agent/tools/builtin/read.py
  28. 433 0
      agent/tools/builtin/sandbox.py
  29. 265 0
      agent/tools/builtin/search.py
  30. 259 0
      agent/tools/builtin/skill.py
  31. 113 0
      agent/tools/builtin/write.py
  32. 167 72
      docs/README.md
  33. 126 0
      docs/multimodal.md
  34. 89 0
      docs/project-structure.md
  35. 120 0
      docs/skills.md
  36. 241 0
      docs/sub-agents.md
  37. 108 0
      docs/testing.md
  38. 232 0
      docs/tools-adapters.md
  39. 94 2
      docs/tools.md
  40. 0 165
      examples/README_gemini_agent.md
  41. 62 0
      examples/browser_use_setup_demo.py
  42. 34 0
      examples/feature_extract/feature_extract.prompt
  43. 5 0
      examples/feature_extract/input_1/feature.md
  44. BIN
      examples/feature_extract/input_1/image.png
  45. 53 0
      examples/feature_extract/output_1/result.txt
  46. 98 0
      examples/feature_extract/run.py
  47. 22 0
      examples/feature_extract/test.prompt
  48. 0 224
      examples/gemini_basic_agent.py
  49. 229 0
      examples/subagent_example.py
  50. 188 0
      examples/tools_complete_demo.py
  51. 5 0
      requirements.txt
  52. 0 0
      tools/__init__.py
  53. 1 0
      vendor/opencode

+ 1 - 0
.gitignore

@@ -35,6 +35,7 @@ env/
 *.swp
 *.swo
 *~
+CLAUDE.md
 
 # Testing
 .pytest_cache/

+ 3 - 0
.gitmodules

@@ -0,0 +1,3 @@
+[submodule "vendor/opencode"]
+	path = vendor/opencode
+	url = https://github.com/anomalyco/opencode.git

+ 3 - 1
agent/__init__.py

@@ -3,6 +3,7 @@ Reson Agent - 可扩展、可学习的 Agent 框架
 
 核心导出:
 - AgentRunner: Agent 执行引擎
+- AgentConfig: Agent 配置
 - AgentEvent: Agent 事件
 - Trace, Step: 执行追踪
 - Experience, Skill: 记忆模型
@@ -10,7 +11,7 @@ Reson Agent - 可扩展、可学习的 Agent 框架
 - TraceStore, MemoryStore: 存储接口
 """
 
-from agent.runner import AgentRunner
+from agent.runner import AgentRunner, AgentConfig
 from agent.events import AgentEvent
 from agent.models.trace import Trace, Step, StepType
 from agent.models.memory import Experience, Skill
@@ -22,6 +23,7 @@ __version__ = "0.1.0"
 __all__ = [
     # Runner
     "AgentRunner",
+    "AgentConfig",
     # Events
     "AgentEvent",
     # Models

+ 55 - 11
agent/llm/providers/gemini.py

@@ -71,14 +71,14 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
         # 非 tool 消息:先 flush buffer
         flush_tool_buffer()
 
-        content_text = msg.get("content", "")
+        content = msg.get("content", "")
         tool_calls = msg.get("tool_calls")
 
         # Assistant 消息 + tool_calls
         if role == "assistant" and tool_calls:
             parts = []
-            if content_text and content_text.strip():
-                parts.append({"text": content_text})
+            if content and (isinstance(content, str) and content.strip()):
+                parts.append({"text": content})
 
             # 转换 tool_calls 为 functionCall
             for tc in tool_calls:
@@ -104,16 +104,60 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
                 })
             continue
 
-        # 跳过空消息
-        if not content_text or not content_text.strip():
+        # 处理多模态消息(content 为数组)
+        if isinstance(content, list):
+            parts = []
+            for item in content:
+                item_type = item.get("type")
+
+                # 文本部分
+                if item_type == "text":
+                    text = item.get("text", "")
+                    if text.strip():
+                        parts.append({"text": text})
+
+                # 图片部分(OpenAI format -> Gemini format)
+                elif item_type == "image_url":
+                    image_url = item.get("image_url", {})
+                    url = image_url.get("url", "")
+
+                    # 处理 data URL (data:image/png;base64,...)
+                    if url.startswith("data:"):
+                        # 解析 MIME type 和 base64 数据
+                        # 格式:data:image/png;base64,<base64_data>
+                        try:
+                            header, base64_data = url.split(",", 1)
+                            mime_type = header.split(";")[0].replace("data:", "")
+
+                            parts.append({
+                                "inline_data": {
+                                    "mime_type": mime_type,
+                                    "data": base64_data
+                                }
+                            })
+                        except Exception as e:
+                            print(f"[WARNING] Failed to parse image data URL: {e}")
+
+            if parts:
+                gemini_role = "model" if role == "assistant" else "user"
+                contents.append({
+                    "role": gemini_role,
+                    "parts": parts
+                })
             continue
 
-        # 普通消息
-        gemini_role = "model" if role == "assistant" else "user"
-        contents.append({
-            "role": gemini_role,
-            "parts": [{"text": content_text}]
-        })
+        # 普通文本消息(content 为字符串)
+        if isinstance(content, str):
+            # 跳过空消息
+            if not content.strip():
+                continue
+
+            gemini_role = "model" if role == "assistant" else "user"
+            contents.append({
+                "role": gemini_role,
+                "parts": [{"text": content}]
+            })
+            continue
 
     # Flush 剩余的 tool messages
     flush_tool_buffer()

+ 6 - 0
agent/prompts/__init__.py

@@ -0,0 +1,6 @@
+"""Prompt loading and processing utilities"""
+
+from .loader import load_prompt, get_message
+from .wrapper import SimplePrompt, create_prompt
+
+__all__ = ["load_prompt", "get_message", "SimplePrompt", "create_prompt"]

+ 190 - 0
agent/prompts/loader.py

@@ -0,0 +1,190 @@
+"""
+Prompt Loader
+
+支持 .prompt 文件格式:
+- YAML frontmatter 配置(model, temperature等)
+- $section$ 分节语法
+- %variable% 参数替换
+
+格式:
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+系统提示...
+
+$user$
+用户提示...
+%variable%
+"""
+
+import re
+import yaml
+from pathlib import Path
+from typing import Dict, Any, Tuple, Union
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def load_prompt(path: Union[Path, str]) -> Tuple[Dict[str, Any], Dict[str, str]]:
+    """
+    加载 .prompt 文件
+
+    Args:
+        path: .prompt 文件路径
+
+    Returns:
+        (config, messages)
+        - config: 配置字典 {'model': 'gemini-2.5-flash', 'temperature': 0.3, ...}
+        - messages: 消息字典 {'system': '...', 'user': '...'}
+
+    Raises:
+        FileNotFoundError: 文件不存在
+        ValueError: 文件格式错误
+
+    Example:
+        >>> config, messages = load_prompt(Path("task.prompt"))
+        >>> config['model']
+        'gemini-2.5-flash'
+        >>> messages['system']
+        '你是一位计算机视觉专家...'
+    """
+    path = Path(path) if isinstance(path, str) else path
+
+    if not path.exists():
+        raise FileNotFoundError(f".prompt 文件不存在: {path}")
+
+    try:
+        content = path.read_text(encoding='utf-8')
+    except Exception as e:
+        raise ValueError(f"读取 .prompt 文件失败: {e}")
+
+    # 解析文件
+    config, messages = _parse_prompt(content)
+
+    logger.debug(f"加载 .prompt 文件: {path}, 配置项: {len(config)}, 消息段: {len(messages)}")
+
+    return config, messages
+
+
+def _parse_prompt(content: str) -> Tuple[Dict[str, Any], Dict[str, str]]:
+    """
+    解析 .prompt 文件内容
+
+    格式:
+    ---
+    model: gemini-2.5-flash
+    temperature: 0.3
+    ---
+
+    $system$
+    系统提示...
+
+    $user$
+    用户提示...
+    """
+    # 1. 分离 YAML frontmatter 和正文
+    parts = content.split('---', 2)
+
+    if len(parts) < 3:
+        raise ValueError(".prompt 文件格式错误: 缺少 YAML frontmatter(需要 --- 包裹)")
+
+    # 2. 解析 YAML 配置
+    try:
+        config = yaml.safe_load(parts[1]) or {}
+    except yaml.YAMLError as e:
+        raise ValueError(f".prompt 文件 YAML 解析失败: {e}")
+
+    # 3. 解析正文(按 $section$ 分割)
+    body = parts[2]
+    messages = _parse_sections(body)
+
+    return config, messages
+
+
+def _parse_sections(body: str) -> Dict[str, str]:
+    """
+    解析 .prompt 正文分节
+
+    支持语法:
+    - $section$ (如 $system$, $user$)
+
+    Args:
+        body: .prompt 正文内容
+
+    Returns:
+        消息字典 {'system': '...', 'user': '...'}
+
+    Example:
+        >>> body = "$system$\\n你好\\n$user$\\n世界"
+        >>> _parse_sections(body)
+        {'system': '你好', 'user': '世界'}
+    """
+    messages = {}
+
+    # 使用正则表达式分割(匹配 $key$)
+    pattern = r'\$([^$]+)\$'
+    parts = re.split(pattern, body)
+
+    # parts 格式:['前置空白', 'key1', '内容1', 'key2', '内容2', ...]
+    # 跳过 parts[0](前置空白)
+    i = 1
+    while i < len(parts):
+        if i + 1 >= len(parts):
+            break
+
+        key = parts[i].strip()
+        value = parts[i + 1].strip()
+
+        if key and value:
+            messages[key] = value
+
+        i += 2
+
+    if not messages:
+        logger.warning(".prompt 文件没有找到任何分节($section$)")
+
+    return messages
+
+
+def get_message(messages: Dict[str, str], key: str, **params) -> str:
+    """
+    获取消息(带参数替换)
+
+    参数替换:
+    - 使用 %variable% 语法
+    - 直接字符串替换
+
+    Args:
+        messages: 消息字典(来自 load_prompt)
+        key: 消息键(如 'system', 'user')
+        **params: 参数替换(如 text='内容')
+
+    Returns:
+        替换后的消息字符串
+
+    Example:
+        >>> messages = {'user': '特征:%text%'}
+        >>> get_message(messages, 'user', text='整体构图')
+        '特征:整体构图'
+    """
+    message = messages.get(key, "")
+
+    if not message:
+        logger.warning(f".prompt 消息未找到: key='{key}'")
+        return ""
+
+    # 参数替换(%variable% 直接替换)
+    if params:
+        try:
+            for param_name, param_value in params.items():
+                placeholder = f"%{param_name}%"
+                if placeholder in message:
+                    message = message.replace(placeholder, str(param_value))
+        except Exception as e:
+            logger.error(f".prompt 参数替换错误: key='{key}', error={e}")
+
+    return message

+ 168 - 0
agent/prompts/wrapper.py

@@ -0,0 +1,168 @@
+"""
+Prompt Wrapper - 为 .prompt 文件提供 Prompt 实现
+
+类似 Resonote 的 SimpleHPrompt,但增加了多模态支持
+"""
+
+import base64
+from pathlib import Path
+from typing import List, Dict, Any, Union, Optional
+from agent.prompts.loader import load_prompt, get_message
+
+
+class SimplePrompt:
+    """
+    通用的 Prompt 包装器
+
+    特性:
+    - 加载 .prompt 文件(YAML frontmatter + sections)
+    - 支持参数替换(%variable%)
+    - 支持多模态消息(图片)
+
+    使用示例:
+        # 纯文本
+        prompt = SimplePrompt(Path("task.prompt"))
+        messages = prompt.build_messages(text="内容")
+
+        # 多模态(文本 + 图片)
+        messages = prompt.build_messages(
+            text="分析这张图片",
+            images="path/to/image.png"  # 或 images=["img1.png", "img2.png"]
+        )
+    """
+
+    def __init__(self, prompt_path: Union[Path, str]):
+        """
+        Args:
+            prompt_path: .prompt 文件路径
+        """
+        self.prompt_path = Path(prompt_path) if isinstance(prompt_path, str) else prompt_path
+
+        # 加载 .prompt 文件
+        self.config, self._messages = load_prompt(self.prompt_path)
+
+    def build_messages(self, **context) -> List[Dict[str, Any]]:
+        """
+        构造消息列表(支持多模态)
+
+        Args:
+            **context: 参数
+                - 普通参数:用于替换 %variable%
+                - images: 图片资源(可选)
+                  - 单个图片:str 或 Path
+                  - 多个图片:List[str | Path]
+                  - 格式:文件路径或 base64 字符串
+
+        Returns:
+            消息列表,格式遵循 OpenAI API 规范
+
+        Example:
+            >>> messages = prompt.build_messages(
+            ...     text="特征描述",
+            ...     images="input/image.png"
+            ... )
+            [
+                {"role": "system", "content": "..."},
+                {
+                    "role": "user",
+                    "content": [
+                        {"type": "text", "text": "..."},
+                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
+                    ]
+                }
+            ]
+        """
+        # 提取图片资源(从 context 中移除,避免传入 get_message)
+        images = context.pop('images', None)
+
+        # 构建文本内容(支持参数替换)
+        system_content = get_message(self._messages, 'system', **context)
+        user_content = get_message(self._messages, 'user', **context)
+
+        messages = []
+
+        # 添加 system 消息
+        if system_content:
+            messages.append({"role": "system", "content": system_content})
+
+        # 添加 user 消息(可能是多模态)
+        if images:
+            # 多模态消息
+            user_message = {"role": "user", "content": []}
+
+            # 添加文本部分
+            if user_content:
+                user_message["content"].append({
+                    "type": "text",
+                    "text": user_content
+                })
+
+            # 添加图片部分
+            if isinstance(images, (list, tuple)):
+                for img in images:
+                    user_message["content"].append(self._build_image_content(img))
+            else:
+                user_message["content"].append(self._build_image_content(images))
+
+            messages.append(user_message)
+        else:
+            # 纯文本消息
+            if user_content:
+                messages.append({"role": "user", "content": user_content})
+
+        return messages
+
+    def _build_image_content(self, image: Union[str, Path]) -> Dict[str, Any]:
+        """
+        构建图片内容部分(OpenAI 格式)
+
+        Args:
+            image: 图片路径或 base64 字符串
+
+        Returns:
+            {"type": "image_url", "image_url": {"url": "data:..."}}
+        """
+        # 如果已经是 base64 data URL,直接使用
+        if isinstance(image, str) and image.startswith("data:"):
+            return {
+                "type": "image_url",
+                "image_url": {"url": image}
+            }
+
+        # 否则,读取文件并转为 base64
+        image_path = Path(image) if isinstance(image, str) else image
+
+        # 推断 MIME type
+        suffix = image_path.suffix.lower()
+        mime_type_map = {
+            '.png': 'image/png',
+            '.jpg': 'image/jpeg',
+            '.jpeg': 'image/jpeg',
+            '.gif': 'image/gif',
+            '.webp': 'image/webp'
+        }
+        mime_type = mime_type_map.get(suffix, 'image/png')
+
+        # 读取并编码
+        with open(image_path, 'rb') as f:
+            image_data = base64.b64encode(f.read()).decode('utf-8')
+
+        data_url = f"data:{mime_type};base64,{image_data}"
+
+        return {
+            "type": "image_url",
+            "image_url": {"url": data_url}
+        }
+
+
+def create_prompt(prompt_path: Union[Path, str]) -> SimplePrompt:
+    """
+    工厂函数:创建 SimplePrompt 实例
+
+    Args:
+        prompt_path: .prompt 文件路径
+
+    Returns:
+        SimplePrompt 实例
+    """
+    return SimplePrompt(prompt_path)

+ 2 - 10
agent/runner.py

@@ -17,6 +17,7 @@ from agent.events import AgentEvent
 from agent.models.trace import Trace, Step
 from agent.models.memory import Experience, Skill
 from agent.storage.protocols import TraceStore, MemoryStore, StateStore
+from agent.storage.skill_loader import load_skills_from_dir
 from agent.tools import ToolRegistry, get_tool_registry
 
 logger = logging.getLogger(__name__)
@@ -239,16 +240,12 @@ class AgentRunner:
         })
 
         try:
-            # 加载记忆
-            skills_text = ""
+            # 加载记忆(仅 Experience)
             experiences_text = ""
 
             if enable_memory and self.memory_store:
                 scope = f"agent:{agent_type}"
-                skills = await self.memory_store.search_skills(scope, task)
                 experiences = await self.memory_store.search_experiences(scope, task)
-
-                skills_text = self._format_skills(skills)
                 experiences_text = self._format_experiences(experiences)
 
                 # 记录 memory_read Step
@@ -258,16 +255,13 @@ class AgentRunner:
                         step_type="memory_read",
                         sequence=0,
                         data={
-                            "skills_count": len(skills),
                             "experiences_count": len(experiences),
-                            "skills": [s.to_dict() for s in skills],
                             "experiences": [e.to_dict() for e in experiences],
                         }
                     )
                     await self.trace_store.add_step(mem_step)
 
                 yield AgentEvent("memory_loaded", {
-                    "skills_count": len(skills),
                     "experiences_count": len(experiences)
                 })
 
@@ -278,8 +272,6 @@ class AgentRunner:
             if system_prompt:
                 # 注入记忆到 system prompt
                 full_system = system_prompt
-                if skills_text:
-                    full_system += f"\n\n## 相关技能\n{skills_text}"
                 if experiences_text:
                     full_system += f"\n\n## 相关经验\n{experiences_text}"
 

+ 15 - 0
agent/skills/browser_use/__init__.py

@@ -0,0 +1,15 @@
+"""
+Browser-Use Skill
+
+包含 browser-use 的使用文档和环境配置工具
+"""
+
+from agent.skills.browser_use.setup import (
+    check_browser_use,
+    install_browser_use_chromium
+)
+
+__all__ = [
+    "check_browser_use",
+    "install_browser_use_chromium",
+]

+ 218 - 0
agent/skills/browser_use/browser-use.md

@@ -0,0 +1,218 @@
+---
+name: browser-use
+description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, or extract information from web pages.
+allowed-tools: Bash(browser-use:*)
+---
+
+# Browser Automation with browser-use CLI
+
+The `browser-use` command provides fast, persistent browser automation. It maintains browser sessions across commands, enabling complex multi-step workflows.
+
+## Quick Start
+
+```bash
+browser-use open https://example.com           # Navigate to URL
+browser-use state                              # Get page elements with indices
+browser-use click 5                            # Click element by index
+browser-use type "Hello World"                 # Type text
+browser-use screenshot                         # Take screenshot
+browser-use close                              # Close browser
+```
+
+## Core Workflow
+
+1. **Navigate**: `browser-use open <url>` - Opens URL (starts browser if needed)
+2. **Inspect**: `browser-use state` - Returns clickable elements with indices
+3. **Interact**: Use indices from state to interact (`browser-use click 5`, `browser-use input 3 "text"`)
+4. **Verify**: `browser-use state` or `browser-use screenshot` to confirm actions
+5. **Repeat**: Browser stays open between commands
+
+## Browser Modes
+
+```bash
+browser-use --browser chromium open <url>      # Default: headless Chromium
+browser-use --browser chromium --headed open <url>  # Visible Chromium window
+browser-use --browser real open <url>          # User's Chrome with login sessions
+browser-use --browser remote open <url>        # Cloud browser (requires API key)
+```
+
+- **chromium**: Fast, isolated, headless by default
+- **real**: Uses your Chrome with cookies, extensions, logged-in sessions
+- **remote**: Cloud-hosted browser with proxy support (requires BROWSER_USE_API_KEY)
+
+## Commands
+
+### Navigation
+```bash
+browser-use open <url>                    # Navigate to URL
+browser-use back                          # Go back in history
+browser-use scroll down                   # Scroll down
+browser-use scroll up                     # Scroll up
+```
+
+### Page State
+```bash
+browser-use state                         # Get URL, title, and clickable elements
+browser-use screenshot                    # Take screenshot (outputs base64)
+browser-use screenshot path.png           # Save screenshot to file
+browser-use screenshot --full path.png    # Full page screenshot
+```
+
+### Interactions (use indices from `browser-use state`)
+```bash
+browser-use click <index>                 # Click element
+browser-use type "text"                   # Type text into focused element
+browser-use input <index> "text"          # Click element, then type text
+browser-use keys "Enter"                  # Send keyboard keys
+browser-use keys "Control+a"              # Send key combination
+browser-use select <index> "option"       # Select dropdown option
+```
+
+### Tab Management
+```bash
+browser-use switch <tab>                  # Switch to tab by index
+browser-use close-tab                     # Close current tab
+browser-use close-tab <tab>               # Close specific tab
+```
+
+### JavaScript & Data
+```bash
+browser-use eval "document.title"         # Execute JavaScript, return result
+browser-use extract "all product prices"  # Extract data using LLM (requires API key)
+```
+
+### Python Execution (Persistent Session)
+```bash
+browser-use python "x = 42"               # Set variable
+browser-use python "print(x)"             # Access variable (outputs: 42)
+browser-use python "print(browser.url)"   # Access browser object
+browser-use python --vars                 # Show defined variables
+browser-use python --reset                # Clear Python namespace
+browser-use python --file script.py       # Execute Python file
+```
+
+The Python session maintains state across commands. The `browser` object provides:
+- `browser.url` - Current page URL
+- `browser.title` - Page title
+- `browser.goto(url)` - Navigate
+- `browser.click(index)` - Click element
+- `browser.type(text)` - Type text
+- `browser.screenshot(path)` - Take screenshot
+- `browser.scroll()` - Scroll page
+- `browser.html` - Get page HTML
+
+### Agent Tasks (Requires API Key)
+```bash
+browser-use run "Fill the contact form with test data"    # Run AI agent
+browser-use run "Extract all product prices" --max-steps 50
+```
+
+Agent tasks use an LLM to autonomously complete complex browser tasks. Requires `BROWSER_USE_API_KEY` or configured LLM API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc).
+
+### Session Management
+```bash
+browser-use sessions                      # List active sessions
+browser-use close                         # Close current session
+browser-use close --all                   # Close all sessions
+```
+
+### Server Control
+```bash
+browser-use server status                 # Check if server is running
+browser-use server stop                   # Stop server
+browser-use server logs                   # View server logs
+```
+
+## Global Options
+
+| Option | Description |
+|--------|-------------|
+| `--session NAME` | Use named session (default: "default") |
+| `--browser MODE` | Browser mode: chromium, real, remote |
+| `--headed` | Show browser window (chromium mode) |
+| `--profile NAME` | Chrome profile (real mode only) |
+| `--json` | Output as JSON |
+| `--api-key KEY` | Override API key |
+
+**Session behavior**: All commands without `--session` use the same "default" session. The browser stays open and is reused across commands. Use `--session NAME` to run multiple browsers in parallel.
+
+## Examples
+
+### Form Submission
+```bash
+browser-use open https://example.com/contact
+browser-use state
+# Shows: [0] input "Name", [1] input "Email", [2] textarea "Message", [3] button "Submit"
+browser-use input 0 "John Doe"
+browser-use input 1 "john@example.com"
+browser-use input 2 "Hello, this is a test message."
+browser-use click 3
+browser-use state  # Verify success
+```
+
+### Multi-Session Workflows
+```bash
+browser-use --session work open https://work.example.com
+browser-use --session personal open https://personal.example.com
+browser-use --session work state    # Check work session
+browser-use --session personal state  # Check personal session
+browser-use close --all             # Close both sessions
+```
+
+### Data Extraction with Python
+```bash
+browser-use open https://example.com/products
+browser-use python "
+products = []
+for i in range(20):
+    browser.scroll('down')
+browser.screenshot('products.png')
+"
+browser-use python "print(f'Captured {len(products)} products')"
+```
+
+### Using Real Browser (Logged-In Sessions)
+```bash
+browser-use --browser real open https://gmail.com
+# Uses your actual Chrome with existing login sessions
+browser-use state  # Already logged in!
+```
+
+## Tips
+
+1. **Always run `browser-use state` first** to see available elements and their indices
+2. **Use `--headed` for debugging** to see what the browser is doing
+3. **Sessions persist** - the browser stays open between commands
+4. **Use `--json` for parsing** output programmatically
+5. **Python variables persist** across `browser-use python` commands within a session
+6. **Real browser mode** preserves your login sessions and extensions
+
+## Troubleshooting
+
+**Browser won't start?**
+```bash
+browser-use server stop               # Stop any stuck server
+browser-use --headed open <url>       # Try with visible window
+```
+
+**Element not found?**
+```bash
+browser-use state                     # Check current elements
+browser-use scroll down               # Element might be below fold
+browser-use state                     # Check again
+```
+
+**Session issues?**
+```bash
+browser-use sessions                  # Check active sessions
+browser-use close --all               # Clean slate
+browser-use open <url>                # Fresh start
+```
+
+## Cleanup
+
+**Always close the browser when done.** Run this after completing browser automation:
+
+```bash
+browser-use close
+```

+ 174 - 0
agent/skills/browser_use/setup.py

@@ -0,0 +1,174 @@
+"""
+Browser-Use 设置工具
+
+检查并安装 browser-use 的依赖(CLI 和 Chromium)
+"""
+
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult
+
+
+def _check_browser_use_cli() -> bool:
+    """检查 browser-use CLI 是否已安装"""
+    try:
+        result = subprocess.run(
+            ["browser-use", "--help"],
+            capture_output=True,
+            timeout=5
+        )
+        return result.returncode == 0
+    except (FileNotFoundError, subprocess.TimeoutExpired):
+        return False
+
+
+def _check_chromium_installed() -> bool:
+    """检查 Chromium 是否已安装(Playwright 缓存)"""
+    # macOS
+    playwright_cache = Path.home() / "Library" / "Caches" / "ms-playwright"
+    if not playwright_cache.exists():
+        # Linux
+        playwright_cache = Path.home() / ".cache" / "ms-playwright"
+
+    if playwright_cache.exists():
+        chromium_dirs = list(playwright_cache.glob("chromium-*"))
+        return len(chromium_dirs) > 0
+
+    return False
+
+
+@tool(description="检查 browser-use 依赖并提供安装指导")
+async def check_browser_use(
+    uid: str = ""
+) -> ToolResult:
+    """
+    检查 browser-use 的依赖是否已安装
+
+    检查项:
+    1. browser-use CLI 是否可用
+    2. Chromium 浏览器是否已安装
+
+    Returns:
+        ToolResult: 检查结果和安装指导
+    """
+    cli_installed = _check_browser_use_cli()
+    chromium_installed = _check_chromium_installed()
+
+    output = "# Browser-Use Dependency Check\n\n"
+
+    # CLI 状态
+    output += "## 1. Browser-Use CLI\n\n"
+    if cli_installed:
+        output += "✅ **Installed** - `browser-use` command is available\n\n"
+    else:
+        output += "❌ **Not Installed**\n\n"
+        output += "Install with:\n```bash\npip install browser-use\n# or\nuv add browser-use && uv sync\n```\n\n"
+
+    # Chromium 状态
+    output += "## 2. Chromium Browser\n\n"
+    if chromium_installed:
+        output += "✅ **Installed** - Chromium is available in Playwright cache\n\n"
+    else:
+        output += "❌ **Not Installed**\n\n"
+        output += "Install with:\n```bash\nuvx browser-use install\n```\n\n"
+        output += "Installation location:\n"
+        output += "- macOS: `~/Library/Caches/ms-playwright/`\n"
+        output += "- Linux: `~/.cache/ms-playwright/`\n\n"
+
+    # 总结
+    if cli_installed and chromium_installed:
+        output += "## ✅ Ready to Use\n\n"
+        output += "All dependencies are installed. You can now use browser-use commands.\n\n"
+        output += "Test with: `browser-use --help`\n"
+        status = "ready"
+    else:
+        output += "## ⚠️ Setup Required\n\n"
+        output += "Please complete the installation steps above before using browser-use.\n"
+        status = "incomplete"
+
+    return ToolResult(
+        title="Browser-Use Dependency Check",
+        output=output,
+        metadata={
+            "cli_installed": cli_installed,
+            "chromium_installed": chromium_installed,
+            "status": status
+        }
+    )
+
+
+@tool(description="安装 browser-use 的 Chromium 浏览器")
+async def install_browser_use_chromium(
+    uid: str = ""
+) -> ToolResult:
+    """
+    安装 Chromium 浏览器(通过 uvx browser-use install)
+
+    注意:这会下载约 200-300MB 的 Chromium 浏览器
+
+    Returns:
+        ToolResult: 安装结果
+    """
+    # 先检查 CLI 是否可用
+    if not _check_browser_use_cli():
+        return ToolResult(
+            title="Browser-Use CLI Not Found",
+            output="Please install browser-use first:\n```bash\npip install browser-use\n```",
+            error="browser-use CLI not found"
+        )
+
+    # 检查是否已安装
+    if _check_chromium_installed():
+        return ToolResult(
+            title="Chromium Already Installed",
+            output="Chromium is already installed in the Playwright cache.\n\n"
+                   "Location: `~/Library/Caches/ms-playwright/` (macOS) or `~/.cache/ms-playwright/` (Linux)"
+        )
+
+    # 执行安装
+    output = "Installing Chromium browser...\n\n"
+    output += "This may take a few minutes (downloading ~200-300MB).\n\n"
+
+    try:
+        result = subprocess.run(
+            ["uvx", "browser-use", "install"],
+            capture_output=True,
+            text=True,
+            timeout=300  # 5 分钟超时
+        )
+
+        if result.returncode == 0:
+            output += "✅ Installation successful!\n\n"
+            output += result.stdout
+
+            return ToolResult(
+                title="Chromium Installed",
+                output=output,
+                metadata={"installed": True}
+            )
+        else:
+            output += f"❌ Installation failed\n\n"
+            output += f"Error: {result.stderr}\n"
+
+            return ToolResult(
+                title="Installation Failed",
+                output=output,
+                error=result.stderr
+            )
+
+    except subprocess.TimeoutExpired:
+        return ToolResult(
+            title="Installation Timeout",
+            output="Installation timed out after 5 minutes.\n"
+                   "Please try running manually:\n```bash\nuvx browser-use install\n```",
+            error="Timeout"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Installation Error",
+            output=f"Unexpected error: {str(e)}\n\n"
+                   "Please try running manually:\n```bash\nuvx browser-use install\n```",
+            error=str(e)
+        )

+ 356 - 0
agent/storage/skill_loader.py

@@ -0,0 +1,356 @@
+"""
+Skill Loader - 从 Markdown 文件加载 Skills
+
+支持两种格式:
+
+格式1 - YAML Frontmatter(推荐):
+---
+name: skill-name
+description: Skill description
+category: category-name
+scope: agent:*
+parent: parent-id
+---
+
+## When to use
+- Use case 1
+- Use case 2
+
+## Guidelines
+- Guideline 1
+- Guideline 2
+
+格式2 - 行内元数据(向后兼容):
+# Skill Name
+
+> category: web-automation
+> scope: agent:*
+
+## Description
+...
+
+## Guidelines
+...
+"""
+
+import os
+import re
+from pathlib import Path
+from typing import List, Dict, Optional
+import logging
+
+from agent.models.memory import Skill
+
+logger = logging.getLogger(__name__)
+
+
+class SkillLoader:
+    """从 Markdown 文件加载 Skills"""
+
+    def __init__(self, skills_dir: str):
+        """
+        初始化 SkillLoader
+
+        Args:
+            skills_dir: skills 目录路径
+        """
+        self.skills_dir = Path(skills_dir)
+        if not self.skills_dir.exists():
+            logger.warning(f"Skills 目录不存在: {skills_dir}")
+
+    def load_all(self) -> List[Skill]:
+        """
+        加载目录下所有 .md 文件
+
+        Returns:
+            Skill 列表
+        """
+        if not self.skills_dir.exists():
+            return []
+
+        skills = []
+        for md_file in self.skills_dir.glob("*.md"):
+            try:
+                skill = self.load_file(md_file)
+                if skill:
+                    skills.append(skill)
+                    logger.info(f"成功加载 skill: {skill.name} from {md_file.name}")
+            except Exception as e:
+                logger.error(f"加载 skill 失败 {md_file}: {e}")
+
+        return skills
+
+    def load_file(self, file_path: Path) -> Optional[Skill]:
+        """
+        从单个 Markdown 文件加载 Skill
+
+        Args:
+            file_path: Markdown 文件路径
+
+        Returns:
+            Skill 对象,解析失败返回 None
+        """
+        if not file_path.exists():
+            logger.warning(f"文件不存在: {file_path}")
+            return None
+
+        with open(file_path, "r", encoding="utf-8") as f:
+            content = f.read()
+
+        return self.parse_markdown(content, file_path.stem)
+
+    def parse_markdown(self, content: str, filename: str) -> Optional[Skill]:
+        """
+        解析 Markdown 内容为 Skill
+
+        支持两种格式:
+
+        格式1 - YAML Frontmatter(推荐):
+        ---
+        name: skill-name
+        description: Skill description
+        category: category-name
+        scope: agent:*
+        ---
+
+        ## When to use
+        - Use case 1
+
+        ## Guidelines
+        - Guideline 1
+
+        格式2 - 行内元数据(向后兼容):
+        # Skill Name
+
+        > category: web-automation
+        > scope: agent:*
+
+        ## Description
+        描述内容...
+
+        ## Guidelines
+        - 指导原则1
+
+        Args:
+            content: Markdown 内容
+            filename: 文件名(不含扩展名)
+
+        Returns:
+            Skill 对象
+        """
+        # 检测格式:是否有 YAML frontmatter
+        if content.strip().startswith("---"):
+            return self._parse_frontmatter_format(content, filename)
+        else:
+            return self._parse_inline_format(content, filename)
+
+    def _parse_frontmatter_format(self, content: str, filename: str) -> Optional[Skill]:
+        """
+        解析 YAML frontmatter 格式
+
+        ---
+        name: skill-name
+        description: Skill description
+        category: category-name
+        scope: agent:*
+        parent: parent-id
+        ---
+
+        ## When to use
+        ...
+
+        ## Guidelines
+        ...
+        """
+        lines = content.split("\n")
+
+        # 提取 YAML frontmatter
+        if not lines[0].strip() == "---":
+            logger.warning("格式错误:缺少开始的 ---")
+            return None
+
+        frontmatter = {}
+        i = 1
+        while i < len(lines):
+            line = lines[i].strip()
+            if line == "---":
+                break
+            if ":" in line:
+                key, value = line.split(":", 1)
+                frontmatter[key.strip()] = value.strip()
+            i += 1
+
+        # 提取元数据
+        name = frontmatter.get("name") or self._filename_to_title(filename)
+        description = frontmatter.get("description", "")
+        category = frontmatter.get("category", "general")
+        scope = frontmatter.get("scope", "agent:*")
+        parent_id = frontmatter.get("parent")
+
+        # 提取章节内容(从 frontmatter 之后开始)
+        remaining_content = "\n".join(lines[i+1:])
+        remaining_lines = remaining_content.split("\n")
+
+        # 提取 "When to use" 章节(可选)
+        when_to_use = self._extract_list_items(remaining_lines, "When to use")
+        if when_to_use:
+            # 将 "When to use" 添加到描述中
+            description += "\n\n适用场景:\n" + "\n".join(f"- {item}" for item in when_to_use)
+
+        # 提取 Guidelines
+        guidelines = self._extract_list_items(remaining_lines, "Guidelines")
+
+        # 创建 Skill
+        return Skill.create(
+            scope=scope,
+            name=name,
+            description=description.strip(),
+            category=category,
+            guidelines=guidelines,
+            parent_id=parent_id,
+        )
+
+    def _parse_inline_format(self, content: str, filename: str) -> Optional[Skill]:
+        """
+        解析行内元数据格式(向后兼容)
+
+        # Skill Name
+
+        > category: web-automation
+        > scope: agent:*
+
+        ## Description
+        ...
+
+        ## Guidelines
+        ...
+        """
+        lines = content.split("\n")
+
+        # 提取标题作为 name
+        name = self._extract_title(lines) or self._filename_to_title(filename)
+
+        # 提取元数据
+        metadata = self._extract_metadata(lines)
+        category = metadata.get("category", "general")
+        scope = metadata.get("scope", "agent:*")
+        parent_id = metadata.get("parent")
+
+        # 提取描述
+        description = self._extract_section(lines, "Description") or ""
+
+        # 提取指导原则
+        guidelines = self._extract_list_items(lines, "Guidelines")
+
+        # 创建 Skill
+        return Skill.create(
+            scope=scope,
+            name=name,
+            description=description.strip(),
+            category=category,
+            guidelines=guidelines,
+            parent_id=parent_id,
+        )
+
+    def _extract_title(self, lines: List[str]) -> Optional[str]:
+        """提取 # 标题"""
+        for line in lines:
+            line = line.strip()
+            if line.startswith("# "):
+                return line[2:].strip()
+        return None
+
+    def _filename_to_title(self, filename: str) -> str:
+        """将文件名转换为标题(kebab-case -> Title Case)"""
+        return " ".join(word.capitalize() for word in filename.split("-"))
+
+    def _extract_metadata(self, lines: List[str]) -> Dict[str, str]:
+        """
+        提取元数据块(> key: value)
+
+        Example:
+            > category: web-automation
+            > scope: agent:*
+        """
+        metadata = {}
+        for line in lines:
+            line = line.strip()
+            if line.startswith(">"):
+                # 去掉 > 符号
+                content = line[1:].strip()
+                # 分割 key: value
+                if ":" in content:
+                    key, value = content.split(":", 1)
+                    metadata[key.strip()] = value.strip()
+        return metadata
+
+    def _extract_section(self, lines: List[str], section_name: str) -> Optional[str]:
+        """
+        提取指定章节的内容
+
+        Args:
+            lines: 文件行列表
+            section_name: 章节名称(如 "Description")
+
+        Returns:
+            章节内容(纯文本)
+        """
+        in_section = False
+        section_lines = []
+
+        for line in lines:
+            stripped = line.strip()
+
+            # 遇到目标章节
+            if stripped.startswith("## ") and section_name.lower() in stripped.lower():
+                in_section = True
+                continue
+
+            # 遇到下一个章节,结束
+            if in_section and stripped.startswith("##"):
+                break
+
+            # 收集章节内容
+            if in_section:
+                section_lines.append(line)
+
+        return "\n".join(section_lines).strip() if section_lines else None
+
+    def _extract_list_items(self, lines: List[str], section_name: str) -> List[str]:
+        """
+        提取指定章节的列表项
+
+        Args:
+            lines: 文件行列表
+            section_name: 章节名称(如 "Guidelines")
+
+        Returns:
+            列表项数组
+        """
+        section_content = self._extract_section(lines, section_name)
+        if not section_content:
+            return []
+
+        items = []
+        for line in section_content.split("\n"):
+            line = line.strip()
+            # 匹配列表项(- item 或 * item)
+            if line.startswith("- ") or line.startswith("* "):
+                items.append(line[2:].strip())
+
+        return items
+
+
+# 便捷函数
+def load_skills_from_dir(skills_dir: str) -> List[Skill]:
+    """
+    从目录加载所有 Skills
+
+    Args:
+        skills_dir: skills 目录路径
+
+    Returns:
+        Skill 列表
+    """
+    loader = SkillLoader(skills_dir)
+    return loader.load_all()

+ 137 - 0
agent/subagents/default.json

@@ -0,0 +1,137 @@
+{
+  "agents": {
+    "code-reviewer": {
+      "description": "代码审查专家,专注于代码质量和最佳实践",
+      "mode": "subagent",
+      "allowed_tools": [
+        "read_file",
+        "search_code",
+        "list_files"
+      ],
+      "denied_tools": [
+        "write_file",
+        "edit_file",
+        "execute_bash",
+        "task"
+      ],
+      "max_iterations": 15,
+      "temperature": 0.2,
+      "system_prompt": "你是一个代码审查专家。专注于:\n\n1. 代码质量\n   - 可读性和可维护性\n   - 命名规范\n   - 注释质量\n\n2. 最佳实践\n   - 设计模式的正确使用\n   - SOLID 原则\n   - DRY 原则\n\n3. 潜在问题\n   - 性能问题\n   - 内存泄漏\n   - 边界条件\n\n输出格式:\n- **文件**: [路径]\n- **问题**: [描述]\n- **严重程度**: [高/中/低]\n- **建议**: [改进建议]\n- **示例**: [可选的代码示例]",
+      "can_spawn_subagent": false
+    },
+
+    "security-scanner": {
+      "description": "安全扫描专家,查找安全漏洞和敏感信息泄露",
+      "mode": "subagent",
+      "allowed_tools": [
+        "read_file",
+        "search_code",
+        "list_files"
+      ],
+      "denied_tools": [
+        "write_file",
+        "edit_file",
+        "execute_bash",
+        "task"
+      ],
+      "max_iterations": 20,
+      "temperature": 0.1,
+      "system_prompt": "你是一个安全扫描专家。专注于:\n\n1. 常见漏洞\n   - SQL 注入\n   - XSS 攻击\n   - CSRF 漏洞\n   - 不安全的反序列化\n\n2. 敏感信息\n   - 硬编码的密钥\n   - API tokens\n   - 数据库凭据\n\n3. 配置安全\n   - 不安全的默认配置\n   - 过于宽松的权限\n\n输出格式:\n- **漏洞类型**: [类型]\n- **位置**: [文件:行号]\n- **严重程度**: [高/中/低]\n- **描述**: [详细说明]\n- **修复建议**: [如何修复]",
+      "can_spawn_subagent": false,
+      "permissions": {
+        "paths": {
+          "/etc": "deny",
+          "~/.ssh": "deny",
+          "/tmp": "allow"
+        }
+      }
+    },
+
+    "doc-writer": {
+      "description": "文档编写专家,生成和改进项目文档",
+      "mode": "subagent",
+      "allowed_tools": [
+        "read_file",
+        "search_code",
+        "list_files",
+        "write_file"
+      ],
+      "denied_tools": [
+        "execute_bash",
+        "task"
+      ],
+      "max_iterations": 15,
+      "temperature": 0.5,
+      "system_prompt": "你是一个文档编写专家。专注于:\n\n1. 文档结构\n   - 清晰的层次\n   - 合理的章节划分\n   - 完整的目录\n\n2. 内容质量\n   - 准确性\n   - 示例代码\n   - 使用场景\n\n3. 文档类型\n   - README\n   - API 文档\n   - 教程\n   - 架构设计文档\n\n输出格式:使用 Markdown",
+      "can_spawn_subagent": false
+    },
+
+    "performance-analyzer": {
+      "description": "性能分析专家,识别性能瓶颈和优化机会",
+      "mode": "subagent",
+      "allowed_tools": [
+        "read_file",
+        "search_code",
+        "list_files",
+        "execute_bash"
+      ],
+      "denied_tools": [
+        "write_file",
+        "edit_file",
+        "task"
+      ],
+      "max_iterations": 25,
+      "temperature": 0.2,
+      "system_prompt": "你是一个性能分析专家。专注于:\n\n1. 性能问题识别\n   - N+1 查询\n   - 不必要的计算\n   - 内存泄漏\n   - 阻塞操作\n\n2. 优化建议\n   - 缓存策略\n   - 数据库索引\n   - 并发处理\n   - 批量操作\n\n3. 基准测试\n   - 关键路径分析\n   - 性能指标\n\n输出格式:\n- **问题**: [描述]\n- **位置**: [文件:函数]\n- **影响**: [响应时间/内存/CPU]\n- **优化建议**: [具体方案]\n- **预期收益**: [性能提升估算]",
+      "can_spawn_subagent": false
+    },
+
+    "test-writer": {
+      "description": "测试编写专家,生成单元测试和集成测试",
+      "mode": "subagent",
+      "allowed_tools": [
+        "read_file",
+        "search_code",
+        "list_files",
+        "write_file"
+      ],
+      "denied_tools": [
+        "execute_bash",
+        "task"
+      ],
+      "max_iterations": 20,
+      "temperature": 0.3,
+      "system_prompt": "你是一个测试编写专家。专注于:\n\n1. 测试覆盖\n   - 核心功能\n   - 边界条件\n   - 错误处理\n\n2. 测试质量\n   - 可读性\n   - 独立性\n   - 可维护性\n\n3. 测试类型\n   - 单元测试\n   - 集成测试\n   - 端到端测试\n\n遵循 AAA 模式:Arrange, Act, Assert",
+      "can_spawn_subagent": false
+    },
+
+    "my-primary-agent": {
+      "description": "自定义主 Agent,具有完整权限",
+      "mode": "primary",
+      "can_spawn_subagent": true,
+      "max_iterations": 30,
+      "temperature": 0.7
+    }
+  },
+
+  "default_agent": "my-primary-agent",
+
+  "permissions": {
+    "global": {
+      "tools": {
+        "execute_bash": "ask",
+        "write_file": "ask"
+      },
+      "paths": {
+        "/etc": "deny",
+        "~/.ssh": "deny",
+        "/tmp": "allow"
+      },
+      "network": {
+        "blocked_domains": [
+          "*.evil.com"
+        ]
+      }
+    }
+  }
+}

+ 1 - 0
agent/tools/__init__.py

@@ -6,6 +6,7 @@ from agent.tools.registry import ToolRegistry, tool, get_tool_registry
 from agent.tools.schema import SchemaGenerator
 from agent.tools.models import ToolResult, ToolContext, ToolContextImpl
 
+
 __all__ = [
 	"ToolRegistry",
 	"tool",

+ 13 - 0
agent/tools/adapters/__init__.py

@@ -0,0 +1,13 @@
+"""
+工具适配器 - 集成第三方工具到 Agent 框架
+
+提供统一的适配器接口,将外部工具(如 opencode)适配到我们的工具系统。
+"""
+
+from agent.tools.adapters.base import ToolAdapter
+from agent.tools.adapters.opencode_bun_adapter import OpenCodeBunAdapter
+
+__all__ = [
+    "ToolAdapter",
+    "OpenCodeBunAdapter",
+]

+ 62 - 0
agent/tools/adapters/base.py

@@ -0,0 +1,62 @@
+"""
+基础工具适配器 - 第三方工具适配接口
+
+职责:
+1. 定义统一的适配器接口
+2. 处理工具执行上下文的转换
+3. 统一返回值格式
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Dict
+
+from agent.tools.models import ToolResult, ToolContext
+
+
+class ToolAdapter(ABC):
+    """工具适配器基类"""
+
+    @abstractmethod
+    async def adapt_execute(
+        self,
+        tool_func: Callable,
+        args: Dict[str, Any],
+        context: ToolContext
+    ) -> ToolResult:
+        """
+        适配第三方工具的执行
+
+        Args:
+            tool_func: 原始工具函数/对象
+            args: 工具参数
+            context: 我们的上下文对象
+
+        Returns:
+            ToolResult: 统一的返回格式
+        """
+        pass
+
+    @abstractmethod
+    def adapt_schema(self, original_schema: Dict) -> Dict:
+        """
+        适配工具 Schema 到我们的格式
+
+        Args:
+            original_schema: 原始工具的 Schema
+
+        Returns:
+            适配后的 Schema(OpenAI Tool Schema 格式)
+        """
+        pass
+
+    def extract_memory(self, result: Any) -> str:
+        """
+        从结果中提取长期记忆摘要
+
+        Args:
+            result: 工具执行结果
+
+        Returns:
+            记忆摘要字符串
+        """
+        return ""

+ 120 - 0
agent/tools/adapters/opencode-wrapper.ts

@@ -0,0 +1,120 @@
+/**
+ * OpenCode Tool Wrapper - 供 Python 调用的命令行接口
+ *
+ * 用法:
+ * bun run tool-wrapper.ts <tool_name> <args_json>
+ *
+ * 示例:
+ * bun run tool-wrapper.ts read '{"filePath": "config.py"}'
+ *
+ * 支持的工具:
+ * - read: 读取文件
+ * - edit: 编辑文件
+ * - write: 写入文件
+ * - bash: 执行命令
+ * - glob: 文件匹配
+ * - grep: 内容搜索
+ * - webfetch: 抓取网页
+ * - lsp: LSP 诊断
+ */
+
+import { resolve } from 'path'
+
+// 动态导入工具(避免编译时依赖)
+async function loadTool(toolName: string) {
+  // 从 agent/tools/adapters/ 定位到 vendor/opencode
+  const toolPath = resolve(__dirname, '../../../../vendor/opencode/packages/opencode/src/tool')
+
+  switch (toolName) {
+    case 'read':
+      return (await import(`${toolPath}/read.ts`)).ReadTool
+    case 'edit':
+      return (await import(`${toolPath}/edit.ts`)).EditTool
+    case 'write':
+      return (await import(`${toolPath}/write.ts`)).WriteTool
+    case 'bash':
+      return (await import(`${toolPath}/bash.ts`)).BashTool
+    case 'glob':
+      return (await import(`${toolPath}/glob.ts`)).GlobTool
+    case 'grep':
+      return (await import(`${toolPath}/grep.ts`)).GrepTool
+    case 'webfetch':
+      return (await import(`${toolPath}/webfetch.ts`)).WebFetchTool
+    case 'lsp':
+      return (await import(`${toolPath}/lsp.ts`)).LspTool
+    default:
+      throw new Error(`Unknown tool: ${toolName}`)
+  }
+}
+
+async function main() {
+  const toolName = process.argv[2]
+  const argsJson = process.argv[3]
+
+  if (!toolName || !argsJson) {
+    console.error('Usage: bun run tool-wrapper.ts <tool_name> <args_json>')
+    console.error('Example: bun run tool-wrapper.ts read \'{"filePath": "test.py"}\'')
+    process.exit(1)
+  }
+
+  try {
+    // 解析参数
+    const args = JSON.parse(argsJson)
+
+    // 加载工具
+    const Tool = await loadTool(toolName)
+
+    // 构造最小化的 context(Python 调用不需要完整 context)
+    const context = {
+      sessionID: 'python-adapter',
+      messageID: 'python-adapter',
+      agent: 'python',
+      abort: new AbortController().signal,
+      messages: [],
+      metadata: () => {},
+      ask: async () => {}, // 跳过权限检查(由 Python 层处理)
+    }
+
+    // 初始化工具
+    const toolInfo = await Tool.init()
+
+    // 执行工具
+    const result = await toolInfo.execute(args, context)
+
+    // 输出 JSON 结果
+    const output = {
+      title: result.title,
+      output: result.output,
+      metadata: result.metadata || {},
+      attachments: result.attachments || []
+    }
+
+    console.log(JSON.stringify(output))
+
+  } catch (error: any) {
+    // 输出错误信息
+    const errorOutput = {
+      title: 'Error',
+      output: `Tool execution failed: ${error.message}`,
+      metadata: {
+        error: error.message,
+        stack: error.stack
+      }
+    }
+
+    console.error(JSON.stringify(errorOutput))
+    process.exit(1)
+  }
+}
+
+main().catch((error) => {
+  console.error(JSON.stringify({
+    title: 'Fatal Error',
+    output: `Fatal error: ${error.message}`,
+    metadata: {
+      error: error.message,
+      stack: error.stack
+    }
+  }))
+  process.exit(1)
+})

+ 138 - 0
agent/tools/adapters/opencode_bun_adapter.py

@@ -0,0 +1,138 @@
+"""
+OpenCode Bun 适配器 - 通过子进程调用 opencode 工具
+
+这个适配器真正调用 opencode 的 TypeScript 实现,
+而不是 Python 重新实现。
+
+使用场景:
+- 高级工具(LSP、CodeSearch 等)
+- 需要完整功能(9 种编辑策略)
+- 不在意性能开销(50-100ms per call)
+"""
+
+import json
+import asyncio
+import subprocess
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from agent.tools.adapters.base import ToolAdapter
+from agent.tools.models import ToolResult, ToolContext
+
+
+class OpenCodeBunAdapter(ToolAdapter):
+    """
+    通过 Bun 子进程调用 opencode 工具
+
+    需要安装 Bun: https://bun.sh/
+    """
+
+    def __init__(self):
+        # wrapper 和 adapter 在同一目录
+        self.wrapper_script = Path(__file__).parent / "opencode-wrapper.ts"
+        self.opencode_path = Path(__file__).parent.parent.parent.parent / "vendor/opencode"
+
+        # 检查 Bun 是否可用
+        self._check_bun()
+
+    def _check_bun(self):
+        """检查 Bun 运行时是否可用"""
+        try:
+            result = subprocess.run(
+                ["bun", "--version"],
+                capture_output=True,
+                timeout=5
+            )
+            if result.returncode != 0:
+                raise RuntimeError("Bun is not available")
+        except FileNotFoundError:
+            raise RuntimeError(
+                "Bun runtime not found. Install from https://bun.sh/\n"
+                "Or use Python-based tools instead."
+            )
+
+    async def adapt_execute(
+        self,
+        tool_name: str,  # 'read', 'edit', 'bash' 等
+        args: Dict[str, Any],
+        context: Optional[ToolContext] = None
+    ) -> ToolResult:
+        """
+        调用 opencode 工具
+
+        Args:
+            tool_name: opencode 工具名称
+            args: 工具参数
+            context: 上下文
+        """
+        # 构造命令
+        cmd = [
+            "bun", "run",
+            str(self.wrapper_script),
+            tool_name,
+            json.dumps(args)
+        ]
+
+        # 执行
+        try:
+            process = await asyncio.create_subprocess_exec(
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+                cwd=str(self.opencode_path)
+            )
+
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(),
+                timeout=30  # 30 秒超时
+            )
+
+            if process.returncode != 0:
+                error_msg = stderr.decode('utf-8', errors='replace')
+                return ToolResult(
+                    title="OpenCode Error",
+                    output=f"工具执行失败: {error_msg}",
+                    error=error_msg
+                )
+
+            # 解析结果
+            result_data = json.loads(stdout.decode('utf-8'))
+
+            # 转换为 ToolResult
+            return ToolResult(
+                title=result_data.get("title", ""),
+                output=result_data.get("output", ""),
+                metadata=result_data.get("metadata", {}),
+                long_term_memory=self.extract_memory(result_data)
+            )
+
+        except asyncio.TimeoutError:
+            return ToolResult(
+                title="Timeout",
+                output="OpenCode 工具执行超时",
+                error="Timeout after 30s"
+            )
+        except Exception as e:
+            return ToolResult(
+                title="Execution Error",
+                output=f"调用 OpenCode 失败: {str(e)}",
+                error=str(e)
+            )
+
+    def adapt_schema(self, original_schema: Dict) -> Dict:
+        """OpenCode 使用 OpenAI 格式,直接返回"""
+        return original_schema
+
+    def extract_memory(self, result: Dict) -> str:
+        """从 opencode 结果提取记忆"""
+        metadata = result.get("metadata", {})
+
+        if metadata.get("truncated"):
+            return f"输出被截断 (file: {result.get('title', '')})"
+
+        if "diagnostics" in metadata:
+            count = len(metadata["diagnostics"])
+            if count > 0:
+                return f"检测到 {count} 个诊断问题"
+
+        return ""

+ 15 - 0
agent/tools/advanced/__init__.py

@@ -0,0 +1,15 @@
+"""
+高级工具 - 通过 Bun 适配器调用 OpenCode
+
+这些工具实现复杂,直接调用 opencode 的 TypeScript 实现。
+
+需要 Bun 运行时:https://bun.sh/
+"""
+
+from agent.tools.advanced.webfetch import webfetch
+from agent.tools.advanced.lsp import lsp_diagnostics
+
+__all__ = [
+    "webfetch",
+    "lsp_diagnostics",
+]

+ 52 - 0
agent/tools/advanced/lsp.py

@@ -0,0 +1,52 @@
+"""
+LSP Tool - 通过 Bun 适配器调用 OpenCode
+
+Language Server Protocol 集成,提供代码诊断、补全等功能。
+"""
+
+from typing import Optional
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.adapters.opencode_bun_adapter import OpenCodeBunAdapter
+
+
+# 创建适配器实例
+_adapter = None
+
+def _get_adapter():
+    global _adapter
+    if _adapter is None:
+        _adapter = OpenCodeBunAdapter()
+    return _adapter
+
+
+@tool(description="获取文件的 LSP 诊断信息(语法错误、类型错误等)")
+async def lsp_diagnostics(
+    file_path: str,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    获取 LSP 诊断信息
+
+    使用 OpenCode 的 LSP 工具(通过 Bun 适配器调用)。
+    返回文件的语法错误、类型错误、代码警告等。
+
+    Args:
+        file_path: 文件路径
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 诊断信息
+    """
+    adapter = _get_adapter()
+
+    args = {
+        "filePath": file_path,
+    }
+
+    return await adapter.adapt_execute(
+        tool_name="lsp",
+        args=args,
+        context=context
+    )

+ 60 - 0
agent/tools/advanced/webfetch.py

@@ -0,0 +1,60 @@
+"""
+WebFetch Tool - 通过 Bun 适配器调用 OpenCode
+
+网页抓取功能,包括 HTML 转 Markdown、内容提取等复杂逻辑。
+"""
+
+from typing import Optional
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.adapters.opencode_bun_adapter import OpenCodeBunAdapter
+
+
+# 创建适配器实例
+_adapter = None
+
+def _get_adapter():
+    global _adapter
+    if _adapter is None:
+        _adapter = OpenCodeBunAdapter()
+    return _adapter
+
+
+@tool(description="抓取网页内容并转换为 Markdown 格式")
+async def webfetch(
+    url: str,
+    format: str = "markdown",
+    timeout: Optional[int] = None,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    抓取网页内容
+
+    使用 OpenCode 的 webfetch 工具(通过 Bun 适配器调用)。
+    包含 HTML 到 Markdown 转换、内容清理等功能。
+
+    Args:
+        url: 网页 URL
+        format: 输出格式(markdown, text, html),默认 markdown
+        timeout: 超时时间(秒)
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 网页内容
+    """
+    adapter = _get_adapter()
+
+    args = {
+        "url": url,
+        "format": format,
+    }
+
+    if timeout is not None:
+        args["timeout"] = timeout
+
+    return await adapter.adapt_execute(
+        tool_name="webfetch",
+        args=args,
+        context=context
+    )

+ 27 - 0
agent/tools/builtin/__init__.py

@@ -0,0 +1,27 @@
+"""
+内置基础工具 - 参考 opencode 实现
+
+这些工具参考 vendor/opencode/packages/opencode/src/tool/ 的设计,
+在 Python 中重新实现核心功能。
+
+参考版本:opencode main branch (2025-01)
+"""
+
+from agent.tools.builtin.read import read_file
+from agent.tools.builtin.edit import edit_file
+from agent.tools.builtin.write import write_file
+from agent.tools.builtin.glob import glob_files
+from agent.tools.builtin.grep import grep_content
+from agent.tools.builtin.bash import bash_command
+from agent.tools.builtin.skill import skill, list_skills
+
+__all__ = [
+    "read_file",
+    "edit_file",
+    "write_file",
+    "glob_files",
+    "grep_content",
+    "bash_command",
+    "skill",
+    "list_skills",
+]

+ 152 - 0
agent/tools/builtin/bash.py

@@ -0,0 +1,152 @@
+"""
+Bash Tool - 命令执行工具
+
+参考 OpenCode bash.ts 完整实现。
+
+核心功能:
+- 执行 shell 命令
+- 超时控制
+- 工作目录设置
+- 环境变量传递
+"""
+
+import subprocess
+import asyncio
+from pathlib import Path
+from typing import Optional, Dict
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+DEFAULT_TIMEOUT = 120  # 2 分钟
+MAX_OUTPUT_LENGTH = 50000  # 最大输出长度
+
+
+@tool(description="执行 bash 命令")
+async def bash_command(
+    command: str,
+    timeout: Optional[int] = None,
+    workdir: Optional[str] = None,
+    env: Optional[Dict[str, str]] = None,
+    description: str = "",
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    执行 bash 命令
+
+    Args:
+        command: 要执行的命令
+        timeout: 超时时间(秒),默认 120 秒
+        workdir: 工作目录,默认为当前目录
+        env: 环境变量字典(会合并到系统环境变量)
+        description: 命令描述(5-10 个词)
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 命令输出
+    """
+    # 参数验证
+    if timeout is not None and timeout < 0:
+        return ToolResult(
+            title="参数错误",
+            output=f"无效的 timeout 值: {timeout}。必须是正数。",
+            error="Invalid timeout"
+        )
+
+    timeout_sec = timeout or DEFAULT_TIMEOUT
+
+    # 工作目录
+    cwd = Path(workdir) if workdir else Path.cwd()
+    if not cwd.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"工作目录不存在: {workdir}",
+            error="Directory not found"
+        )
+
+    # 准备环境变量
+    import os
+    process_env = os.environ.copy()
+    if env:
+        process_env.update(env)
+
+    # 执行命令
+    try:
+        process = await asyncio.create_subprocess_shell(
+            command,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+            cwd=str(cwd),
+            env=process_env
+        )
+
+        # 等待命令完成(带超时)
+        try:
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(),
+                timeout=timeout_sec
+            )
+        except asyncio.TimeoutError:
+            # 超时,终止进程
+            process.kill()
+            await process.wait()
+            return ToolResult(
+                title="命令超时",
+                output=f"命令执行超时(>{timeout_sec}s): {command[:100]}",
+                error="Timeout",
+                metadata={"command": command, "timeout": timeout_sec}
+            )
+
+        # 解码输出
+        stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
+        stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
+
+        # 截断过长输出
+        truncated = False
+        if len(stdout_text) > MAX_OUTPUT_LENGTH:
+            stdout_text = stdout_text[:MAX_OUTPUT_LENGTH] + f"\n\n(输出被截断,总长度: {len(stdout_text)} 字符)"
+            truncated = True
+
+        # 组合输出
+        output = ""
+        if stdout_text:
+            output += stdout_text
+
+        if stderr_text:
+            if output:
+                output += "\n\n--- stderr ---\n"
+            output += stderr_text
+
+        if not output:
+            output = "(命令无输出)"
+
+        # 检查退出码
+        exit_code = process.returncode
+        success = exit_code == 0
+
+        title = description or f"命令: {command[:50]}"
+        if not success:
+            title += f" (exit code: {exit_code})"
+
+        return ToolResult(
+            title=title,
+            output=output,
+            metadata={
+                "exit_code": exit_code,
+                "success": success,
+                "truncated": truncated,
+                "command": command,
+                "cwd": str(cwd)
+            },
+            error=None if success else f"Command failed with exit code {exit_code}"
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="执行错误",
+            output=f"命令执行失败: {str(e)}",
+            error=str(e),
+            metadata={"command": command}
+        )

+ 533 - 0
agent/tools/builtin/edit.py

@@ -0,0 +1,533 @@
+"""
+Edit Tool - 文件编辑工具
+
+参考 OpenCode 的 edit.ts 完整实现。
+
+核心功能:
+- 精确字符串替换
+- 9 种智能匹配策略(按优先级依次尝试)
+- 生成 diff 预览
+"""
+
+from pathlib import Path
+from typing import Optional, Generator
+import difflib
+import re
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+@tool(description="编辑文件,使用精确字符串替换。支持多种智能匹配策略。")
+async def edit_file(
+    file_path: str,
+    old_string: str,
+    new_string: str,
+    replace_all: bool = False,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    编辑文件内容
+
+    使用 9 种智能匹配策略,按优先级尝试:
+    1. SimpleReplacer - 精确匹配
+    2. LineTrimmedReplacer - 忽略行首尾空白
+    3. BlockAnchorReplacer - 基于首尾锚点的块匹配(使用 Levenshtein 相似度)
+    4. WhitespaceNormalizedReplacer - 空白归一化
+    5. IndentationFlexibleReplacer - 灵活缩进匹配
+    6. EscapeNormalizedReplacer - 转义序列归一化
+    7. TrimmedBoundaryReplacer - 边界空白裁剪
+    8. ContextAwareReplacer - 上下文感知匹配
+    9. MultiOccurrenceReplacer - 多次出现匹配
+
+    Args:
+        file_path: 文件路径
+        old_string: 要替换的文本
+        new_string: 替换后的文本
+        replace_all: 是否替换所有匹配(默认 False,只替换唯一匹配)
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 编辑结果和 diff
+    """
+    if old_string == new_string:
+        return ToolResult(
+            title="无需编辑",
+            output="old_string 和 new_string 相同",
+            error="Strings are identical"
+        )
+
+    # 解析路径
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = Path.cwd() / path
+
+    # 检查文件
+    if not path.exists():
+        return ToolResult(
+            title="文件未找到",
+            output=f"文件不存在: {file_path}",
+            error="File not found"
+        )
+
+    if path.is_dir():
+        return ToolResult(
+            title="路径错误",
+            output=f"路径是目录,不是文件: {file_path}",
+            error="Path is a directory"
+        )
+
+    # 读取文件
+    try:
+        with open(path, 'r', encoding='utf-8') as f:
+            content_old = f.read()
+    except Exception as e:
+        return ToolResult(
+            title="读取失败",
+            output=f"无法读取文件: {str(e)}",
+            error=str(e)
+        )
+
+    # 执行替换
+    try:
+        content_new = replace(content_old, old_string, new_string, replace_all)
+    except ValueError as e:
+        return ToolResult(
+            title="替换失败",
+            output=str(e),
+            error=str(e)
+        )
+
+    # 生成 diff
+    diff = _create_diff(file_path, content_old, content_new)
+
+    # 写入文件
+    try:
+        with open(path, 'w', encoding='utf-8') as f:
+            f.write(content_new)
+    except Exception as e:
+        return ToolResult(
+            title="写入失败",
+            output=f"无法写入文件: {str(e)}",
+            error=str(e)
+        )
+
+    # 统计变更
+    old_lines = content_old.count('\n')
+    new_lines = content_new.count('\n')
+
+    return ToolResult(
+        title=path.name,
+        output=f"编辑成功\n\n{diff}",
+        metadata={
+            "diff": diff,
+            "old_lines": old_lines,
+            "new_lines": new_lines,
+            "additions": max(0, new_lines - old_lines),
+            "deletions": max(0, old_lines - new_lines)
+        },
+        long_term_memory=f"已编辑文件 {path.name}"
+    )
+
+
+# ============================================================================
+# 替换策略(Replacers)
+# ============================================================================
+
+def replace(content: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
+    """
+    使用多种策略尝试替换
+
+    按优先级尝试所有策略,直到找到匹配
+    """
+    if old_string == new_string:
+        raise ValueError("old_string 和 new_string 必须不同")
+
+    not_found = True
+
+    # 按优先级尝试策略
+    for replacer in [
+        simple_replacer,
+        line_trimmed_replacer,
+        block_anchor_replacer,
+        whitespace_normalized_replacer,
+        indentation_flexible_replacer,
+        escape_normalized_replacer,
+        trimmed_boundary_replacer,
+        context_aware_replacer,
+        multi_occurrence_replacer,
+    ]:
+        for search in replacer(content, old_string):
+            index = content.find(search)
+            if index == -1:
+                continue
+
+            not_found = False
+
+            if replace_all:
+                return content.replace(search, new_string)
+
+            # 检查唯一性
+            last_index = content.rfind(search)
+            if index != last_index:
+                continue
+
+            return content[:index] + new_string + content[index + len(search):]
+
+    if not_found:
+        raise ValueError("在文件中未找到匹配的文本")
+
+    raise ValueError(
+        "找到多个匹配。请在 old_string 中提供更多上下文以唯一标识,"
+        "或使用 replace_all=True 替换所有匹配。"
+    )
+
+
+# 1. SimpleReplacer - 精确匹配
+def simple_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """精确匹配"""
+    yield find
+
+
+# 2. LineTrimmedReplacer - 忽略行首尾空白
+def line_trimmed_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """忽略行首尾空白进行匹配"""
+    content_lines = content.split('\n')
+    find_lines = find.rstrip('\n').split('\n')
+
+    for i in range(len(content_lines) - len(find_lines) + 1):
+        # 检查所有行是否匹配(忽略首尾空白)
+        matches = all(
+            content_lines[i + j].strip() == find_lines[j].strip()
+            for j in range(len(find_lines))
+        )
+
+        if matches:
+            # 计算原始文本位置
+            match_start = sum(len(content_lines[k]) + 1 for k in range(i))
+            match_end = match_start + sum(
+                len(content_lines[i + k]) + (1 if k < len(find_lines) - 1 else 0)
+                for k in range(len(find_lines))
+            )
+            yield content[match_start:match_end]
+
+
+# 3. BlockAnchorReplacer - 基于首尾锚点的块匹配
+def block_anchor_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """
+    基于首尾行作为锚点进行块匹配
+    使用 Levenshtein 距离计算中间行的相似度
+    """
+    content_lines = content.split('\n')
+    find_lines = find.rstrip('\n').split('\n')
+
+    if len(find_lines) < 3:
+        return
+
+    first_line_find = find_lines[0].strip()
+    last_line_find = find_lines[-1].strip()
+    find_block_size = len(find_lines)
+
+    # 收集所有候选位置(首尾行都匹配)
+    candidates = []
+    for i in range(len(content_lines)):
+        if content_lines[i].strip() != first_line_find:
+            continue
+
+        # 查找匹配的尾行
+        for j in range(i + 2, len(content_lines)):
+            if content_lines[j].strip() == last_line_find:
+                candidates.append((i, j))
+                break
+
+    if not candidates:
+        return
+
+    # 单个候选:使用宽松阈值
+    if len(candidates) == 1:
+        start_line, end_line = candidates[0]
+        actual_block_size = end_line - start_line + 1
+
+        similarity = _calculate_block_similarity(
+            content_lines[start_line:end_line + 1],
+            find_lines
+        )
+
+        if similarity >= 0.0:  # SINGLE_CANDIDATE_SIMILARITY_THRESHOLD
+            match_start = sum(len(content_lines[k]) + 1 for k in range(start_line))
+            match_end = match_start + sum(
+                len(content_lines[k]) + (1 if k < end_line else 0)
+                for k in range(start_line, end_line + 1)
+            )
+            yield content[match_start:match_end]
+        return
+
+    # 多个候选:选择相似度最高的
+    best_match = None
+    max_similarity = -1
+
+    for start_line, end_line in candidates:
+        similarity = _calculate_block_similarity(
+            content_lines[start_line:end_line + 1],
+            find_lines
+        )
+
+        if similarity > max_similarity:
+            max_similarity = similarity
+            best_match = (start_line, end_line)
+
+    if max_similarity >= 0.3 and best_match:  # MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD
+        start_line, end_line = best_match
+        match_start = sum(len(content_lines[k]) + 1 for k in range(start_line))
+        match_end = match_start + sum(
+            len(content_lines[k]) + (1 if k < end_line else 0)
+            for k in range(start_line, end_line + 1)
+        )
+        yield content[match_start:match_end]
+
+
+def _calculate_block_similarity(content_block: list, find_block: list) -> float:
+    """计算块相似度(使用 Levenshtein 距离)"""
+    actual_size = len(content_block)
+    find_size = len(find_block)
+    lines_to_check = min(find_size - 2, actual_size - 2)
+
+    if lines_to_check <= 0:
+        return 1.0
+
+    similarity = 0.0
+    for j in range(1, min(find_size - 1, actual_size - 1)):
+        content_line = content_block[j].strip()
+        find_line = find_block[j].strip()
+        max_len = max(len(content_line), len(find_line))
+
+        if max_len == 0:
+            continue
+
+        distance = _levenshtein(content_line, find_line)
+        similarity += (1 - distance / max_len) / lines_to_check
+
+    return similarity
+
+
+def _levenshtein(a: str, b: str) -> int:
+    """Levenshtein 距离算法"""
+    if not a:
+        return len(b)
+    if not b:
+        return len(a)
+
+    matrix = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
+
+    for i in range(len(a) + 1):
+        matrix[i][0] = i
+    for j in range(len(b) + 1):
+        matrix[0][j] = j
+
+    for i in range(1, len(a) + 1):
+        for j in range(1, len(b) + 1):
+            cost = 0 if a[i - 1] == b[j - 1] else 1
+            matrix[i][j] = min(
+                matrix[i - 1][j] + 1,      # 删除
+                matrix[i][j - 1] + 1,      # 插入
+                matrix[i - 1][j - 1] + cost  # 替换
+            )
+
+    return matrix[len(a)][len(b)]
+
+
+# 4. WhitespaceNormalizedReplacer - 空白归一化
+def whitespace_normalized_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """空白归一化匹配(所有空白序列归一化为单个空格)"""
+    def normalize_ws(text: str) -> str:
+        return re.sub(r'\s+', ' ', text).strip()
+
+    normalized_find = normalize_ws(find)
+    lines = content.split('\n')
+
+    # 单行匹配
+    for line in lines:
+        if normalize_ws(line) == normalized_find:
+            yield line
+            continue
+
+        # 子串匹配
+        if normalized_find in normalize_ws(line):
+            words = find.strip().split()
+            if words:
+                pattern = r'\s+'.join(re.escape(word) for word in words)
+                match = re.search(pattern, line)
+                if match:
+                    yield match.group(0)
+
+    # 多行匹配
+    find_lines = find.split('\n')
+    if len(find_lines) > 1:
+        for i in range(len(lines) - len(find_lines) + 1):
+            block = lines[i:i + len(find_lines)]
+            if normalize_ws('\n'.join(block)) == normalized_find:
+                yield '\n'.join(block)
+
+
+# 5. IndentationFlexibleReplacer - 灵活缩进匹配
+def indentation_flexible_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """移除缩进后匹配"""
+    def remove_indentation(text: str) -> str:
+        lines = text.split('\n')
+        non_empty = [line for line in lines if line.strip()]
+
+        if not non_empty:
+            return text
+
+        min_indent = min(len(line) - len(line.lstrip()) for line in non_empty)
+        return '\n'.join(
+            line[min_indent:] if line.strip() else line
+            for line in lines
+        )
+
+    normalized_find = remove_indentation(find)
+    content_lines = content.split('\n')
+    find_lines = find.split('\n')
+
+    for i in range(len(content_lines) - len(find_lines) + 1):
+        block = '\n'.join(content_lines[i:i + len(find_lines)])
+        if remove_indentation(block) == normalized_find:
+            yield block
+
+
+# 6. EscapeNormalizedReplacer - 转义序列归一化
+def escape_normalized_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """反转义后匹配"""
+    def unescape_string(s: str) -> str:
+        replacements = {
+            r'\n': '\n',
+            r'\t': '\t',
+            r'\r': '\r',
+            r"\'": "'",
+            r'\"': '"',
+            r'\`': '`',
+            r'\\': '\\',
+            r'\$': '$',
+        }
+        result = s
+        for escaped, unescaped in replacements.items():
+            result = result.replace(escaped, unescaped)
+        return result
+
+    unescaped_find = unescape_string(find)
+
+    # 直接匹配
+    if unescaped_find in content:
+        yield unescaped_find
+
+    # 尝试反转义后匹配
+    lines = content.split('\n')
+    find_lines = unescaped_find.split('\n')
+
+    for i in range(len(lines) - len(find_lines) + 1):
+        block = '\n'.join(lines[i:i + len(find_lines)])
+        if unescape_string(block) == unescaped_find:
+            yield block
+
+
+# 7. TrimmedBoundaryReplacer - 边界空白裁剪
+def trimmed_boundary_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """裁剪边界空白后匹配"""
+    trimmed_find = find.strip()
+
+    if trimmed_find == find:
+        return  # 已经是 trimmed,无需尝试
+
+    # 尝试匹配 trimmed 版本
+    if trimmed_find in content:
+        yield trimmed_find
+
+    # 尝试块匹配
+    lines = content.split('\n')
+    find_lines = find.split('\n')
+
+    for i in range(len(lines) - len(find_lines) + 1):
+        block = '\n'.join(lines[i:i + len(find_lines)])
+        if block.strip() == trimmed_find:
+            yield block
+
+
+# 8. ContextAwareReplacer - 上下文感知匹配
+def context_aware_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """基于上下文(首尾行)匹配,允许中间行有差异"""
+    find_lines = find.split('\n')
+    if find_lines and find_lines[-1] == '':
+        find_lines.pop()
+
+    if len(find_lines) < 3:
+        return
+
+    content_lines = content.split('\n')
+    first_line = find_lines[0].strip()
+    last_line = find_lines[-1].strip()
+
+    # 查找首尾匹配的块
+    for i in range(len(content_lines)):
+        if content_lines[i].strip() != first_line:
+            continue
+
+        for j in range(i + 2, len(content_lines)):
+            if content_lines[j].strip() == last_line:
+                block_lines = content_lines[i:j + 1]
+
+                # 检查块长度是否匹配
+                if len(block_lines) == len(find_lines):
+                    # 计算中间行匹配率
+                    matching_lines = 0
+                    total_non_empty = 0
+
+                    for k in range(1, len(block_lines) - 1):
+                        block_line = block_lines[k].strip()
+                        find_line = find_lines[k].strip()
+
+                        if block_line or find_line:
+                            total_non_empty += 1
+                            if block_line == find_line:
+                                matching_lines += 1
+
+                    # 至少 50% 的中间行匹配
+                    if total_non_empty == 0 or matching_lines / total_non_empty >= 0.5:
+                        yield '\n'.join(block_lines)
+                        break
+                break
+
+
+# 9. MultiOccurrenceReplacer - 多次出现匹配
+def multi_occurrence_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """yield 所有精确匹配,用于 replace_all"""
+    start_index = 0
+    while True:
+        index = content.find(find, start_index)
+        if index == -1:
+            break
+        yield find
+        start_index = index + len(find)
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def _create_diff(filepath: str, old_content: str, new_content: str) -> str:
+    """生成 unified diff"""
+    old_lines = old_content.splitlines(keepends=True)
+    new_lines = new_content.splitlines(keepends=True)
+
+    diff_lines = list(difflib.unified_diff(
+        old_lines,
+        new_lines,
+        fromfile=f"a/{filepath}",
+        tofile=f"b/{filepath}",
+        lineterm=''
+    ))
+
+    if not diff_lines:
+        return "(无变更)"
+
+    return ''.join(diff_lines)

+ 110 - 0
agent/tools/builtin/glob.py

@@ -0,0 +1,110 @@
+"""
+Glob Tool - 文件模式匹配工具
+
+参考:vendor/opencode/packages/opencode/src/tool/glob.ts
+
+核心功能:
+- 使用 glob 模式匹配文件
+- 按修改时间排序
+- 限制返回数量
+"""
+
+import glob as glob_module
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
+
+
+@tool(description="使用 glob 模式匹配文件")
+async def glob_files(
+    pattern: str,
+    path: Optional[str] = None,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    使用 glob 模式匹配文件
+
+    参考 OpenCode 实现
+
+    Args:
+        pattern: glob 模式(如 "*.py", "src/**/*.ts")
+        path: 搜索目录(默认当前目录)
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 匹配的文件列表
+    """
+    # 确定搜索路径
+    search_path = Path(path) if path else Path.cwd()
+    if not search_path.is_absolute():
+        search_path = Path.cwd() / search_path
+
+    if not search_path.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"搜索目录不存在: {path}",
+            error="Directory not found"
+        )
+
+    # 执行 glob 搜索
+    try:
+        # 使用 pathlib 的 glob(支持 ** 递归)
+        if "**" in pattern:
+            matches = list(search_path.glob(pattern))
+        else:
+            # 使用标准 glob(更快)
+            pattern_path = search_path / pattern
+            matches = [Path(p) for p in glob_module.glob(str(pattern_path))]
+
+        # 过滤掉目录,只保留文件
+        file_matches = [m for m in matches if m.is_file()]
+
+        # 按修改时间排序(参考 opencode:47-56)
+        file_matches_with_mtime = []
+        for file_path in file_matches:
+            try:
+                mtime = file_path.stat().st_mtime
+                file_matches_with_mtime.append((file_path, mtime))
+            except Exception:
+                file_matches_with_mtime.append((file_path, 0))
+
+        # 按修改时间降序排序(最新的在前)
+        file_matches_with_mtime.sort(key=lambda x: x[1], reverse=True)
+
+        # 限制数量
+        truncated = len(file_matches_with_mtime) > LIMIT
+        file_matches_with_mtime = file_matches_with_mtime[:LIMIT]
+
+        # 格式化输出
+        if not file_matches_with_mtime:
+            output = "未找到匹配的文件"
+        else:
+            file_paths = [str(f[0]) for f in file_matches_with_mtime]
+            output = "\n".join(file_paths)
+
+            if truncated:
+                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+
+        return ToolResult(
+            title=f"匹配: {pattern}",
+            output=output,
+            metadata={
+                "count": len(file_matches_with_mtime),
+                "truncated": truncated,
+                "pattern": pattern,
+                "search_path": str(search_path)
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="Glob 错误",
+            output=f"glob 匹配失败: {str(e)}",
+            error=str(e)
+        )

+ 218 - 0
agent/tools/builtin/grep.py

@@ -0,0 +1,218 @@
+"""
+Grep Tool - 内容搜索工具
+
+参考:vendor/opencode/packages/opencode/src/tool/grep.ts
+
+核心功能:
+- 在文件中搜索正则表达式模式
+- 支持文件类型过滤
+- 按修改时间排序结果
+"""
+
+import re
+import subprocess
+from pathlib import Path
+from typing import Optional, List, Tuple
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+LIMIT = 100  # 最大返回匹配数(参考 opencode grep.ts:107)
+MAX_LINE_LENGTH = 2000  # 最大行长度(参考 opencode grep.ts:10)
+
+
+@tool(description="在文件内容中搜索模式")
+async def grep_content(
+    pattern: str,
+    path: Optional[str] = None,
+    include: Optional[str] = None,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    在文件中搜索正则表达式模式
+
+    参考 OpenCode 实现
+
+    优先使用 ripgrep(如果可用),否则使用 Python 实现。
+
+    Args:
+        pattern: 正则表达式模式
+        path: 搜索目录(默认当前目录)
+        include: 文件模式(如 "*.py", "*.{ts,tsx}")
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 搜索结果
+    """
+    # 确定搜索路径
+    search_path = Path(path) if path else Path.cwd()
+    if not search_path.is_absolute():
+        search_path = Path.cwd() / search_path
+
+    if not search_path.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"搜索目录不存在: {path}",
+            error="Directory not found"
+        )
+
+    # 尝试使用 ripgrep
+    try:
+        matches = await _ripgrep_search(pattern, search_path, include)
+    except Exception:
+        # ripgrep 不可用,使用 Python 实现
+        matches = await _python_search(pattern, search_path, include)
+
+    # 按修改时间排序(参考 opencode:105)
+    matches_with_mtime = []
+    for file_path, line_num, line_text in matches:
+        try:
+            mtime = file_path.stat().st_mtime
+            matches_with_mtime.append((file_path, line_num, line_text, mtime))
+        except Exception:
+            matches_with_mtime.append((file_path, line_num, line_text, 0))
+
+    matches_with_mtime.sort(key=lambda x: x[3], reverse=True)
+
+    # 限制数量
+    truncated = len(matches_with_mtime) > LIMIT
+    matches_with_mtime = matches_with_mtime[:LIMIT]
+
+    # 格式化输出(参考 opencode:118-133)
+    if not matches_with_mtime:
+        output = "未找到匹配"
+    else:
+        output = f"找到 {len(matches_with_mtime)} 个匹配\n"
+
+        current_file = None
+        for file_path, line_num, line_text, _ in matches_with_mtime:
+            if current_file != file_path:
+                if current_file is not None:
+                    output += "\n"
+                current_file = file_path
+                output += f"\n{file_path}:\n"
+
+            # 截断过长的行
+            if len(line_text) > MAX_LINE_LENGTH:
+                line_text = line_text[:MAX_LINE_LENGTH] + "..."
+
+            output += f"  Line {line_num}: {line_text}\n"
+
+        if truncated:
+            output += "\n(结果已截断。考虑使用更具体的路径或模式。)"
+
+    return ToolResult(
+        title=f"搜索: {pattern}",
+        output=output,
+        metadata={
+            "matches": len(matches_with_mtime),
+            "truncated": truncated,
+            "pattern": pattern
+        }
+    )
+
+
+async def _ripgrep_search(
+    pattern: str,
+    search_path: Path,
+    include: Optional[str]
+) -> List[Tuple[Path, int, str]]:
+    """
+    使用 ripgrep 搜索
+
+    参考 OpenCode 实现
+    """
+    args = [
+        "rg",
+        "-nH",  # 显示行号和文件名
+        "--hidden",
+        "--follow",
+        "--no-messages",
+        "--field-match-separator=|",
+        "--regexp", pattern
+    ]
+
+    if include:
+        args.extend(["--glob", include])
+
+    args.append(str(search_path))
+
+    # 执行 ripgrep
+    process = await subprocess.create_subprocess_exec(
+        *args,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE
+    )
+
+    stdout, stderr = await process.communicate()
+    exit_code = process.returncode
+
+    # Exit codes: 0 = matches, 1 = no matches, 2 = errors
+    if exit_code == 1:
+        return []
+
+    if exit_code != 0 and exit_code != 2:
+        raise RuntimeError(f"ripgrep failed: {stderr.decode()}")
+
+    # 解析输出
+    matches = []
+    for line in stdout.decode('utf-8', errors='replace').strip().split('\n'):
+        if not line:
+            continue
+
+        parts = line.split('|', 2)
+        if len(parts) < 3:
+            continue
+
+        file_path_str, line_num_str, line_text = parts
+        matches.append((
+            Path(file_path_str),
+            int(line_num_str),
+            line_text
+        ))
+
+    return matches
+
+
+async def _python_search(
+    pattern: str,
+    search_path: Path,
+    include: Optional[str]
+) -> List[Tuple[Path, int, str]]:
+    """
+    使用 Python 正则实现搜索(fallback)
+    """
+    try:
+        regex = re.compile(pattern)
+    except Exception as e:
+        raise ValueError(f"无效的正则表达式: {e}")
+
+    matches = []
+
+    # 确定要搜索的文件
+    if include:
+        # 简单的 glob 匹配
+        import glob
+        file_pattern = str(search_path / "**" / include)
+        files = [Path(f) for f in glob.glob(file_pattern, recursive=True)]
+    else:
+        # 搜索所有文本文件
+        files = [f for f in search_path.rglob("*") if f.is_file()]
+
+    # 搜索文件内容
+    for file_path in files:
+        try:
+            with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+                for line_num, line in enumerate(f, 1):
+                    if regex.search(line):
+                        matches.append((file_path, line_num, line.rstrip('\n')))
+
+                    # 限制数量避免过多搜索
+                    if len(matches) >= LIMIT * 2:
+                        return matches
+        except Exception:
+            continue
+
+    return matches

+ 229 - 0
agent/tools/builtin/read.py

@@ -0,0 +1,229 @@
+"""
+Read Tool - 文件读取工具
+
+参考 OpenCode read.ts 完整实现。
+
+核心功能:
+- 支持文本文件、图片、PDF
+- 分页读取(offset/limit)
+- 二进制文件检测
+- 行长度和字节限制
+"""
+
+import os
+import mimetypes
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量(参考 opencode)
+DEFAULT_READ_LIMIT = 2000
+MAX_LINE_LENGTH = 2000
+MAX_BYTES = 50 * 1024  # 50KB
+
+
+@tool(description="读取文件内容,支持文本文件、图片、PDF 等多种格式")
+async def read_file(
+    file_path: str,
+    offset: int = 0,
+    limit: int = DEFAULT_READ_LIMIT,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    读取文件内容
+
+    参考 OpenCode 实现
+
+    Args:
+        file_path: 文件路径(绝对路径或相对路径)
+        offset: 起始行号(从 0 开始)
+        limit: 读取行数(默认 2000 行)
+        uid: 用户 ID(自动注入)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 文件内容
+    """
+    # 解析路径
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = Path.cwd() / path
+
+    # 检查文件是否存在
+    if not path.exists():
+        # 尝试提供建议(参考 opencode:44-60)
+        parent_dir = path.parent
+        if parent_dir.exists():
+            candidates = [
+                f for f in parent_dir.iterdir()
+                if path.name.lower() in f.name.lower() or f.name.lower() in path.name.lower()
+            ][:3]
+
+            if candidates:
+                suggestions = "\n".join(str(c) for c in candidates)
+                return ToolResult(
+                    title=f"文件未找到: {path.name}",
+                    output=f"文件不存在: {file_path}\n\n你是否想要:\n{suggestions}",
+                    error="File not found"
+                )
+
+        return ToolResult(
+            title="文件未找到",
+            output=f"文件不存在: {file_path}",
+            error="File not found"
+        )
+
+    # 检测文件类型
+    mime_type, _ = mimetypes.guess_type(str(path))
+    mime_type = mime_type or ""
+
+    # 图片文件(参考 opencode:66-91)
+    if mime_type.startswith("image/") and mime_type not in ["image/svg+xml", "image/vnd.fastbidsheet"]:
+        # 注意:实际项目中需要实现图片的 base64 编码
+        # 这里简化处理
+        return ToolResult(
+            title=path.name,
+            output=f"图片文件: {path.name} (MIME: {mime_type})",
+            metadata={"mime_type": mime_type, "truncated": False}
+        )
+
+    # PDF 文件
+    if mime_type == "application/pdf":
+        return ToolResult(
+            title=path.name,
+            output=f"PDF 文件: {path.name}",
+            metadata={"mime_type": mime_type, "truncated": False}
+        )
+
+    # 二进制文件检测(参考 opencode:156-211)
+    if _is_binary_file(path):
+        return ToolResult(
+            title="二进制文件",
+            output=f"无法读取二进制文件: {path.name}",
+            error="Binary file"
+        )
+
+    # 读取文本文件(参考 opencode:96-143)
+    try:
+        with open(path, 'r', encoding='utf-8') as f:
+            lines = f.readlines()
+
+        total_lines = len(lines)
+        end_line = min(offset + limit, total_lines)
+
+        # 截取行并处理长度限制
+        output_lines = []
+        total_bytes = 0
+        truncated_by_bytes = False
+
+        for i in range(offset, end_line):
+            line = lines[i].rstrip('\n\r')
+
+            # 行长度限制(参考 opencode:104)
+            if len(line) > MAX_LINE_LENGTH:
+                line = line[:MAX_LINE_LENGTH] + "..."
+
+            # 字节限制(参考 opencode:105-112)
+            line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0)
+            if total_bytes + line_bytes > MAX_BYTES:
+                truncated_by_bytes = True
+                break
+
+            output_lines.append(line)
+            total_bytes += line_bytes
+
+        # 格式化输出(参考 opencode:114-134)
+        formatted = []
+        for idx, line in enumerate(output_lines):
+            line_num = offset + idx + 1
+            formatted.append(f"{line_num:5d}| {line}")
+
+        output = "<file>\n" + "\n".join(formatted)
+
+        last_read_line = offset + len(output_lines)
+        has_more = total_lines > last_read_line
+        truncated = has_more or truncated_by_bytes
+
+        # 添加提示
+        if truncated_by_bytes:
+            output += f"\n\n(输出在 {MAX_BYTES} 字节处被截断。使用 'offset' 参数读取第 {last_read_line} 行之后的内容)"
+        elif has_more:
+            output += f"\n\n(文件还有更多内容。使用 'offset' 参数读取第 {last_read_line} 行之后的内容)"
+        else:
+            output += f"\n\n(文件结束 - 共 {total_lines} 行)"
+
+        output += "\n</file>"
+
+        # 预览(前 20 行)
+        preview = "\n".join(output_lines[:20])
+
+        return ToolResult(
+            title=path.name,
+            output=output,
+            metadata={
+                "preview": preview,
+                "truncated": truncated,
+                "total_lines": total_lines,
+                "read_lines": len(output_lines)
+            }
+        )
+
+    except UnicodeDecodeError:
+        return ToolResult(
+            title="编码错误",
+            output=f"无法解码文件(非 UTF-8 编码): {path.name}",
+            error="Encoding error"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="读取错误",
+            output=f"读取文件时出错: {str(e)}",
+            error=str(e)
+        )
+
+
+def _is_binary_file(path: Path) -> bool:
+    """
+    检测是否为二进制文件
+
+    参考 OpenCode 实现
+    """
+    # 常见二进制扩展名
+    binary_exts = {
+        '.zip', '.tar', '.gz', '.exe', '.dll', '.so', '.class',
+        '.jar', '.war', '.7z', '.doc', '.docx', '.xls', '.xlsx',
+        '.ppt', '.pptx', '.odt', '.ods', '.odp', '.bin', '.dat',
+        '.obj', '.o', '.a', '.lib', '.wasm', '.pyc', '.pyo'
+    }
+
+    if path.suffix.lower() in binary_exts:
+        return True
+
+    # 检查文件内容
+    try:
+        file_size = path.stat().st_size
+        if file_size == 0:
+            return False
+
+        # 读取前 4KB
+        buffer_size = min(4096, file_size)
+        with open(path, 'rb') as f:
+            buffer = f.read(buffer_size)
+
+        # 检测 null 字节
+        if b'\x00' in buffer:
+            return True
+
+        # 统计非打印字符(参考 opencode:202-210)
+        non_printable = 0
+        for byte in buffer:
+            if byte < 9 or (13 < byte < 32):
+                non_printable += 1
+
+        # 如果超过 30% 是非打印字符,认为是二进制
+        return non_printable / len(buffer) > 0.3
+
+    except Exception:
+        return False

+ 433 - 0
agent/tools/builtin/sandbox.py

@@ -0,0 +1,433 @@
+"""
+Sandbox Tools (Async)
+通过 HTTP 异步调用沙盒管理服务的客户端库。
+"""
+
+import json
+import httpx
+from typing import Optional, List, Dict, Any
+
+from agent import tool
+from agent.tools.models import ToolResult
+
+
+# 服务地址,可根据实际部署情况修改
+SANDBOX_SERVER_URL = "http://61.48.133.26:9999"
+
+# 默认超时时间(秒)
+DEFAULT_TIMEOUT = 300.0
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "创建沙盒环境",
+            "params": {
+                "image": "Docker 镜像",
+                "mem_limit": "内存限制",
+                "nano_cpus": "CPU 限制",
+                "ports": "端口列表"
+            }
+        },
+        "en": {
+            "name": "Create Sandbox",
+            "params": {
+                "image": "Docker image",
+                "mem_limit": "Memory limit",
+                "nano_cpus": "CPU limit",
+                "ports": "Port list"
+            }
+        }
+    }
+)
+async def sandbox_create_environment(
+    image: str = "agent-sandbox:latest",
+    mem_limit: str = "512m",
+    nano_cpus: int = 500000000,
+    ports: Optional[List[int]] = None,
+    uid: str = ""
+) -> ToolResult:
+    """
+    创建一个隔离的 Docker 开发环境。
+
+    Args:
+        image: Docker 镜像名称,默认为 "agent-sandbox:latest"。
+               可以使用其他镜像如 "python:3.12-slim", "node:18-slim" 等。
+        mem_limit: 容器最大内存限制,默认为 "512m"。
+        nano_cpus: 容器最大 CPU 限制(纳秒),默认为 500000000(0.5 CPU)。
+        ports: 需要映射的端口列表,如 [8080, 3000]。
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含:
+            - sandbox_id (str): 沙盒唯一标识,后续操作需要用到
+            - message (str): 提示信息
+            - port_mapping (dict): 端口映射关系,如 {8080: 32001}
+            - access_urls (list): 访问 URL 列表
+    """
+    try:
+        url = f"{SANDBOX_SERVER_URL}/api/create_environment"
+        payload = {
+            "image": image,
+            "mem_limit": mem_limit,
+            "nano_cpus": nano_cpus
+        }
+        if ports:
+            payload["ports"] = ports
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title="Sandbox Created",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Created sandbox: {data.get('sandbox_id', 'unknown')}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="Create Sandbox Failed",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Create Sandbox Failed",
+            output="",
+            error=str(e)
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "执行沙盒命令",
+            "params": {
+                "sandbox_id": "沙盒 ID",
+                "command": "Shell 命令",
+                "is_background": "后台执行",
+                "timeout": "超时时间"
+            }
+        },
+        "en": {
+            "name": "Run Shell in Sandbox",
+            "params": {
+                "sandbox_id": "Sandbox ID",
+                "command": "Shell command",
+                "is_background": "Run in background",
+                "timeout": "Timeout"
+            }
+        }
+    }
+)
+async def sandbox_run_shell(
+    sandbox_id: str,
+    command: str,
+    is_background: bool = False,
+    timeout: int = 120,
+    uid: str = ""
+) -> ToolResult:
+    """
+    在指定的沙盒中执行 Shell 命令。
+
+    Args:
+        sandbox_id: 沙盒 ID,由 create_environment 返回。
+        command: 要执行的 Shell 命令,如 "pip install flask" 或 "python app.py"。
+        is_background: 是否后台执行,默认为 False。
+            - False:前台执行,等待命令完成并返回输出
+            - True:后台执行,适合启动长期运行的服务
+        timeout: 前台命令的超时时间(秒),默认 120 秒。后台命令不受此限制。
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含:
+            前台执行:
+                - exit_code (int): 命令退出码
+                - stdout (str): 标准输出
+                - stderr (str): 标准错误
+            后台执行:
+                - status (str): 状态
+                - message (str): 提示信息
+                - log_file (str): 日志文件路径
+    """
+    try:
+        url = f"{SANDBOX_SERVER_URL}/api/run_shell"
+        payload = {
+            "sandbox_id": sandbox_id,
+            "command": command,
+            "is_background": is_background,
+            "timeout": timeout
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title=f"Shell: {command[:50]}{'...' if len(command) > 50 else ''}",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Executed in sandbox {sandbox_id}: {command[:100]}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="Run Shell Failed",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Run Shell Failed",
+            output="",
+            error=str(e)
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "重建沙盒端口",
+            "params": {
+                "sandbox_id": "沙盒 ID",
+                "ports": "端口列表",
+                "mem_limit": "内存限制",
+                "nano_cpus": "CPU 限制"
+            }
+        },
+        "en": {
+            "name": "Rebuild Sandbox Ports",
+            "params": {
+                "sandbox_id": "Sandbox ID",
+                "ports": "Port list",
+                "mem_limit": "Memory limit",
+                "nano_cpus": "CPU limit"
+            }
+        }
+    }
+)
+async def sandbox_rebuild_with_ports(
+    sandbox_id: str,
+    ports: List[int],
+    mem_limit: str = "1g",
+    nano_cpus: int = 1000000000,
+    uid: str = ""
+) -> ToolResult:
+    """
+    重建沙盒并应用新的端口映射。
+
+    使用场景:先创建沙盒克隆项目,阅读 README 后才知道需要暴露哪些端口,
+    此时调用此函数重建沙盒,应用正确的端口映射。
+
+    注意:重建后会返回新的 sandbox_id,后续操作需要使用新 ID。
+    容器内的所有文件(克隆的代码、安装的依赖等)都会保留。
+
+    Args:
+        sandbox_id: 当前沙盒 ID。
+        ports: 需要映射的端口列表,如 [8080, 3306, 6379]。
+        mem_limit: 容器最大内存限制,默认为 "1g"。
+        nano_cpus: 容器最大 CPU 限制(纳秒),默认为 1000000000(1 CPU)。
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含:
+            - old_sandbox_id (str): 旧沙盒 ID(已销毁)
+            - new_sandbox_id (str): 新沙盒 ID(后续使用这个)
+            - port_mapping (dict): 端口映射关系
+            - access_urls (list): 访问 URL 列表
+    """
+    try:
+        url = f"{SANDBOX_SERVER_URL}/api/rebuild_with_ports"
+        payload = {
+            "sandbox_id": sandbox_id,
+            "ports": ports,
+            "mem_limit": mem_limit,
+            "nano_cpus": nano_cpus
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title="Sandbox Rebuilt",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Rebuilt sandbox {sandbox_id} -> {data.get('new_sandbox_id', 'unknown')} with ports {ports}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="Rebuild Sandbox Failed",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Rebuild Sandbox Failed",
+            output="",
+            error=str(e)
+        )
+
+
+@tool(
+    requires_confirmation=True,
+    display={
+        "zh": {
+            "name": "销毁沙盒环境",
+            "params": {
+                "sandbox_id": "沙盒 ID"
+            }
+        },
+        "en": {
+            "name": "Destroy Sandbox",
+            "params": {
+                "sandbox_id": "Sandbox ID"
+            }
+        }
+    }
+)
+async def sandbox_destroy_environment(
+    sandbox_id: str,
+    uid: str = ""
+) -> ToolResult:
+    """
+    销毁沙盒环境,释放资源。
+
+    Args:
+        sandbox_id: 沙盒 ID。
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含:
+            - status (str): 操作状态,如 "success"
+            - message (str): 提示信息
+            - removed_tools (list): 被移除的工具列表(如有关联的已注册工具)
+    """
+    try:
+        url = f"{SANDBOX_SERVER_URL}/api/destroy_environment"
+        payload = {
+            "sandbox_id": sandbox_id
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title="Sandbox Destroyed",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Destroyed sandbox: {sandbox_id}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="Destroy Sandbox Failed",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Destroy Sandbox Failed",
+            output="",
+            error=str(e)
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "注册沙盒工具",
+            "params": {
+                "tool_name": "工具名称",
+                "description": "工具描述",
+                "input_schema": "参数定义",
+                "sandbox_id": "沙盒 ID",
+                "internal_port": "内部端口",
+                "endpoint_path": "API 路径",
+                "http_method": "HTTP 方法"
+            }
+        },
+        "en": {
+            "name": "Register Sandbox Tool",
+            "params": {
+                "tool_name": "Tool name",
+                "description": "Tool description",
+                "input_schema": "Input schema",
+                "sandbox_id": "Sandbox ID",
+                "internal_port": "Internal port",
+                "endpoint_path": "API path",
+                "http_method": "HTTP method"
+            }
+        }
+    }
+)
+async def sandbox_register_tool(
+    tool_name: str,
+    description: str,
+    input_schema: Dict[str, Any],
+    sandbox_id: str,
+    internal_port: int,
+    endpoint_path: str = "/",
+    http_method: str = "POST",
+    metadata: Optional[Dict[str, Any]] = None,
+    uid: str = ""
+) -> ToolResult:
+    """
+    将部署好的服务注册为工具。
+
+    注册后,该工具会出现在统一 MCP Server 的工具列表中,可被上游服务调用。
+
+    Args:
+        tool_name: 工具唯一标识(字母开头,只能包含字母、数字、下划线),
+                   如 "rendercv_api"。
+        description: 工具描述,描述该工具的功能。
+        input_schema: JSON Schema 格式的参数定义,定义工具接收的参数。
+        sandbox_id: 服务所在的沙盒 ID。
+        internal_port: 服务在容器内的端口。
+        endpoint_path: API 路径,默认 "/"。
+        http_method: HTTP 方法,默认 "POST"。
+        metadata: 额外元数据(可选)。
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含:
+            - status (str): 操作状态,"success" 或 "error"
+            - message (str): 提示信息
+            - tool_info (dict): 工具信息(成功时)
+    """
+    try:
+        url = f"{SANDBOX_SERVER_URL}/api/register_tool"
+        payload = {
+            "tool_name": tool_name,
+            "description": description,
+            "input_schema": input_schema,
+            "sandbox_id": sandbox_id,
+            "internal_port": internal_port,
+            "endpoint_path": endpoint_path,
+            "http_method": http_method
+        }
+        if metadata:
+            payload["metadata"] = metadata
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title=f"Tool Registered: {tool_name}",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Registered tool '{tool_name}' on sandbox {sandbox_id}:{internal_port}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="Register Tool Failed",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Register Tool Failed",
+            output="",
+            error=str(e)
+        )

+ 265 - 0
agent/tools/builtin/search.py

@@ -0,0 +1,265 @@
+"""
+搜索工具模块
+
+提供帖子搜索和建议词搜索功能,支持多个渠道平台。
+
+主要功能:
+1. search_posts - 帖子搜索
+2. get_search_suggestions - 获取平台的搜索补全建议词
+"""
+
+import json
+from enum import Enum
+from typing import Any, Dict
+
+import httpx
+
+from agent import tool
+from agent.tools.models import ToolResult
+
+
+# API 基础配置
+BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+
+class PostSearchChannel(str, Enum):
+    """
+    帖子搜索支持的渠道类型
+    """
+    XHS = "xhs"           # 小红书
+    GZH = "gzh"           # 公众号
+    SPH = "sph"           # 视频号
+    GITHUB = "github"     # GitHub
+    TOUTIAO = "toutiao"   # 头条
+    DOUYIN = "douyin"     # 抖音
+    BILI = "bili"         # B站
+    ZHIHU = "zhihu"       # 知乎
+    WEIBO = "weibo"       # 微博
+
+
+class SuggestSearchChannel(str, Enum):
+    """
+    建议词搜索支持的渠道类型
+    """
+    XHS = "xhs"           # 小红书
+    WX = "wx"             # 微信
+    GITHUB = "github"     # GitHub
+    TOUTIAO = "toutiao"   # 头条
+    DOUYIN = "douyin"     # 抖音
+    BILI = "bili"         # B站
+    ZHIHU = "zhihu"       # 知乎
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "帖子搜索",
+            "params": {
+                "keyword": "搜索关键词",
+                "channel": "搜索渠道",
+                "cursor": "分页游标",
+                "max_count": "返回条数"
+            }
+        },
+        "en": {
+            "name": "Search Posts",
+            "params": {
+                "keyword": "Search keyword",
+                "channel": "Search channel",
+                "cursor": "Pagination cursor",
+                "max_count": "Max results"
+            }
+        }
+    }
+)
+async def search_posts(
+    keyword: str,
+    channel: str = "xhs",
+    cursor: str = "0",
+    max_count: int = 5,
+    uid: str = "",
+) -> ToolResult:
+    """
+    帖子搜索
+
+    根据关键词在指定渠道平台搜索帖子内容。
+
+    Args:
+        keyword: 搜索关键词
+        channel: 搜索渠道,支持的渠道有:
+            - xhs: 小红书
+            - gzh: 公众号
+            - sph: 视频号
+            - github: GitHub
+            - toutiao: 头条
+            - douyin: 抖音
+            - bili: B站
+            - zhihu: 知乎
+            - weibo: 微博
+        cursor: 分页游标,默认为 "0"(第一页)
+        max_count: 返回的最大条数,默认为 5
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含搜索结果:
+        {
+            "code": 0,                    # 状态码,0 表示成功
+            "message": "success",         # 状态消息
+            "data": [                     # 帖子列表
+                {
+                    "channel_content_id": "68dd03db000000000303beb2",  # 内容唯一ID
+                    "title": "",                                       # 标题
+                    "content_type": "note",                            # 内容类型
+                    "body_text": "",                                   # 正文内容
+                    "like_count": 127,                                 # 点赞数
+                    "publish_timestamp": 1759314907000,                # 发布时间戳(毫秒)
+                    "images": ["https://xxx.webp"],                    # 图片列表
+                    "videos": [],                                      # 视频列表
+                    "channel": "xhs",                                  # 来源渠道
+                    "link": "xxx"                                      # 原文链接
+                }
+            ]
+        }
+    """
+    try:
+        # 处理 channel 参数,支持枚举和字符串
+        channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
+
+        url = f"{BASE_URL}/data"
+        payload = {
+            "type": channel_value,
+            "keyword": keyword,
+            "cursor": cursor,
+            "max_count": max_count,
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                url,
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        # 计算结果数量
+        result_count = len(data.get("data", []))
+
+        return ToolResult(
+            title=f"搜索结果: {keyword} ({channel_value})",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched '{keyword}' on {channel_value}, found {result_count} posts"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="搜索失败",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="搜索失败",
+            output="",
+            error=str(e)
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "获取搜索关键词补全建议",
+            "params": {
+                "keyword": "搜索关键词",
+                "channel": "搜索渠道"
+            }
+        },
+        "en": {
+            "name": "Get Search Suggestions",
+            "params": {
+                "keyword": "Search keyword",
+                "channel": "Search channel"
+            }
+        }
+    }
+)
+async def get_search_suggestions(
+    keyword: str,
+    channel: str = "xhs",
+    uid: str = "",
+) -> ToolResult:
+    """
+    获取搜索关键词补全建议
+
+    根据关键词在指定渠道平台获取搜索建议词。
+
+    Args:
+        keyword: 搜索关键词
+        channel: 搜索渠道,支持的渠道有:
+            - xhs: 小红书
+            - wx: 微信
+            - github: GitHub
+            - toutiao: 头条
+            - douyin: 抖音
+            - bili: B站
+            - zhihu: 知乎
+        uid: 用户ID(自动注入)
+
+    Returns:
+        ToolResult 包含建议词数据:
+        {
+            "code": 0,                    # 状态码,0 表示成功
+            "message": "success",         # 状态消息
+            "data": [                     # 建议词数据
+                {
+                    "type": "xhs",        # 渠道类型
+                    "list": [             # 建议词列表
+                        {
+                            "name": "彩虹染发"  # 建议词
+                        }
+                    ]
+                }
+            ]
+        }
+    """
+    try:
+        # 处理 channel 参数,支持枚举和字符串
+        channel_value = channel.value if isinstance(channel, SuggestSearchChannel) else channel
+
+        url = f"{BASE_URL}/suggest"
+        payload = {
+            "type": channel_value,
+            "keyword": keyword,
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                url,
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        # 计算建议词数量
+        suggestion_count = 0
+        for item in data.get("data", []):
+            suggestion_count += len(item.get("list", []))
+
+        return ToolResult(
+            title=f"建议词: {keyword} ({channel_value})",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Got {suggestion_count} suggestions for '{keyword}' on {channel_value}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="获取建议词失败",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="获取建议词失败",
+            output="",
+            error=str(e)
+        )

+ 259 - 0
agent/tools/builtin/skill.py

@@ -0,0 +1,259 @@
+"""
+Skill 工具 - 按需加载 Skill 文件
+
+Agent 可以调用此工具来加载特定的 skill 文档
+"""
+
+import os
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult
+from agent.storage.skill_loader import SkillLoader
+
+# 默认 skills 目录(优先级:项目 skills > 框架 skills)
+DEFAULT_SKILLS_DIRS = [
+    os.getenv("SKILLS_DIR", "./skills"),      # 项目特定 skills(优先)
+    "./agent/skills"                            # 框架内置 skills
+]
+
+
+def _check_skill_setup(skill_name: str) -> Optional[str]:
+    """
+    检查 skill 的环境配置,返回缺失依赖的警告信息
+
+    Args:
+        skill_name: Skill 名称
+
+    Returns:
+        警告信息(如果有缺失的依赖),否则返回 None
+    """
+    # 特殊处理:browser-use skill
+    if skill_name in ["browser-use", "browser_use"]:
+        try:
+            # 动态导入 browser-use skill 的 setup 模块
+            from agent.skills.browser_use.setup import (
+                _check_browser_use_cli,
+                _check_chromium_installed
+            )
+
+            cli_installed = _check_browser_use_cli()
+            chromium_installed = _check_chromium_installed()
+
+            if not cli_installed or not chromium_installed:
+                warning = "\n⚠️ **Setup Required**\n\n"
+                warning += "The following dependencies are missing:\n\n"
+
+                if not cli_installed:
+                    warning += "- `pip install browser-use`\n"
+                if not chromium_installed:
+                    warning += "- `uvx browser-use install`\n"
+
+                warning += "\nYou can also use the setup tools:\n"
+                warning += "- `check_browser_use()` - Check dependency status\n"
+                warning += "- `install_browser_use_chromium()` - Auto-install Chromium\n\n"
+
+                return warning
+        except ImportError:
+            # Setup 模块不存在,跳过检查
+            pass
+
+    return None
+
+
+@tool(
+    description="加载指定的 skill 文档。Skills 提供领域知识和最佳实践指导。"
+)
+async def skill(
+    skill_name: str,
+    skills_dir: Optional[str] = None,
+    uid: str = ""
+) -> ToolResult:
+    """
+    加载指定的 skill 文档
+
+    Args:
+        skill_name: Skill 名称(如 "browser-use", "error-handling")
+        skills_dir: Skills 目录路径(可选,默认按优先级查找)
+        uid: 用户 ID(自动注入)
+
+    Returns:
+        ToolResult: 包含 skill 的详细内容
+
+    加载顺序:
+    1. 如果指定 skills_dir,只在该目录查找
+    2. 否则按优先级查找:./skills/ (项目) -> ./agent/skills/ (框架)
+    """
+    # 确定要搜索的目录列表
+    if skills_dir:
+        search_paths = [Path(skills_dir)]
+    else:
+        search_paths = [Path(d) for d in DEFAULT_SKILLS_DIRS]
+
+    # 在目录中查找 skill 文件
+    skill_file = None
+    found_in_dir = None
+
+    for skills_path in search_paths:
+        if not skills_path.exists():
+            continue
+
+        # 查找文件(支持 skill-name.md 或 skill_name.md)
+        for ext in [".md"]:
+            for name_format in [skill_name, skill_name.replace("-", "_"), skill_name.replace("_", "-")]:
+                candidate = skills_path / f"{name_format}{ext}"
+                if candidate.exists():
+                    skill_file = candidate
+                    found_in_dir = skills_path
+                    break
+            if skill_file:
+                break
+
+        if skill_file:
+            break
+
+    if not skill_file:
+        # 列出所有可用的 skills
+        available_skills = []
+        for skills_path in search_paths:
+            if skills_path.exists():
+                available_skills.extend([f.stem for f in skills_path.glob("**/*.md")])
+
+        return ToolResult(
+            title=f"Skill '{skill_name}' 未找到",
+            output=f"可用的 skills: {', '.join(set(available_skills))}\n\n"
+                   f"查找路径: {', '.join([str(p) for p in search_paths])}",
+            error=f"Skill not found: {skill_name}"
+        )
+
+    # 加载 skill
+    try:
+        loader = SkillLoader(str(skills_path))
+        skill_obj = loader.load_file(skill_file)
+
+        if not skill_obj:
+            return ToolResult(
+                title="加载失败",
+                output=f"无法解析 skill 文件: {skill_file.name}",
+                error="Failed to parse skill file"
+            )
+
+        # 格式化输出
+        output = f"# {skill_obj.name}\n\n"
+        output += f"**Category**: {skill_obj.category}\n\n"
+
+        if skill_obj.description:
+            output += f"## Description\n\n{skill_obj.description}\n\n"
+
+        # 检查 skill 的环境配置
+        setup_warning = _check_skill_setup(skill_name)
+        if setup_warning:
+            output += setup_warning
+
+        if skill_obj.guidelines:
+            output += f"## Guidelines\n\n"
+            for i, guideline in enumerate(skill_obj.guidelines, 1):
+                output += f"{i}. {guideline}\n"
+            output += "\n"
+
+        return ToolResult(
+            title=f"Skill: {skill_obj.name}",
+            output=output,
+            long_term_memory=f"已加载 skill: {skill_obj.name} ({skill_obj.category}) from {found_in_dir}",
+            include_output_only_once=True,  # skill 内容只展示一次
+            metadata={
+                "skill_name": skill_obj.name,
+                "category": skill_obj.category,
+                "scope": skill_obj.scope,
+                "guidelines_count": len(skill_obj.guidelines),
+                "loaded_from": str(found_in_dir)
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="加载错误",
+            output=f"加载 skill 时出错: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(
+    description="列出所有可用的 skills"
+)
+async def list_skills(
+    skills_dir: Optional[str] = None,
+    uid: str = ""
+) -> ToolResult:
+    """
+    列出所有可用的 skills
+
+    Args:
+        skills_dir: Skills 目录路径(可选)
+        uid: 用户 ID(自动注入)
+
+    Returns:
+        ToolResult: 包含所有 skills 的列表
+    """
+    skills_path = Path(skills_dir or DEFAULT_SKILLS_DIR)
+
+    if not skills_path.exists():
+        return ToolResult(
+            title="Skills 目录不存在",
+            output=f"找不到 skills 目录: {skills_path}",
+            error=f"Directory not found: {skills_path}"
+        )
+
+    try:
+        loader = SkillLoader(str(skills_path))
+        skills = loader.load_all()
+
+        if not skills:
+            return ToolResult(
+                title="没有可用的 Skills",
+                output="skills 目录中没有找到任何 .md 文件",
+                metadata={"count": 0}
+            )
+
+        # 按 category 分组
+        by_category = {}
+        for skill_obj in skills:
+            category = skill_obj.category or "general"
+            if category not in by_category:
+                by_category[category] = []
+            by_category[category].append(skill_obj)
+
+        # 格式化输出
+        output = f"# 可用的 Skills ({len(skills)} 个)\n\n"
+
+        for category in sorted(by_category.keys()):
+            output += f"## {category.title()}\n\n"
+            for skill_obj in by_category[category]:
+                skill_id = skill_obj.skill_id or skill_obj.name.lower().replace(' ', '-')
+                output += f"- **{skill_obj.name}** (`{skill_id}`)\n"
+                if skill_obj.description:
+                    desc = skill_obj.description.split('\n')[0]  # 第一行
+                    output += f"  {desc[:100]}{'...' if len(desc) > 100 else ''}\n"
+            output += "\n"
+
+        output += "\n使用 `skill` 工具加载具体的 skill:`skill(skill_name=\"browser-use\")`"
+
+        return ToolResult(
+            title=f"可用 Skills ({len(skills)} 个)",
+            output=output,
+            long_term_memory=f"找到 {len(skills)} 个可用 skills,分为 {len(by_category)} 个类别",
+            include_output_only_once=True,
+            metadata={
+                "count": len(skills),
+                "categories": list(by_category.keys()),
+                "skills": [{"name": s.name, "category": s.category} for s in skills]
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="列出 Skills 错误",
+            output=f"列出 skills 时出错: {str(e)}",
+            error=str(e)
+        )

+ 113 - 0
agent/tools/builtin/write.py

@@ -0,0 +1,113 @@
+"""
+Write Tool - 文件写入工具
+
+参考:vendor/opencode/packages/opencode/src/tool/write.ts
+
+核心功能:
+- 创建新文件或覆盖现有文件
+- 生成 diff 预览
+"""
+
+from pathlib import Path
+from typing import Optional
+import difflib
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+@tool(description="写入文件内容(创建新文件或覆盖现有文件)")
+async def write_file(
+    file_path: str,
+    content: str,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    写入文件
+
+    参考 OpenCode 实现
+
+    Args:
+        file_path: 文件路径
+        content: 文件内容
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 写入结果
+    """
+    # 解析路径
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = Path.cwd() / path
+
+    # 检查是否为目录
+    if path.exists() and path.is_dir():
+        return ToolResult(
+            title="路径错误",
+            output=f"路径是目录,不是文件: {file_path}",
+            error="Path is a directory"
+        )
+
+    # 读取旧内容(如果存在)
+    existed = path.exists()
+    old_content = ""
+    if existed:
+        try:
+            with open(path, 'r', encoding='utf-8') as f:
+                old_content = f.read()
+        except Exception:
+            old_content = ""
+
+    # 生成 diff
+    if existed and old_content:
+        diff = _create_diff(str(path), old_content, content)
+    else:
+        diff = f"(新建文件: {path.name})"
+
+    # 确保父目录存在
+    path.parent.mkdir(parents=True, exist_ok=True)
+
+    # 写入文件
+    try:
+        with open(path, 'w', encoding='utf-8') as f:
+            f.write(content)
+    except Exception as e:
+        return ToolResult(
+            title="写入失败",
+            output=f"无法写入文件: {str(e)}",
+            error=str(e)
+        )
+
+    # 统计
+    lines = content.count('\n')
+
+    return ToolResult(
+        title=path.name,
+        output=f"文件写入成功\n\n{diff}",
+        metadata={
+            "existed": existed,
+            "lines": lines,
+            "diff": diff
+        },
+        long_term_memory=f"{'覆盖' if existed else '创建'}文件 {path.name}"
+    )
+
+
+def _create_diff(filepath: str, old_content: str, new_content: str) -> str:
+    """生成 unified diff"""
+    old_lines = old_content.splitlines(keepends=True)
+    new_lines = new_content.splitlines(keepends=True)
+
+    diff_lines = list(difflib.unified_diff(
+        old_lines,
+        new_lines,
+        fromfile=f"a/{filepath}",
+        tofile=f"b/{filepath}",
+        lineterm=''
+    ))
+
+    if not diff_lines:
+        return "(无变更)"
+
+    return ''.join(diff_lines)

+ 167 - 72
docs/README.md

@@ -12,7 +12,7 @@
 2. **保持结构稳定** - 只增删内容,不随意调整层级结构
 3. **流程优先** - 新功能先写入核心流程,再补充模块详情
 4. **链接代码** - 关键实现标注文件路径,格式:`module/file.py:function_name`
-5. **简洁原则** - 只记录最重要的信息,避免大量代码
+5. **简洁原则** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议,或大量代码
 6. **文档分层** - 每层文档是不同层次的overview,在上层文档对应位置引用下层详细文档
 
 ---
@@ -21,13 +21,15 @@
 
 **单次调用是 Agent 的特例**:
 
-| 特性 | 单次调用 | Agent 模式 |
-|------|---------|-----------|
-| 循环次数 | 1 | N (可配置) |
-| 工具调用 | 可选 | 常用 |
-| 状态管理 | 无 | 有 (Trace) |
-| 记忆检索 | 无 | 有 (Experience/Skill) |
-| 执行图 | 1 个节点 | N 个节点的 DAG |
+| 特性 | 单次调用 | Agent 模式 | Sub-Agent 模式 |
+|------|---------|-----------|--------------|
+| 循环次数 | 1 | N (可配置) | N (可配置,受限) |
+| 工具调用 | 可选 | 常用 | 受限工具集 |
+| 状态管理 | 无 | 有 (Trace) | 有 (独立 Trace + 父子关系) |
+| 记忆检索 | 无 | 有 (Experience/Skill) | 有 (继承主 Agent) |
+| 执行图 | 1 个节点 | N 个节点的 DAG | 嵌套 DAG(多个 Trace) |
+| 触发方式 | 直接调用 | 直接调用 | 通过 Task 工具 |
+| 权限范围 | 完整 | 完整 | 受限(可配置) |
 
 ---
 
@@ -84,7 +86,7 @@ async def run(task: str, max_steps: int = 50):
         response = await llm.chat(
             messages=messages,
             system=system_prompt,
-            tools=tool_registry.to_schema()  # 包括 skill 工具
+            tools=tool_registry.to_schema()  # 包括 skill、task 工具
         )
 
         # 记录 LLM 调用
@@ -103,7 +105,7 @@ async def run(task: str, max_steps: int = 50):
             if is_doom_loop(tool_call):
                 raise DoomLoopError()
 
-            # 执行工具(包括 skill 工具)
+            # 执行工具(包括 skill、task 工具)
             result = await execute_tool(tool_call)
 
             # 记录步骤
@@ -124,6 +126,25 @@ async def run(task: str, max_steps: int = 50):
 **关键机制**:
 - **Doom Loop 检测**:跟踪最近 3 次工具调用,如果都是同一个工具且参数相同,中断循环
 - **动态工具加载**:Skill 通过 tool 动态加载,按需消耗 context
+- **Sub-Agent 支持**:通过 task 工具启动专门化的 Sub-Agent 处理子任务
+
+### Sub-Agent 执行流程
+
+主 Agent 通过 `task` 工具启动 Sub-Agent。
+
+**层级关系**:
+```
+Main Trace (primary agent)
+  └─▶ Sub Trace (parent_trace_id 指向主 Trace)
+        └─▶ Steps...
+```
+
+**关键字段**:
+- `Trace.parent_trace_id` - 指向父 Trace
+- `Trace.agent_definition` - Sub-Agent 类型(如 "explore")
+- Sub-Agent 有独立的工具权限配置
+
+**实现位置**:`agent/tools/builtin/task.py`(待实现)
 
 ---
 
@@ -136,30 +157,27 @@ async def run(task: str, max_steps: int = 50):
 class Trace:
     trace_id: str
     mode: Literal["call", "agent"]
-
-    # 任务信息
     task: Optional[str] = None
     agent_type: Optional[str] = None
-
-    # 状态
     status: Literal["running", "completed", "failed"] = "running"
 
-    # 上下文(灵活的元数据)
-    context: Dict[str, Any] = field(default_factory=dict)
+    # Sub-Agent 支持
+    parent_trace_id: Optional[str] = None      # 父 Trace ID
+    agent_definition: Optional[str] = None     # Agent 类型名称
+    spawned_by_tool: Optional[str] = None      # 启动此 Sub-Agent 的 Step ID
+
+    # 统计
+    total_steps: int = 0
+    total_tokens: int = 0
+    total_cost: float = 0.0
 
-    # 时间
-    created_at: datetime
-    completed_at: Optional[datetime] = None
+    # 上下文
+    uid: Optional[str] = None
+    context: Dict[str, Any] = field(default_factory=dict)
 ```
 
 **实现**:`agent/models/trace.py:Trace`
 
-**context 字段**:存储任务相关的元信息
-- `user_id`: 用户 ID
-- `project_id`: 项目 ID
-- `priority`: 优先级
-- `tags`: 标签列表
-
 ### Step(执行步骤)
 
 ```python
@@ -168,54 +186,59 @@ class Step:
     step_id: str
     trace_id: str
     step_type: StepType  # "llm_call", "tool_call", "tool_result", ...
-
-    # DAG 结构
     parent_ids: List[str] = field(default_factory=list)
-
-    # 灵活的步骤数据
     data: Dict[str, Any] = field(default_factory=dict)
-
-    created_at: datetime
 ```
 
 **实现**:`agent/models/trace.py:Step`
 
-**常见 step_type**:
-- `llm_call`: LLM 调用(data: messages, response, tokens, cost)
-- `tool_call`: 工具调用(data: tool_name, arguments)
-- `tool_result`: 工具结果(data: output, metadata)
-- `reasoning`: 推理过程(data: content)
-
-### 执行图示例
-
-```
-Trace
-  │
-  ├─▶ Step(llm_call)
-  │     │
-  │     ├─▶ Step(tool_call: skill)
-  │     │     └─▶ Step(tool_result: "# Error Handling...")
-  │     │
-  │     └─▶ Step(tool_call: search_logs)
-  │           └─▶ Step(tool_result: "...")
-  │
-  └─▶ Step(llm_call)
-        └─▶ ...
-```
-
 ---
 
 ## 模块详情
 
 详细的模块文档请参阅:
 
+### [项目结构](./project-structure.md)
+- 框架与应用分层设计
+- 框架内置预设(agent/presets/, agent/skills/)
+- 项目层配置(./skills/, ./subagents/, ./tools/)
+- 加载优先级和覆盖机制
+
+### [Sub-Agent 机制](./sub-agents.md)
+- 数据模型:AgentDefinition、Trace 扩展
+- 内置 Sub-Agent:general、explore、analyst
+- Task Tool 实现
+- Agent Registry 和权限控制
+- 配置文件格式
+
+**使用示例**:`examples/subagent_example.py`
+
 ### [工具系统](./tools.md)
 - 工具定义和注册
 - 双层记忆管理
 - 域名过滤、敏感数据处理
-- 集成 Browser-Use
 - 最佳实践
 
+**工具接入规范**:
+- **高频&简单工具**:Python 原生实现 → `agent/tools/builtin/`
+- **复杂&低频工具**:第三方仓库 → `vendor/` + 适配器 → `agent/tools/advanced/`
+- **CLI 命令行工具**:pip 安装 → `bash_command` 调用(如 browser-use)
+
+**内置工具**(`builtin/`):
+- `read_file`, `edit_file`, `write_file` - 文件操作
+- `bash_command` - 命令执行
+- `glob_files`, `grep_content` - 文件搜索
+- `skill`, `list_skills` - 技能库管理
+
+**高级工具**(`advanced/`):
+- `webfetch` - 网页抓取(调用 opencode)
+- `lsp_diagnostics` - LSP 诊断(调用 opencode)
+
+**Skills**(`agent/skills/`):
+- `browser_use` - 浏览器自动化(包含环境配置工具)
+
+**详细设计**:参考 [`docs/tools-adapters.md`](./tools-adapters.md)
+
 **核心特性**:
 ```python
 from reson_agent import tool, ToolResult, ToolContext
@@ -232,17 +255,64 @@ async def my_tool(arg: str, ctx: ToolContext) -> ToolResult:
     )
 ```
 
-### Skills(技能库)
+### [多模态支持](./multimodal.md)
+- Prompt 层多模态消息构建
+- OpenAI 格式消息规范
+- Gemini Provider 适配
+- 图片资源处理
+
+**实现**:
+- `agent/prompts/wrapper.py:SimplePrompt` - Prompt 包装器
+- `agent/llm/providers/gemini.py:_convert_messages_to_gemini` - 格式转换
+
+**使用示例**:`examples/feature_extract/run.py`
+
+### Prompt Loader(提示加载器)
+
+**职责**:加载和处理 `.prompt` 文件,支持多模态消息构建
 
-**存储**:Markdown 文件
+**文件格式**:
+```yaml
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
 
+$system$
+系统提示...
+
+$user$
+用户提示:%variable%
 ```
-~/.reson/skills/           # 全局
-├── error-handling/SKILL.md
-└── data-processing/SKILL.md
 
-./project/.reson/skills/   # 项目级
-└── api-integration/SKILL.md
+**核心功能**:
+- YAML frontmatter 解析(配置)
+- `$section$` 分节语法
+- `%variable%` 参数替换
+- 多模态消息支持(图片等)
+
+**实现**:
+- `agent/prompts/loader.py:load_prompt()` - 文件解析
+- `agent/prompts/loader.py:get_message()` - 参数替换
+- `agent/prompts/wrapper.py:SimplePrompt` - Prompt 包装器
+
+**使用**:
+```python
+prompt = SimplePrompt("task.prompt")
+messages = prompt.build_messages(text="...", images="img.png")
+```
+
+### Skills(技能库)
+
+**存储**:Markdown 文件 + 环境配置代码
+
+```
+./agent/skills/                # Skills 目录
+├── browser_use/              # browser-use skill
+│   ├── browser-use.md        # 使用文档
+│   ├── setup.py              # 环境配置(依赖检查和安装)
+│   └── __init__.py           # 模块导出
+└── [其他 skills]/
 ```
 
 **格式**:
@@ -265,7 +335,22 @@ description: Error handling best practices
 
 **加载**:通过 `skill` 工具动态加载
 
-**实现**:`agent/storage/skill_fs.py:SkillLoader`
+Agent 在需要时调用 `skill` 工具:
+```python
+# Agent 运行时
+await tools.execute("skill", {"skill_name": "browser-use"})
+# 自动检查环境依赖,加载使用文档
+```
+
+工具会读取文件并返回内容,注入到对话历史中。
+
+**实现**:
+- `agent/storage/skill_loader.py:SkillLoader` - Markdown 解析器
+- `agent/tools/builtin/skill.py:skill()` - skill 工具实现
+- `agent/tools/builtin/skill.py:list_skills()` - 列出可用 skills
+- `agent/skills/*/setup.py` - 环境配置(可选,每个 skill 可自定义)
+
+**详细文档**:参考 [`docs/skills.md`](./skills.md)
 
 ### Experiences(经验库)
 
@@ -400,18 +485,26 @@ agent/
 
 ---
 
-## 实现计划
+## 测试
 
-### Phase 1:MVP
-- [ ] AgentRunner 基础循环
-- [ ] 基础工具(read, skill)
-- [ ] 高级工具集成:Browser-Use、Search
-- [ ] 单次执行的监控与分析:Trace/Step 数据模型与文件系统存储、初步的执行历史可视化
+详见 [测试指南](./testing.md)
 
-### Phase 2:反思能力
-- [ ] Experience:feedback、归纳反思
-- [ ] 批量执行的监控与分析
+**测试分层**:
+- **单元测试**:Agent 定义、工具系统、Trace 模型
+- **集成测试**:Sub-Agent、Trace 存储、多模块协作
+- **E2E 测试**:真实 LLM 调用(需要 API Key)
 
+**运行测试**:
+```bash
+# 单元测试
+pytest tests/ -v -m "not e2e"
+
+# 覆盖率
+pytest --cov=agent tests/ -m "not e2e"
+
+# E2E 测试(可选)
+GEMINI_API_KEY=xxx pytest tests/e2e/ -v -m e2e
+```
 
 ---
 
@@ -421,6 +514,8 @@ agent/
 |------|------|------|------|
 | **Trace** | 一次任务执行 | 文件系统(JSON) | `models/trace.py` |
 | **Step** | 执行步骤 | 文件系统(JSON) | `models/trace.py` |
+| **Sub-Agent** | 专门化的子代理 | 独立 Trace | `tools/builtin/task.py` |
+| **AgentDefinition** | Agent 类型定义 | 配置文件/代码 | `models/agent.py` |
 | **Skill** | 能力描述(Markdown) | 文件系统 | `storage/skill_fs.py` |
 | **Experience** | 经验规则(条件+规则) | 数据库 + 向量 | `storage/experience_pg.py` |
 | **Tool** | 可调用的函数 | 内存(注册表) | `tools/registry.py` |

+ 126 - 0
docs/multimodal.md

@@ -0,0 +1,126 @@
+# 多模态支持
+
+多模态消息(文本 + 图片)支持,遵循 OpenAI API 规范。
+
+---
+
+## 架构层次
+
+```
+Prompt 层 (SimplePrompt) → OpenAI 格式消息 → Provider 层适配 → 模型 API
+```
+
+**关键原则**:
+- 遵循 OpenAI API 消息格式规范
+- 模型适配封装在 Provider 层
+- 应用层通过 Prompt 层统一处理
+
+---
+
+## 核心实现
+
+### 1. Prompt 层多模态支持
+
+**实现位置**:`agent/prompts/wrapper.py:SimplePrompt`
+
+**功能**:构建 OpenAI 格式的多模态消息
+
+```python
+# 使用示例
+prompt = SimplePrompt("task.prompt")
+messages = prompt.build_messages(
+    text="内容",
+    images="path/to/image.png"  # 或 images=["img1.png", "img2.png"]
+)
+```
+
+**关键方法**:
+- `build_messages(**context)` - 构建消息列表,支持 `images` 参数
+- `_build_image_content(image)` - 将图片路径转为 OpenAI 格式(data URL)
+
+**消息格式**(OpenAI 规范):
+```python
+[
+  {"role": "system", "content": "系统提示"},
+  {
+    "role": "user",
+    "content": [
+      {"type": "text", "text": "..."},
+      {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
+    ]
+  }
+]
+```
+
+### 2. Gemini Provider 适配
+
+**实现位置**:`agent/llm/providers/gemini.py:_convert_messages_to_gemini`
+
+**功能**:将 OpenAI 多模态格式转换为 Gemini 格式
+
+**转换规则**:
+- 检测 `content` 是否为数组(多模态标志)
+- `{"type": "text"}` → Gemini `{"text": "..."}`
+- `{"type": "image_url"}` → Gemini `{"inline_data": {"mime_type": "...", "data": "..."}}`
+
+**关键逻辑**:
+```python
+# 处理多模态消息
+if isinstance(content, list):
+    parts = []
+    for item in content:
+        if item.get("type") == "text":
+            parts.append({"text": item.get("text")})
+        elif item.get("type") == "image_url":
+            # 解析 data URL 并转换
+            mime_type, base64_data = parse_data_url(url)
+            parts.append({"inline_data": {"mime_type": mime_type, "data": base64_data}})
+```
+
+---
+
+## 使用方式
+
+### .prompt 文件
+
+标准 `.prompt` 文件格式:
+```yaml
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+系统提示...
+
+$user$
+用户提示:%text%
+```
+
+### 应用层调用
+
+**参考示例**:`examples/feature_extract/run.py`
+
+```python
+# 1. 加载 prompt
+prompt = SimplePrompt("task.prompt")
+
+# 2. 构建消息(自动处理图片)
+messages = prompt.build_messages(text="...", images="img.png")
+
+# 3. 调用 Agent
+runner = AgentRunner(llm_call=create_gemini_llm_call())
+result = await runner.call(messages=messages, model="gemini-2.5-flash")
+```
+
+---
+
+## 扩展支持
+
+**当前支持**:
+- 图片格式:PNG, JPEG, GIF, WebP
+- 输入方式:文件路径或 base64 data URL
+
+**未来扩展**:
+- 音频、视频等其他模态
+- 资源缓存和异步加载

+ 89 - 0
docs/project-structure.md

@@ -0,0 +1,89 @@
+# Agent Framework 目录结构
+
+> 框架与应用分层设计
+
+---
+
+## 核心架构
+
+**框架层** (`agent/`): 核心代码、内置预设,可作为独立包发布
+
+**应用层** (项目根目录): 项目特定配置,覆盖或扩展框架
+
+---
+
+## 框架层(`agent/`)
+
+```
+agent/
+├── tools/builtin/              # 内置基础工具
+├── tools/advanced/             # 高级工具(第三方适配)
+├── skills/                     # 框架内置 Skills
+│   └── browser_use/            # 浏览器自动化 skill
+├── presets/subagents/          # 内置 Sub-Agent 定义
+└── runner.py                   # Agent 运行器
+```
+
+**实现**:
+- 工具系统:`agent/tools/`
+- Skills:`agent/skills/browser_use/`(含文档和环境配置)
+- Sub-Agent 预设:`agent/subagents/default.json`
+
+---
+
+## 应用层(项目根目录)
+
+项目特定配置(可选):
+
+```
+your-project/
+├── skills/                     # 项目特定 Skills
+├── subagents/                  # 项目特定 Sub-Agents
+└── tools/                      # 项目特定工具
+```
+
+---
+
+## 加载优先级
+
+| 资源 | 框架预设 | 项目配置 | 优先级 |
+|------|---------|---------|--------|
+| Skills | `agent/skills/` | `./skills/` | 项目优先 |
+| Sub-Agents | `agent/subagents/default.json` | `./subagents/*.json` | 项目覆盖 |
+| Tools | `agent/tools/builtin/` | `./tools/` | 手动注册 |
+
+**实现**:
+- Skills 多路径查找:`agent/tools/builtin/skill.py` - `DEFAULT_SKILLS_DIRS`
+- Sub-Agents 加载:按顺序加载配置,后加载的覆盖先加载的
+
+---
+
+## 使用方式
+
+### 加载框架预设
+
+```python
+from agent.agent_registry import get_agent_registry
+
+registry = get_agent_registry()
+registry.load_from_config("agent/subagents/default.json")
+```
+
+### 扩展框架(可选)
+
+```python
+# 项目特定配置覆盖框架预设
+from pathlib import Path
+if Path("./subagents/custom.json").exists():
+    registry.load_from_config("./subagents/custom.json")
+```
+
+Skills 和工具自动按优先级加载。
+
+---
+
+## 相关文档
+
+- [Sub-Agent 配置](./sub-agents.md) - Sub-Agent 定义格式
+- [Skills](./skills.md) - Skill 使用和编写
+- [工具系统](./tools.md) - 工具注册和使用

+ 120 - 0
docs/skills.md

@@ -0,0 +1,120 @@
+# Skills 使用指南
+
+Skills 是 Agent 的领域知识库,存储在 Markdown 文件中。Agent 通过 `skill` 工具按需加载。
+
+## Skill 文件格式
+
+```markdown
+---
+name: browser-use
+description: 浏览器自动化工具使用指南
+category: web-automation
+scope: agent:*
+---
+
+## When to use
+- 需要访问网页、填写表单
+- 需要截图或提取网页内容
+
+## Guidelines
+- 先运行 `browser-use state` 查看可点击元素
+- 使用元素索引进行交互:`browser-use click 5`
+- 每次操作后验证结果
+```
+
+**Frontmatter 字段**:
+- `name`: Skill 名称(必填)
+- `description`: 简短描述(必填)
+- `category`: 分类(可选,默认 general)
+- `scope`: 作用域(可选,默认 agent:*)
+
+**章节**:
+- `## When to use`: 适用场景列表
+- `## Guidelines`: 指导原则列表
+
+## 使用方法
+
+### 1. 创建 skills 目录
+
+```bash
+mkdir skills
+```
+
+### 2. 添加 skill 文件
+
+创建 `skills/browser-use.md` 文件,按照上述格式编写。
+
+### 3. Agent 调用
+
+Agent 在运行时可以调用 skill 工具:
+
+```python
+from agent import AgentRunner
+from agent.llm.providers.gemini import create_gemini_llm_call
+import os
+
+runner = AgentRunner(
+    llm_call=create_gemini_llm_call(os.getenv("GEMINI_API_KEY"))
+)
+
+async for event in runner.run(
+    task="帮我从网站提取数据",
+    tools=["skill", "list_skills", "bash"],
+    model="gemini-2.0-flash-exp"
+):
+    if event.type == "conclusion":
+        print(event.data["content"])
+```
+
+**Agent 工作流**:
+1. Agent 接收任务
+2. Agent 调用 `list_skills()` 查看可用 skills
+3. Agent 调用 `skill(skill_name="browser-use")` 加载需要的 skill
+4. Skill 内容注入到对话历史
+5. Agent 根据 skill 指导完成任务
+
+### 4. 手动测试
+
+```python
+from tools.skill import list_skills, skill
+
+# 列出所有 skills
+result = await list_skills()
+print(result.output)
+
+# 加载特定 skill
+result = await skill(skill_name="browser-use")
+print(result.output)
+```
+
+## Skill 工具
+
+### `skill` 工具
+
+加载指定的 skill 文档。
+
+**参数**:
+- `skill_name` (str): Skill 名称,如 "browser-use"
+
+**返回**:Skill 的完整内容(Markdown 格式)
+
+### `list_skills` 工具
+
+列出所有可用的 skills,按 category 分组。
+
+**返回**:Skills 列表,包含名称、ID 和简短描述
+
+## 环境变量
+
+可以设置默认 skills 目录:
+
+```bash
+# .env
+SKILLS_DIR=./skills
+```
+
+## 参考
+
+- 示例: `examples/skills_example.py`
+- Skill 文件: `skills/` 目录
+- 工具实现: `tools/skill.py`

+ 241 - 0
docs/sub-agents.md

@@ -0,0 +1,241 @@
+# Sub-Agent 实现指南
+
+> **可执行规格书**:Sub-Agent 实现细节。代码修改必须同步更新此文档。
+
+---
+
+## 数据模型
+
+### AgentDefinition
+
+```python
+# agent/models/agent.py
+
+@dataclass
+class AgentDefinition:
+    name: str
+    description: Optional[str] = None
+    mode: Literal["primary", "subagent", "all"] = "all"
+
+    # 权限配置
+    allowed_tools: Optional[List[str]] = None  # 白名单
+    denied_tools: Optional[List[str]] = None   # 黑名单
+    permissions: Dict[str, Any] = field(default_factory=dict)
+
+    # 模型配置
+    model: Optional[str] = None
+    temperature: Optional[float] = None
+    max_iterations: Optional[int] = None
+    system_prompt: Optional[str] = None
+
+    # 是否可以调用其他 Sub-Agent
+    can_spawn_subagent: bool = False
+```
+
+**实现位置**:`agent/models/agent.py:AgentDefinition`(待实现)
+
+### Trace 扩展
+
+```python
+# agent/models/trace.py (扩展现有模型)
+
+@dataclass
+class Trace:
+    # ... 原有字段
+
+    # Sub-Agent 支持(新增)
+    parent_trace_id: Optional[str] = None    # 父 Trace ID
+    agent_definition: Optional[str] = None   # Agent 类型名称
+    spawned_by_tool: Optional[str] = None    # 启动此 Sub-Agent 的 Step ID
+```
+
+**实现位置**:`agent/models/trace.py:Trace`
+
+---
+
+## 内置 Sub-Agent
+
+### 1. general - 通用型
+
+```python
+GENERAL_AGENT = AgentDefinition(
+    name="general",
+    description="通用型 Sub-Agent,执行复杂的多步骤任务",
+    mode="subagent",
+    denied_tools=["task"],
+    max_iterations=20,
+)
+```
+
+**用途**:多步骤任务、数据收集、复杂分析
+
+### 2. explore - 探索型
+
+```python
+EXPLORE_AGENT = AgentDefinition(
+    name="explore",
+    description="探索型 Sub-Agent,快速查找文件和代码",
+    mode="subagent",
+    allowed_tools=["read_file", "list_files", "search_code", "search_files"],
+    denied_tools=["write_file", "edit_file", "execute_bash", "task"],
+    max_iterations=15,
+)
+```
+
+**用途**:代码库探索、文件查找、结构分析
+
+### 3. analyst - 分析型
+
+```python
+ANALYST_AGENT = AgentDefinition(
+    name="analyst",
+    description="分析型 Sub-Agent,深度分析和报告生成",
+    mode="subagent",
+    allowed_tools=["read_file", "list_files", "search_code", "web_search", "fetch_url"],
+    denied_tools=["task", "write_file", "edit_file"],
+    max_iterations=25,
+    temperature=0.3,
+)
+```
+
+**用途**:技术栈分析、性能分析、安全审计
+
+**实现位置**:`agent/builtin_agents.py`(待实现)
+
+---
+
+## 核心 API
+
+### Task Tool
+
+```python
+# agent/tools/builtin/task.py
+
+@tool(name="task")
+async def task_tool(
+    subagent_type: str,      # Sub-Agent 类型
+    description: str,         # 任务简短描述(3-5词)
+    prompt: str,              # 详细任务描述
+    ctx: ToolContext
+) -> ToolResult:
+    """启动 Sub-Agent 处理子任务"""
+```
+
+**实现位置**:`agent/tools/builtin/task.py:task_tool`(待实现)
+
+### Agent Registry
+
+```python
+# agent/agent_registry.py
+
+class AgentRegistry:
+    def register(self, agent: AgentDefinition) -> None:
+        """注册 Agent"""
+
+    def get(self, name: str) -> Optional[AgentDefinition]:
+        """获取 Agent 定义"""
+
+    def list_subagents(self) -> List[AgentDefinition]:
+        """列出所有可用的 Sub-Agent"""
+
+    def load_from_config(self, config_path: str) -> None:
+        """从配置文件加载"""
+
+# 全局注册表
+def get_agent_registry() -> AgentRegistry:
+    """获取全局 Agent 注册表"""
+```
+
+**实现位置**:`agent/agent_registry.py:AgentRegistry`(待实现)
+
+---
+
+## 配置文件
+
+### 格式
+
+```json
+{
+  "agents": {
+    "code-reviewer": {
+      "description": "代码审查专家",
+      "mode": "subagent",
+      "allowed_tools": ["read_file", "search_code", "list_files"],
+      "max_iterations": 15,
+      "temperature": 0.2,
+      "system_prompt": "你是代码审查专家...",
+      "can_spawn_subagent": false
+    }
+  }
+}
+```
+
+**框架预设**:`agent/subagents/default.json`
+
+**项目配置**(可选):`./subagents/custom.json`
+
+### 加载
+
+```python
+from agent.agent_registry import get_agent_registry
+
+# 加载框架预设
+registry = get_agent_registry()
+registry.load_from_config("agent/subagents/default.json")
+
+# 加载项目自定义配置(可选,覆盖框架预设)
+from pathlib import Path
+if Path("./subagents/custom.json").exists():
+    registry.load_from_config("./subagents/custom.json")
+```
+
+---
+
+## 使用示例
+
+### 基本使用
+
+```python
+from agent import AgentRunner, AgentConfig
+
+runner = AgentRunner(
+    llm_call=your_llm,
+    config=AgentConfig(max_iterations=20),
+)
+
+# 主 Agent 会自动使用 task 工具启动 Sub-Agent
+async for event in runner.run(
+    task="使用 explore sub-agent 查找所有配置文件"
+):
+    if event.type == "conclusion":
+        print(event.data["content"])
+```
+
+### 自定义 Sub-Agent
+
+```python
+from agent.models.agent import AgentDefinition
+from agent.agent_registry import get_agent_registry
+
+# 定义
+custom = AgentDefinition(
+    name="security-scanner",
+    description="安全扫描专家",
+    mode="subagent",
+    allowed_tools=["read_file", "search_code"],
+)
+
+# 注册
+get_agent_registry().register(custom)
+```
+
+**完整示例**:`examples/subagent_example.py`
+
+---
+
+## 注意事项
+
+1. **防止递归**:默认禁止 Sub-Agent 调用 task 工具
+2. **权限隔离**:Sub-Agent 只能访问允许的工具
+3. **Trace 完整性**:每个 Sub-Agent 有独立的 Trace
+4. **成本控制**:跟踪每个 Sub-Agent 的 token 消耗

+ 108 - 0
docs/testing.md

@@ -0,0 +1,108 @@
+# 测试指南
+
+> 项目测试策略和运行方法
+
+---
+
+## 测试分层
+
+```
+E2E 测试 - 真实 LLM 调用(需要 API Key)
+    ↑
+集成测试 - 多模块协作(Mock LLM)
+    ↑
+单元测试 - 单个函数/类
+```
+
+---
+
+## 运行测试
+
+### 本地运行
+
+```bash
+# 单元测试 + 集成测试
+pytest tests/ -v -m "not e2e"
+
+# 指定模块
+pytest tests/test_agent_definition.py -v
+
+# 生成覆盖率报告
+pytest --cov=agent --cov-report=html tests/ -m "not e2e"
+
+# E2E 测试(可选,需要 API Key)
+GEMINI_API_KEY=xxx pytest tests/e2e/ -v -m e2e
+```
+
+### CI 运行
+
+```bash
+# 单元测试 + 集成测试(PR 时运行)
+pytest tests/ -v -m "not e2e" --cov=agent
+
+# E2E 测试(仅 main 分支)
+pytest tests/e2e/ -v -m e2e
+```
+
+---
+
+## 覆盖率要求
+
+| 模块 | 目标覆盖率 |
+|------|-----------|
+| agent/models/ | 90%+ |
+| agent/tools/ | 85%+ |
+| agent/storage/ | 80%+ |
+| agent/llm/ | 70%+ |
+
+---
+
+## 测试标记
+
+```python
+# 单元测试(默认)
+def test_something():
+    pass
+
+# E2E 测试(需要 API Key)
+@pytest.mark.e2e
+def test_real_llm():
+    pass
+```
+
+---
+
+## CI 配置
+
+```yaml
+# .github/workflows/test.yml
+
+jobs:
+  test:
+    - name: Run tests
+      run: pytest tests/ -v -m "not e2e" --cov=agent
+
+  e2e:
+    if: github.ref == 'refs/heads/main'
+    - name: Run E2E tests
+      env:
+        GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+      run: pytest tests/e2e/ -v -m e2e
+```
+
+---
+
+## 测试文件结构
+
+```
+tests/
+├── conftest.py              # Fixtures
+├── test_agent_definition.py
+├── test_trace.py
+├── test_tools.py
+├── integration/
+│   ├── test_subagent.py
+│   └── test_trace_store.py
+└── e2e/
+    └── test_subagent_e2e.py
+```

+ 232 - 0
docs/tools-adapters.md

@@ -0,0 +1,232 @@
+# 工具适配器
+
+> 参考 opencode 实现的基础工具,移植到 Python 并集成到框架
+
+---
+
+## ⚠️ 第三方工具接入规范
+
+**工具分类原则**:
+
+| 类型 | 实现方式 | 目录结构 | 示例 |
+|------|---------|---------|------|
+| **高频&简单工具** | Python 原生实现 | `agent/tools/builtin/` | read_file, edit_file, bash_command |
+| **复杂&低频工具** | 第三方仓库 + 适配器 | `vendor/` + `agent/tools/adapters/` + `agent/tools/advanced/` | webfetch, lsp_diagnostics |
+| **CLI 命令行工具** | pip 安装到虚拟环境 | `requirements.txt` + `bash_command` 调用 | browser-use |
+
+**接入流程**:
+
+1. **简单工具**:直接在 `builtin/` 实现
+   - 参考第三方设计(如 opencode)
+   - Python 完整实现
+   - 通过 `@tool` 装饰器注册
+
+2. **复杂工具**:通过适配器调用
+   - 第三方仓库 → `vendor/`(Git Submodule,只读)
+   - 适配器/桥接 → `agent/tools/adapters/`
+   - 工具注册 → `agent/tools/advanced/`
+
+3. **CLI 工具**:通过 bash_command 调用
+   - 添加到 `requirements.txt`
+   - pip 安装到项目虚拟环境
+   - 通过 `bash_command` 工具调用 CLI
+   - 在 `skills/` 中提供使用指导
+
+**设计目标**:
+- 参考 opencode 的成熟工具设计(Git Submodule: `vendor/opencode/`)
+- Python 实现:基础工具(builtin/)完整复刻,高级工具(advanced/)通过 Bun 调用
+- 保持解耦,便于独立维护和更新
+
+---
+
+## 架构设计
+
+```
+vendor/opencode/                       # Git submodule(只读参考)
+  └── packages/opencode/src/tool/      # TypeScript 实现
+
+agent/tools/
+  ├── builtin/                         # 基础工具(Python 完整实现)
+  │   ├── read.py, edit.py, write.py  # 文件操作
+  │   ├── bash.py                      # 命令执行
+  │   └── glob.py, grep.py             # 文件搜索
+  │
+  ├── advanced/                        # 高级工具(Bun 适配器)
+  │   ├── webfetch.py                  # 网页抓取
+  │   └── lsp.py                       # LSP 诊断
+  │
+  └── adapters/
+      ├── base.py                      # 适配器基类
+      ├── opencode_bun_adapter.py      # Bun 调用适配器
+      └── opencode-wrapper.ts          # TypeScript 桥接
+```
+
+**实现策略**:
+- 基础工具(高频):Python 完整实现(~1-5ms,零依赖)
+- 高级工具(低频复杂):通过 Bun 调用 opencode(~50-100ms,功能完整)
+
+---
+
+## 工具清单
+
+### 基础工具(Python 实现)
+
+| 工具 | 实现 | 功能 |
+|------|------|------|
+| `read_file` | `builtin/read.py` | 读取文件(文本/图片/PDF),分页支持 |
+| `edit_file` | `builtin/edit.py` | 文件编辑,9 种智能匹配策略 |
+| `write_file` | `builtin/write.py` | 文件写入,自动创建目录 |
+| `bash_command` | `builtin/bash.py` | 命令执行,超时控制 |
+| `glob_files` | `builtin/glob.py` | 文件模式匹配 |
+| `grep_content` | `builtin/grep.py` | 内容搜索(ripgrep 优先) |
+| `skill` | `builtin/skill.py` | 加载技能文档 |
+| `list_skills` | `builtin/skill.py` | 列出可用技能 |
+
+**参考源**:`vendor/opencode/packages/opencode/src/tool/*.ts`
+
+**关键实现**:
+- `edit_file` 的 9 种策略:包含 Levenshtein 算法的 BlockAnchorReplacer
+- `bash_command` 的环境变量支持
+- `grep_content` 的 ripgrep fallback
+
+### 高级工具(Bun 适配器)
+
+| 工具 | 实现 | 调用方式 |
+|------|------|----------|
+| `webfetch` | `advanced/webfetch.py` | Bun → opencode(HTML 转 MD)|
+| `lsp_diagnostics` | `advanced/lsp.py` | Bun → opencode(LSP 集成)|
+
+**依赖**:需要 [Bun](https://bun.sh/) 运行时
+
+**桥接**:`adapters/opencode-wrapper.ts` 通过子进程调用 opencode 工具
+
+### CLI 工具(pip 安装)
+
+| 工具 | 安装方式 | 调用方式 |
+|------|---------|----------|
+| `browser-use` | `pip install browser-use` | `bash_command` 调用 CLI |
+
+**依赖检查**:自动检测并提示缺失的依赖
+
+```python
+# 1. 加载 skill 时自动检查(会提示缺失的依赖)
+await skill(skill_name="browser-use")
+# 输出包含:⚠️ Setup Required - pip install browser-use / uvx browser-use install
+
+# 2. 手动检查依赖(使用 skill 自带的配置工具)
+from agent.skills.browser_use import check_browser_use
+await check_browser_use()
+# 返回详细的依赖状态
+
+# 3. 自动安装 Chromium(可选,需要下载 200-300MB)
+from agent.skills.browser_use import install_browser_use_chromium
+await install_browser_use_chromium()
+```
+
+**使用示例**:
+```python
+# Agent 加载使用指南
+await skill(skill_name="browser-use")
+
+# 通过 bash_command 调用 CLI
+await bash_command(command="browser-use open https://example.com")
+await bash_command(command="browser-use state")
+await bash_command(command="browser-use click 5")
+```
+
+**Skill 文档**:`agent/skills/browser_use/browser-use.md` 提供完整的 CLI 使用指南
+
+---
+
+## 适配器接口
+
+**实现**:`agent/tools/adapters/base.py:ToolAdapter`
+
+核心方法:
+- `adapt_execute()` - 执行工具并转换结果为 `ToolResult`
+- `adapt_schema()` - 转换工具 Schema
+- `extract_memory()` - 提取长期记忆摘要
+
+**Bun 适配器**:`adapters/opencode_bun_adapter.py:OpenCodeBunAdapter`
+- 通过子进程调用 `bun run opencode-wrapper.ts <tool> <args>`
+- 30 秒超时
+- JSON 结果解析
+
+---
+
+## 更新 opencode 参考
+
+### 检查更新
+
+```bash
+# 查看 submodule 状态
+git submodule status
+
+# 更新 submodule
+cd vendor/opencode
+git pull origin main
+cd ../..
+
+# 提交更新
+git add vendor/opencode
+git commit -m "chore: update opencode reference"
+```
+
+### 同步改进
+
+1. **查看 opencode 变更**:
+   ```bash
+   cd vendor/opencode
+   git log --oneline --since="1 month ago" -- packages/opencode/src/tool/
+   ```
+
+2. **对比实现差异**:
+   - 查看 opencode 的具体改动
+   - 评估是否需要同步到 Python 实现
+
+3. **更新 Python 实现**:
+   - 在 `agent/tools/builtin/` 中更新对应工具
+   - 更新代码注释中的参考位置
+
+4. **测试验证**:
+   ```bash
+   pytest tests/tools/builtin/ -v
+   ```
+
+---
+
+## 使用示例
+
+```python
+from agent.tools.builtin import read_file, edit_file, bash_command
+from agent.tools.advanced import webfetch
+
+# 基础工具
+result = await read_file(file_path="config.py", limit=100)
+result = await edit_file(file_path="config.py", old_string="DEBUG = True", new_string="DEBUG = False")
+result = await bash_command(command="git status", timeout=30)
+
+# 高级工具(需要 Bun)
+result = await webfetch(url="https://docs.python.org/3/")
+```
+
+工具通过 `@tool` 装饰器自动注册到 `ToolRegistry`。
+
+**完整示例**:`examples/tools_complete_demo.py`
+
+---
+
+## 关键设计
+
+**Git Submodule 原则**:
+- `vendor/opencode/` 只读,绝不修改
+- 作为参考源,可随时更新:`cd vendor/opencode && git pull`
+- 基础工具手动同步重要改进,高级工具自动获得更新
+
+**高内聚原则**:
+- `opencode-wrapper.ts` 与 `opencode_bun_adapter.py` 在同一目录(`adapters/`)
+- Wrapper 是适配器的一部分,不是独立脚本
+
+**性能权衡**:
+- 基础工具(高频):Python 实现避免子进程开销
+- 高级工具(低频):Bun 适配器避免重复实现复杂逻辑

+ 94 - 2
docs/tools.md

@@ -11,8 +11,9 @@
 3. [ToolResult 和记忆管理](#toolresult-和记忆管理)
 4. [ToolContext 和依赖注入](#toolcontext-和依赖注入)
 5. [高级特性](#高级特性)
-6. [集成 Browser-Use](#集成-browser-use)
-7. [最佳实践](#最佳实践)
+6. [内置基础工具](#内置基础工具)
+7. [集成 Browser-Use](#集成-browser-use)
+8. [最佳实践](#最佳实践)
 
 ---
 
@@ -688,6 +689,97 @@ print(f"Success rate: {stats['success_rate']:.1%}")
 
 ---
 
+## 内置基础工具
+
+> 参考 opencode 实现的文件操作和命令执行工具
+
+框架提供一组内置的基础工具,用于文件读取、编辑、搜索和命令执行等常见任务。这些工具参考了 [opencode](https://github.com/anomalyco/opencode) 的成熟设计,在 Python 中重新实现。
+
+**实现位置**:
+- 工具实现:`agent/tools/builtin/`
+- 适配器层:`agent/tools/adapters/`
+- OpenCode 参考:`vendor/opencode/` (git submodule)
+
+**详细文档**:参考 [`docs/tools-adapters.md`](./tools-adapters.md)
+
+### 可用工具
+
+| 工具 | 功能 | 参考 |
+|------|------|------|
+| `read_file` | 读取文件内容(支持图片、PDF) | opencode read.ts |
+| `edit_file` | 智能文件编辑(多种匹配策略) | opencode edit.ts |
+| `write_file` | 写入文件(创建或覆盖) | opencode write.ts |
+| `bash_command` | 执行 shell 命令 | opencode bash.ts |
+| `glob_files` | 文件模式匹配 | opencode glob.ts |
+| `grep_content` | 内容搜索(正则表达式) | opencode grep.ts |
+
+### 快速使用
+
+```python
+from agent.tools.builtin import read_file, edit_file, bash_command
+
+# 读取文件
+result = await read_file(file_path="config.py", limit=100)
+print(result.output)
+
+# 编辑文件(智能匹配)
+result = await edit_file(
+    file_path="config.py",
+    old_string="DEBUG = True",
+    new_string="DEBUG = False"
+)
+
+# 执行命令
+result = await bash_command(
+    command="git status",
+    timeout=30,
+    description="Check git status"
+)
+```
+
+### 核心特性
+
+**Read Tool 特性**:
+- 二进制文件检测
+- 分页读取(offset/limit)
+- 行长度和字节限制
+- 图片/PDF 支持
+
+**Edit Tool 特性**:
+- 多种智能匹配策略:
+  - SimpleReplacer - 精确匹配
+  - LineTrimmedReplacer - 忽略行首尾空白
+  - WhitespaceNormalizedReplacer - 空白归一化
+- 自动生成 unified diff
+- 唯一性检查(防止错误替换)
+
+**Bash Tool 特性**:
+- 异步执行
+- 超时控制(默认 120 秒)
+- 工作目录设置
+- 输出截断(防止过长)
+
+### 更新 OpenCode 参考
+
+内置工具参考 `vendor/opencode/` 中的实现,通过 git submodule 管理:
+
+```bash
+# 更新 opencode 参考
+cd vendor/opencode
+git pull origin main
+cd ../..
+git add vendor/opencode
+git commit -m "chore: update opencode reference"
+
+# 查看最近变更
+cd vendor/opencode
+git log --oneline --since="1 month ago" -- packages/opencode/src/tool/
+```
+
+更新后,检查是否需要同步改进到 Python 实现。
+
+---
+
 ## 集成 Browser-Use
 
 ### 适配器模式

+ 0 - 165
examples/README_gemini_agent.md

@@ -1,165 +0,0 @@
-# Gemini Agent 测试成功 ✅
-
-## 问题诊断与修复
-
-### 🔍 发现的问题
-
-1. **消息历史丢失**
-   - **问题**:tool 消息缺少 `name` 字段
-   - **位置**:`agent/runner.py:417`
-   - **修复**:添加 `name: tool_name` 字段到 tool 消息
-
-2. **架构设计不当**
-   - **问题**:模型封装放在 example 中,应该在基础模块
-   - **修复**:创建 `agent/llm/providers/gemini.py` 模块
-
-3. **SDK 兼容性问题**
-   - **问题**:`google-generativeai` SDK 对参数格式要求严格,与 OpenAI JSON Schema 不兼容
-   - **修复**:使用 HTTP REST API(`httpx`)代替 SDK
-
-### ✅ 最终方案
-
-#### 1. HTTP API Provider(推荐)
-
-**文件**:`agent/llm/providers/gemini.py`
-
-**特性**:
-- 使用 `httpx` 直接调用 Gemini REST API
-- 完全控制消息格式转换
-- 正确处理:
-  - `systemInstruction`
-  - `functionCall` / `functionResponse`
-  - 连续 user 消息合并(Gemini 要求严格的 user/model 交替)
-
-**关键实现**:
-
-```python
-def _convert_messages_to_gemini(messages):
-    """OpenAI -> Gemini 格式转换"""
-    # 1. system -> systemInstruction
-    # 2. assistant + tool_calls -> model + functionCall
-    # 3. tool -> user + functionResponse
-    # 4. 合并连续的 user 消息
-    ...
-
-def _convert_tools_to_gemini(tools):
-    """工具定义转换,清理不支持的字段(default)"""
-    ...
-```
-
-#### 2. 使用方式
-
-```python
-from agent.llm.providers.gemini import create_gemini_llm_call
-from agent.runner import AgentRunner
-
-# 创建 LLM 调用函数
-gemini_llm_call = create_gemini_llm_call()
-
-# 创建 Agent
-runner = AgentRunner(
-    tool_registry=registry,
-    llm_call=gemini_llm_call
-)
-
-# 运行
-async for event in runner.run(
-    task="你的任务",
-    model="gemini-2.5-pro",
-    tools=["tool1", "tool2"],
-    system_prompt="系统提示"
-):
-    # 处理事件
-    ...
-```
-
-## 测试结果
-
-**任务**:"北京今天的天气怎么样?顺便帮我计算一下 15 * 8 等于多少。"
-
-**执行流程**:
-```
-1. [Gemini HTTP] Converted 1 messages: ['user']
-   → 调用工具: get_current_weather, calculate
-
-2. [Gemini HTTP] Converted 3 messages: ['user', 'model', 'user']
-   → 生成答案: "北京今天天气晴朗,15摄氏度。15 * 8 的计算结果是 120。"
-
-✓ Trace 完成
-  总 Tokens: 643
-```
-
-**结果**:✅ 完美运行,没有重复调用,正确结束
-
-## 架构对比
-
-### ❌ 之前(错误)
-
-```
-examples/
-└── gemini_basic_agent.py  # 包含所有逻辑
-    ├── 工具定义
-    ├── Gemini SDK 调用
-    └── Agent 运行
-```
-
-**问题**:
-- 模型封装混在示例中
-- 使用 SDK 导致兼容性问题
-- 消息历史处理有bug
-
-### ✅ 现在(正确)
-
-```
-agent/
-├── llm/
-│   └── providers/
-│       └── gemini.py          # HTTP API 封装
-├── tools/
-│   └── registry.py            # 工具注册
-└── runner.py                  # Agent 运行器
-
-examples/
-└── gemini_basic_agent.py      # 简洁的示例代码
-    ├── 工具定义
-    └── 使用 agent.llm.providers
-```
-
-**优势**:
-- 清晰的模块分层
-- 易于扩展(添加其他 provider)
-- HTTP API 完全控制格式
-
-## 依赖
-
-```bash
-pip install httpx python-dotenv
-
-# 如果有代理
-pip install 'httpx[socks]'
-```
-
-## 环境变量
-
-```bash
-# .env
-GEMINI_API_KEY=your_api_key_here
-```
-
-## 参考
-
-- Resonote 实现:`/Users/sunlit/Code/Resonote/llm/providers/gemini.py`
-- Gemini REST API 文档:https://ai.google.dev/api/rest
-
-## 下一步
-
-可以扩展支持更多 LLM provider:
-- OpenAI(已有 SDK)
-- Claude(Anthropic API)
-- DeepSeek
-- 本地模型(Ollama)
-
-每个 provider 只需实现相同的接口:
-```python
-async def llm_call(messages, model, tools, **kwargs) -> Dict
-```

+ 62 - 0
examples/browser_use_setup_demo.py

@@ -0,0 +1,62 @@
+"""
+Browser-Use 自动设置演示
+
+展示如何使用自动检查和安装工具
+"""
+
+import asyncio
+from agent.skills.browser_use import (
+    check_browser_use,
+    install_browser_use_chromium
+)
+from agent.tools.builtin import skill
+
+
+async def demo():
+    """演示 browser-use 设置流程"""
+
+    print("=" * 60)
+    print("Browser-Use 自动设置演示")
+    print("=" * 60)
+
+    # 1. 加载 skill(会自动检查依赖)
+    print("\n1. 加载 browser-use skill(自动检查依赖)")
+    result = await skill(skill_name="browser-use")
+    print(f"✅ {result.title}")
+    if "⚠️" in result.output:
+        print("   检测到缺失的依赖,输出中包含安装提示")
+
+    # 2. 手动检查依赖
+    print("\n2. 手动检查依赖状态")
+    result = await check_browser_use()
+    print(f"✅ {result.title}")
+    print(f"   CLI 已安装: {result.metadata.get('cli_installed', False)}")
+    print(f"   Chromium 已安装: {result.metadata.get('chromium_installed', False)}")
+    print(f"   状态: {result.metadata.get('status', 'unknown')}")
+
+    # 3. 自动安装 Chromium(如果需要)
+    if not result.metadata.get("chromium_installed", False):
+        print("\n3. 安装 Chromium 浏览器(可选)")
+        print("   注意:这会下载 200-300MB 数据")
+
+        # 用户确认
+        confirm = input("   是否继续安装?(y/N): ")
+        if confirm.lower() == "y":
+            result = await install_browser_use_chromium()
+            print(f"   {result.title}")
+            if result.metadata.get("installed"):
+                print("   ✅ 安装成功")
+            else:
+                print("   ❌ 安装失败,请查看输出")
+        else:
+            print("   跳过安装")
+    else:
+        print("\n3. Chromium 已安装,跳过")
+
+    print("\n" + "=" * 60)
+    print("演示完成!")
+    print("=" * 60)
+
+
+if __name__ == "__main__":
+    asyncio.run(demo())

+ 34 - 0
examples/feature_extract/feature_extract.prompt

@@ -0,0 +1,34 @@
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+# 角色
+你是一位计算机视觉专家,也是一位才华横溢的社媒博主、内容创作者。
+
+# 任务
+分析一个优质内容的指定特征适合如何表示,并完成该特征的提取。
+提取的特征将用于在生成类似内容时作为参考内容(所以要保留重要信息),也会和其他内容的同一维度的特征放在一起聚类发现规律(所以特征表示要尽量精简、不要过于具体),或用于模型训练。
+
+# 工具
+- 你可以加载browser-use的skill,并根据skill中的指引操作浏览器,来做调研或检索
+- 可以使用 task 工具启动专门的 Sub-Agent。
+
+可用的 Sub-Agent:
+- tool_builder: 编写代码来创建指定工具,并配置好工具所需环境、对外提供API
+- tool_deployer: 完成指定的开源代码工具的环境配置与部署,对外提供API
+
+# 经验与反思
+你可以调用 search_experience 搜索过去的经验。
+注意总结任务执行过程中的经验,调用 record_experience 工具进行记录。
+
+过去经验的一个总结如下:
+%memory%
+
+$user$
+# 指定的特征:
+%text%
+
+# 结果保存路径
+/Users/sunlit/Code/Agent/examples/feature_extract/output_1/

+ 5 - 0
examples/feature_extract/input_1/feature.md

@@ -0,0 +1,5 @@
+  {
+    "核心视觉特征": "整体构图",
+    "具体表现": "画面采用中心构图与引导线构图相结合,林间小路从画面底部中央向远方延伸,形成视觉引导线,将观众视线引向远处的骑行者和光束。画面下方约1/2的垂直高度(约350像素)有水坑倒影,宽度覆盖整个画面(1200像素),形成对称构图,增强了画面的平衡感和层次感。画面左右两侧高大的树木形成自然的拱形通道,将主体框选在其中。",
+    "评分": 0.652
+  }

BIN
examples/feature_extract/input_1/image.png


+ 53 - 0
examples/feature_extract/output_1/result.txt

@@ -0,0 +1,53 @@
+作为一位计算机视觉专家和社媒博主,我将根据您提供的“整体构图”特征描述,对其进行结构化和精炼,以便于内容生成、聚类分析和模型训练。
+
+# 特征表示分析
+
+为了在保留关键信息的同时,使特征既能用于生成类似内容(需要细节),又能用于聚类和模型训练(需要精简和标准化),我将采用嵌套的JSON结构。
+
+*   **顶层**:明确指出这是关于“整体构图”的特征,并包含原始的“评分”。
+*   **构图手法列表**:使用一个列表来列出主要的构图类型,这对于快速分类和聚类非常有用。
+*   **具体构图元素**:对于引导线、对称和框架构图,我将它们分别抽象为独立的子对象,每个子对象包含:
+    *   `存在`:布尔值,表示该构图手法是否存在(便于快速判断和训练)。
+    *   `元素`:构成该手法的具体物体(例如“道路”、“树木”)。
+    *   `形式`:该元素的具体呈现方式(例如“拱形通道”、“水坑倒影”)。
+    *   `方向/位置`:描述元素在画面中的相对位置或方向。
+    *   `目标/对象`:该构图手法旨在强调或引导的对象。
+*   **主体放置**:对画面中主体(如骑行者)的相对位置进行概括性描述。
+*   **补充说明**:提供一段概括性的文字,用于在内容生成时增加语境和艺术性。
+
+# 提取的特征
+
+```json
+{
+  "整体构图": {
+    "主要构图手法": [
+      "中心构图",
+      "引导线构图",
+      "对称构图",
+      "框架构图"
+    ],
+    "引导线": {
+      "存在": true,
+      "元素": "道路",
+      "方向": "从画面底部中央向远方延伸",
+      "引导目标": ["骑行者", "光束"]
+    },
+    "对称构图": {
+      "存在": true,
+      "类型": "水平倒影对称",
+      "对称元素": "水坑倒影",
+      "位置描述": "画面下半部,倒影区域宽度覆盖整个画面"
+    },
+    "框架构图": {
+      "存在": true,
+      "元素": "高大树木",
+      "形式": "形成自然的拱形通道",
+      "位置": "画面左右两侧",
+      "框选对象": "骑行者 (主体)"
+    },
+    "主体放置": "中心偏远"
+  },
+  "补充说明": "多种构图手法巧妙结合,营造出强烈的纵深感和空间感,特别是光线和倒影的运用,极大地增强了画面的艺术感染力,使得整体画面富有叙事性和沉浸感。",
+  "评分": 0.652
+}
+```

+ 98 - 0
examples/feature_extract/run.py

@@ -0,0 +1,98 @@
+"""
+特征提取示例
+
+使用 Agent 框架 + Prompt loader + 多模态支持
+"""
+
+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()
+
+from agent.prompts import SimplePrompt
+from agent.runner import AgentRunner
+from agent.llm.providers.gemini import create_gemini_llm_call
+
+
+async def main():
+    # 路径配置
+    base_dir = Path(__file__).parent
+    prompt_path = base_dir / "test.prompt"
+    feature_md_path = base_dir / "input_1" / "feature.md"
+    image_path = base_dir / "input_1" / "image.png"
+    output_dir = base_dir / "output_1"
+    output_dir.mkdir(exist_ok=True)
+
+    print("=" * 60)
+    print("特征提取任务")
+    print("=" * 60)
+    print()
+
+    # 1. 加载 prompt
+    print("1. 加载 prompt...")
+    prompt = SimplePrompt(prompt_path)
+
+    # 2. 读取特征描述
+    print("2. 读取特征描述...")
+    with open(feature_md_path, 'r', encoding='utf-8') as f:
+        feature_text = f.read()
+
+    # 3. 构建多模态消息
+    print("3. 构建多模态消息(文本 + 图片)...")
+    messages = prompt.build_messages(
+        text=feature_text,
+        images=image_path  # 框架自动处理图片
+    )
+
+    print(f"   - 消息数量: {len(messages)}")
+    print(f"   - 图片: {image_path.name}")
+
+    # 4. 创建 Agent Runner
+    print("4. 创建 Agent Runner...")
+    runner = AgentRunner(
+        llm_call=create_gemini_llm_call()
+    )
+
+    # 5. 调用 Agent
+    print(f"5. 调用模型: {prompt.config.get('model', 'gemini-2.5-flash')}...")
+    print()
+
+    result = await runner.call(
+        messages=messages,
+        model=prompt.config.get('model', 'gemini-2.5-flash'),
+        temperature=float(prompt.config.get('temperature', 0.3)),
+        trace=False  # 暂不记录 trace
+    )
+
+    # 6. 输出结果
+    print("=" * 60)
+    print("模型响应:")
+    print("=" * 60)
+    print(result.reply)
+    print("=" * 60)
+    print()
+
+    # 7. 保存结果
+    output_file = output_dir / "result.txt"
+    with open(output_file, 'w', encoding='utf-8') as f:
+        f.write(result.reply)
+
+    print(f"✓ 结果已保存到: {output_file}")
+    print()
+
+    # 8. 打印统计信息
+    print("统计信息:")
+    if result.tokens:
+        print(f"  输入 tokens: {result.tokens.get('prompt', 0)}")
+        print(f"  输出 tokens: {result.tokens.get('completion', 0)}")
+    print(f"  费用: ${result.cost:.4f}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 22 - 0
examples/feature_extract/test.prompt

@@ -0,0 +1,22 @@
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+# 角色
+你是一位计算机视觉专家,也是一位才华横溢的社媒博主、内容创作者。
+
+# 任务
+分析一个优质内容的指定特征适合如何表示,并完成该特征的提取。
+提取的特征将用于在生成类似内容时作为参考内容(所以要保留重要信息),也会和其他内容的同一维度的特征放在一起聚类发现规律(所以特征表示要尽量精简、不要过于具体),或用于模型训练。
+
+# 工具
+- 你可以加载browser-use的skill,并根据skill中的指引操作浏览器,来做调研或检索
+
+$user$
+# 指定的特征:
+%text%
+
+# 结果保存路径
+/Users/sunlit/Code/Agent/examples/feature_extract/output_1/

+ 0 - 224
examples/gemini_basic_agent.py

@@ -1,224 +0,0 @@
-"""
-Gemini Agent 基础示例
-
-使用 Gemini 2.5 Pro 模型,演示带工具调用的 Agent
-
-依赖:
-    pip install httpx python-dotenv
-
-使用方法:
-    python examples/gemini_basic_agent.py
-"""
-
-import os
-import sys
-import json
-import asyncio
-from typing import Dict, Any, List, Optional
-from dotenv import load_dotenv
-
-# 添加项目根目录到 Python 路径
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-# 加载环境变量
-load_dotenv()
-
-# 导入框架
-from agent.tools import tool, ToolResult, get_tool_registry
-from agent.runner import AgentRunner
-from agent.llm.providers.gemini import create_gemini_llm_call
-
-
-# ============================================================
-# 定义工具
-# ============================================================
-
-@tool()
-async def get_current_weather(location: str, unit: str = "celsius", uid: str = "") -> Dict[str, Any]:
-    """
-    获取指定地点的当前天气
-
-    Args:
-        location: 城市名称,如 "北京"、"San Francisco"
-        unit: 温度单位,"celsius" 或 "fahrenheit"
-
-    Returns:
-        天气信息字典
-    """
-    # 模拟天气数据
-    weather_data = {
-        "北京": {"temp": 15, "condition": "晴朗", "humidity": 45},
-        "上海": {"temp": 20, "condition": "多云", "humidity": 60},
-        "San Francisco": {"temp": 18, "condition": "Foggy", "humidity": 70},
-        "New York": {"temp": 10, "condition": "Rainy", "humidity": 80}
-    }
-
-    data = weather_data.get(location, {"temp": 22, "condition": "Unknown", "humidity": 50})
-
-    if unit == "fahrenheit":
-        data["temp"] = data["temp"] * 9/5 + 32
-
-    return {
-        "location": location,
-        "temperature": data["temp"],
-        "unit": unit,
-        "condition": data["condition"],
-        "humidity": data["humidity"]
-    }
-
-
-@tool()
-async def calculate(expression: str, uid: str = "") -> ToolResult:
-    """
-    执行数学计算
-
-    Args:
-        expression: 数学表达式,如 "2 + 2"、"10 * 5"
-
-    Returns:
-        计算结果
-    """
-    try:
-        # 安全地计算简单表达式
-        # 注意:实际生产环境应使用更安全的方法
-        result = eval(expression, {"__builtins__": {}}, {})
-
-        return ToolResult(
-            title="计算结果",
-            output=f"{expression} = {result}",
-            long_term_memory=f"计算了 {expression}"
-        )
-    except Exception as e:
-        return ToolResult(
-            title="计算错误",
-            output=f"无法计算 '{expression}': {str(e)}",
-            long_term_memory=f"计算失败: {expression}"
-        )
-
-
-@tool()
-async def search_knowledge(query: str, max_results: int = 3, uid: str = "") -> ToolResult:
-    """
-    搜索知识库
-
-    Args:
-        query: 搜索关键词
-        max_results: 返回结果数量
-
-    Returns:
-        搜索结果
-    """
-    # 模拟知识库搜索
-    knowledge_base = {
-        "Python": "Python 是一种高级编程语言,以简洁易读的语法著称。",
-        "Agent": "Agent 是能够感知环境并采取行动以实现目标的智能体。",
-        "Gemini": "Gemini 是 Google 开发的多模态大语言模型系列。",
-        "AI": "人工智能(AI)是计算机科学的一个分支,致力于创建智能机器。"
-    }
-
-    results = []
-    for key, value in knowledge_base.items():
-        if query.lower() in key.lower() or query.lower() in value.lower():
-            results.append({"title": key, "content": value})
-            if len(results) >= max_results:
-                break
-
-    if not results:
-        output = f"未找到关于 '{query}' 的信息"
-    else:
-        output = "\n\n".join([f"**{r['title']}**\n{r['content']}" for r in results])
-
-    return ToolResult(
-        title=f"搜索结果: {query}",
-        output=output,
-        long_term_memory=f"搜索了 '{query}',找到 {len(results)} 条结果"
-    )
-
-
-# ============================================================
-# 主函数
-# ============================================================
-
-async def main():
-    print("=" * 60)
-    print("Gemini Agent 基础示例")
-    print("=" * 60)
-    print()
-
-    # 获取工具注册表
-    registry = get_tool_registry()
-
-    # 打印可用工具
-    print("可用工具:")
-    for tool_name in registry.get_tool_names():
-        print(f"  - {tool_name}")
-    print()
-
-    # 创建 Gemini LLM 调用函数
-    gemini_llm_call = create_gemini_llm_call()
-
-    # 创建 Agent Runner
-    runner = AgentRunner(
-        tool_registry=registry,
-        llm_call=gemini_llm_call,
-    )
-
-    # 测试任务
-    task = "北京今天的天气怎么样?顺便帮我计算一下 15 * 8 等于多少。"
-
-    print(f"任务: {task}")
-    print("-" * 60)
-    print()
-
-    # 运行 Agent
-    async for event in runner.run(
-        task=task,
-        model="gemini-2.5-pro",
-        tools=["get_current_weather", "calculate", "search_knowledge"],
-        max_iterations=5,
-        enable_memory=False,  # 暂不启用记忆
-        auto_execute_tools=True,
-        system_prompt="你是一个有用的AI助手,可以使用工具来帮助用户。请简洁明了地回答问题。"
-    ):
-        event_type = event.type
-        data = event.data
-
-        if event_type == "trace_started":
-            print(f"✓ Trace 开始: {data['trace_id']}")
-            print()
-
-        elif event_type == "llm_call_completed":
-            print(f"🤖 LLM 响应:")
-            if data.get("content"):
-                print(f"   {data['content']}")
-            if data.get("tool_calls"):
-                print(f"   工具调用: {len(data['tool_calls'])} 个")
-            print(f"   Tokens: {data.get('tokens', 0)}")
-            print()
-
-        elif event_type == "tool_executing":
-            print(f"🔧 执行工具: {data['tool_name']}")
-            print(f"   参数: {json.dumps(data['arguments'], ensure_ascii=False)}")
-
-        elif event_type == "tool_result":
-            print(f"   结果: {data['result'][:100]}...")
-            print()
-
-        elif event_type == "conclusion":
-            print(f"✅ 最终回答:")
-            print(f"   {data['content']}")
-            print()
-
-        elif event_type == "trace_completed":
-            print(f"✓ Trace 完成")
-            print(f"   总 Tokens: {data.get('total_tokens', 0)}")
-            print(f"   总成本: ${data.get('total_cost', 0):.4f}")
-            print()
-
-        elif event_type == "trace_failed":
-            print(f"❌ Trace 失败: {data.get('error')}")
-            print()
-
-
-if __name__ == "__main__":
-    asyncio.run(main())

+ 229 - 0
examples/subagent_example.py

@@ -0,0 +1,229 @@
+"""
+Sub-Agent 使用示例
+
+演示如何使用 Sub-Agent 机制处理复杂任务。
+"""
+
+import asyncio
+import os
+from agent import AgentRunner, AgentConfig
+from agent.llm.providers.gemini import create_gemini_llm_call
+from agent.agent_registry import get_agent_registry
+from agent.models.agent import AgentDefinition
+
+
+async def example_basic_subagent():
+    """示例 1: 使用内置 Sub-Agent"""
+    print("=== 示例 1: 使用内置 Sub-Agent ===\n")
+
+    # 配置主 Agent
+    config = AgentConfig(
+        agent_type="primary",
+        max_iterations=20,
+        skills_dir="./skills",
+    )
+
+    runner = AgentRunner(
+        llm_call=create_gemini_llm_call(os.getenv("GEMINI_API_KEY")),
+        config=config,
+    )
+
+    # 主 Agent 会自动知道如何使用 Task 工具启动 Sub-Agent
+    task = """
+    分析这个 Python 项目的架构:
+    1. 找出所有主要的模块和它们的职责
+    2. 识别核心的数据流
+    3. 列出使用的外部依赖
+
+    请使用 explore sub-agent 来探索代码库。
+    """
+
+    async for event in runner.run(task=task, model="gemini-2.0-flash-exp"):
+        if event.type == "tool_call" and event.data.get("tool") == "task":
+            print(f"🚀 启动 Sub-Agent: {event.data['args']['subagent_type']}")
+            print(f"   任务: {event.data['args']['description']}")
+
+        elif event.type == "tool_result" and event.data.get("tool") == "task":
+            metadata = event.data.get("metadata", {})
+            print(f"✅ Sub-Agent 完成")
+            print(f"   Sub-Trace ID: {metadata.get('sub_trace_id')}")
+            print(f"   工具调用: {metadata.get('tool_summary')}")
+
+        elif event.type == "conclusion":
+            print(f"\n📋 最终结果:\n{event.data['content']}")
+
+
+async def example_custom_subagent():
+    """示例 2: 定义和使用自定义 Sub-Agent"""
+    print("\n=== 示例 2: 自定义 Sub-Agent ===\n")
+
+    # 1. 定义自定义 Sub-Agent
+    security_scanner = AgentDefinition(
+        name="security-scanner",
+        description="安全扫描专家,查找常见漏洞和安全问题",
+        mode="subagent",
+        allowed_tools=[
+            "read_file",
+            "search_code",
+            "list_files",
+        ],
+        denied_tools=[
+            "write_file",
+            "edit_file",
+            "execute_bash",
+        ],
+        system_prompt="""你是一个安全扫描专家。专注于:
+
+1. 查找常见安全漏洞:
+   - SQL 注入
+   - XSS 攻击
+   - CSRF 漏洞
+   - 不安全的序列化
+   - 路径遍历
+
+2. 检查敏感信息泄露:
+   - 硬编码的密钥和密码
+   - API token
+   - 数据库凭据
+
+3. 依赖项安全:
+   - 过时的依赖
+   - 已知漏洞的包
+
+输出格式:
+- **漏洞类型**: [类型名称]
+- **严重程度**: [高/中/低]
+- **位置**: [文件路径:行号]
+- **描述**: [详细说明]
+- **修复建议**: [如何修复]
+""",
+        max_iterations=25,
+        temperature=0.2,  # 更精确的分析
+    )
+
+    # 2. 注册到全局注册表
+    get_agent_registry().register(security_scanner)
+    print(f"✓ 注册自定义 Sub-Agent: {security_scanner.name}")
+
+    # 3. 使用自定义 Sub-Agent
+    config = AgentConfig(
+        agent_type="primary",
+        max_iterations=20,
+    )
+
+    runner = AgentRunner(
+        llm_call=create_gemini_llm_call(os.getenv("GEMINI_API_KEY")),
+        config=config,
+    )
+
+    task = """
+    对这个项目进行安全审计,使用 security-scanner sub-agent。
+    重点关注:
+    1. 认证和授权相关代码
+    2. 数据库交互代码
+    3. 用户输入处理
+    """
+
+    async for event in runner.run(task=task, model="gemini-2.0-flash-exp"):
+        if event.type == "tool_call" and event.data.get("tool") == "task":
+            print(f"🔍 启动安全扫描...")
+
+        elif event.type == "conclusion":
+            print(f"\n🔒 安全审计报告:\n{event.data['content']}")
+
+
+async def example_multiple_subagents():
+    """示例 3: 并行使用多个 Sub-Agent"""
+    print("\n=== 示例 3: 并行使用多个 Sub-Agent ===\n")
+
+    config = AgentConfig(
+        agent_type="primary",
+        max_iterations=30,
+    )
+
+    runner = AgentRunner(
+        llm_call=create_gemini_llm_call(os.getenv("GEMINI_API_KEY")),
+        config=config,
+    )
+
+    task = """
+    完整分析这个项目:
+
+    1. 使用 explore sub-agent 探索代码结构
+    2. 使用 analyst sub-agent 分析技术栈和依赖
+    3. 综合两个分析结果,给出项目概览
+
+    可以并行启动多个 sub-agent 来加快速度。
+    """
+
+    subagent_results = {}
+
+    async for event in runner.run(task=task, model="gemini-2.0-flash-exp"):
+        if event.type == "tool_call" and event.data.get("tool") == "task":
+            agent_type = event.data['args']['subagent_type']
+            print(f"🔄 启动 Sub-Agent: {agent_type}")
+
+        elif event.type == "tool_result" and event.data.get("tool") == "task":
+            metadata = event.data.get("metadata", {})
+            agent_type = metadata.get("subagent_type")
+            subagent_results[agent_type] = {
+                "sub_trace_id": metadata.get("sub_trace_id"),
+                "summary": metadata.get("tool_summary"),
+            }
+            print(f"✅ {agent_type} 完成: {metadata.get('tool_summary')}")
+
+        elif event.type == "conclusion":
+            print(f"\n📊 综合分析报告:\n{event.data['content']}")
+            print(f"\n📈 Sub-Agent 使用统计:")
+            for agent_type, result in subagent_results.items():
+                print(f"  - {agent_type}: {result['summary']}")
+
+
+async def example_subagent_config_file():
+    """示例 4: 从配置文件加载 Sub-Agent"""
+    print("\n=== 示例 4: 配置文件方式 ===\n")
+
+    # 1. 从配置文件加载
+    # 假设有 sub_agents.json:
+    # {
+    #   "agents": {
+    #     "code-reviewer": {
+    #       "description": "代码审查专家",
+    #       "mode": "subagent",
+    #       "allowed_tools": ["read_file", "search_code"],
+    #       "system_prompt": "你是代码审查专家..."
+    #     }
+    #   }
+    # }
+
+    registry = get_agent_registry()
+
+    # 如果配置文件存在
+    config_path = "sub_agents.json"
+    if os.path.exists(config_path):
+        registry.load_from_config(config_path)
+        print(f"✓ 从 {config_path} 加载 Agent 配置")
+
+    # 2. 列出所有可用的 Sub-Agent
+    print("\n可用的 Sub-Agent:")
+    for agent in registry.list_subagents():
+        print(f"  - {agent.name}: {agent.description}")
+
+
+async def main():
+    """运行所有示例"""
+    # 示例 1: 基本使用
+    await example_basic_subagent()
+
+    # 示例 2: 自定义 Sub-Agent
+    await example_custom_subagent()
+
+    # 示例 3: 并行使用多个 Sub-Agent
+    await example_multiple_subagents()
+
+    # 示例 4: 配置文件方式
+    await example_subagent_config_file()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 188 - 0
examples/tools_complete_demo.py

@@ -0,0 +1,188 @@
+"""
+完整工具系统使用示例
+
+演示基础工具和高级工具的使用。
+"""
+
+import asyncio
+from agent.tools.builtin import (
+    read_file,
+    edit_file,
+    write_file,
+    bash_command,
+    glob_files,
+    grep_content
+)
+
+
+async def demo_basic_tools():
+    """演示基础工具(Python 实现)"""
+
+    print("=" * 60)
+    print("基础工具演示")
+    print("=" * 60)
+
+    # 1. 读取文件
+    print("\n1. 读取文件")
+    result = await read_file(file_path="README.md", limit=20)
+    print(f"✅ {result.title}")
+    print(f"   前 5 行: {result.output[:200]}...")
+
+    # 2. 搜索文件
+    print("\n2. Glob 搜索")
+    result = await glob_files(pattern="**/*.py", path="agent/tools")
+    print(f"✅ {result.title}")
+    print(f"   找到 {result.metadata['count']} 个文件")
+
+    # 3. 内容搜索
+    print("\n3. Grep 搜索")
+    result = await grep_content(
+        pattern="async def",
+        path="agent/tools/builtin",
+        include="*.py"
+    )
+    print(f"✅ {result.title}")
+    print(f"   找到 {result.metadata['matches']} 个匹配")
+
+    # 4. 执行命令
+    print("\n4. Bash 命令")
+    result = await bash_command(
+        command="git status --short",
+        timeout=10
+    )
+    print(f"✅ {result.title}")
+    print(f"   退出码: {result.metadata['exit_code']}")
+
+    # 5. 编辑文件(演示智能匹配)
+    print("\n5. 智能编辑(9 种策略)")
+
+    # 创建测试文件
+    test_content = """
+def hello():
+    print("Hello")
+
+def world():
+    print("World")
+"""
+    await write_file(file_path="/tmp/test_edit.py", content=test_content)
+
+    # 编辑:忽略缩进(会使用 IndentationFlexibleReplacer)
+    result = await edit_file(
+        file_path="/tmp/test_edit.py",
+        old_string='def hello():\nprint("Hello")',  # 缩进不同
+        new_string='def hello():\n    print("Hello, World!")'
+    )
+    print(f"✅ {result.title}")
+    print(f"   Diff:\n{result.metadata['diff'][:200]}...")
+
+
+async def demo_advanced_tools():
+    """演示高级工具(Bun 适配器)"""
+
+    print("\n" + "=" * 60)
+    print("高级工具演示(需要 Bun)")
+    print("=" * 60)
+
+    try:
+        from agent.tools.advanced import webfetch, lsp_diagnostics
+
+        # 1. 网页抓取
+        print("\n1. 网页抓取 (HTML -> Markdown)")
+        result = await webfetch(
+            url="https://example.com",
+            format="markdown"
+        )
+        print(f"✅ {result.title}")
+        print(f"   内容长度: {len(result.output)} 字符")
+
+        # 2. LSP 诊断
+        print("\n2. LSP 诊断")
+        result = await lsp_diagnostics(
+            file_path="agent/tools/builtin/edit.py"
+        )
+        print(f"✅ {result.title}")
+        print(f"   诊断结果: {result.output[:200]}...")
+
+    except Exception as e:
+        print(f"⚠️  高级工具需要 Bun 运行时: {e}")
+        print("   安装: curl -fsSL https://bun.sh/install | bash")
+
+
+async def demo_edit_strategies():
+    """演示 edit_file 的 9 种匹配策略"""
+
+    print("\n" + "=" * 60)
+    print("edit_file 策略演示")
+    print("=" * 60)
+
+    test_cases = [
+        {
+            "name": "策略 1: 精确匹配",
+            "content": "DEBUG = True\nVERBOSE = False",
+            "old": "DEBUG = True",
+            "new": "DEBUG = False"
+        },
+        {
+            "name": "策略 2: 忽略行首尾空白",
+            "content": "  DEBUG = True  \nVERBOSE = False",
+            "old": "DEBUG = True",  # 无空白
+            "new": "DEBUG = False"
+        },
+        {
+            "name": "策略 4: 空白归一化",
+            "content": "DEBUG  =   True",
+            "old": "DEBUG = True",  # 单空格
+            "new": "DEBUG = False"
+        },
+        {
+            "name": "策略 5: 灵活缩进",
+            "content": """
+def foo():
+    if True:
+        print("hello")
+""",
+            "old": "if True:\nprint(\"hello\")",  # 无缩进
+            "new": "if True:\n    print(\"world\")"
+        }
+    ]
+
+    for i, test in enumerate(test_cases, 1):
+        print(f"\n{i}. {test['name']}")
+
+        # 创建测试文件
+        test_file = f"/tmp/test_strategy_{i}.py"
+        await write_file(file_path=test_file, content=test["content"])
+
+        # 执行编辑
+        try:
+            result = await edit_file(
+                file_path=test_file,
+                old_string=test["old"],
+                new_string=test["new"]
+            )
+            print(f"   ✅ 成功匹配")
+        except Exception as e:
+            print(f"   ❌ 失败: {e}")
+
+
+async def main():
+    """运行所有演示"""
+
+    print("\n🚀 工具系统完整演示\n")
+
+    # 基础工具
+    await demo_basic_tools()
+
+    # 编辑策略
+    await demo_edit_strategies()
+
+    # 高级工具
+    await demo_advanced_tools()
+
+    print("\n" + "=" * 60)
+    print("演示完成!")
+    print("=" * 60)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 5 - 0
requirements.txt

@@ -1,3 +1,8 @@
 # LLM request
 httpx[socks]>=0.28.0
 python-dotenv>=1.0.0
+
+# Browser automation CLI
+# 推荐安装方式: uv add browser-use && uv sync
+# 或使用: pip install browser-use
+browser-use>=0.11.0

+ 0 - 0
tools/search.py → tools/__init__.py


+ 1 - 0
vendor/opencode

@@ -0,0 +1 @@
+Subproject commit 0c8de47f7d8ec04400c45d491c45807b6ddacb4a