Parcourir la source

feat: step tree visualization (without test)

Talegorithm il y a 3 mois
Parent
commit
a78494920f

+ 2 - 2
agent/__init__.py

@@ -13,10 +13,10 @@ Reson Agent - 可扩展、可学习的 Agent 框架
 
 
 from agent.runner import AgentRunner, AgentConfig
 from agent.runner import AgentRunner, AgentConfig
 from agent.events import AgentEvent
 from agent.events import AgentEvent
-from agent.models.trace import Trace, Step, StepType
+from agent.trace import Trace, Step, StepType, TraceStore
 from agent.models.memory import Experience, Skill
 from agent.models.memory import Experience, Skill
 from agent.tools import tool, ToolRegistry, get_tool_registry
 from agent.tools import tool, ToolRegistry, get_tool_registry
-from agent.storage.protocols import TraceStore, MemoryStore, StateStore
+from agent.storage.protocols import MemoryStore, StateStore
 
 
 __version__ = "0.1.0"
 __version__ = "0.1.0"
 
 

+ 2 - 2
agent/debug/__init__.py

@@ -4,6 +4,6 @@ Debug 工具模块
 提供 Step 树的实时查看功能,用于开发调试。
 提供 Step 树的实时查看功能,用于开发调试。
 """
 """
 
 
-from .tree_dump import StepTreeDumper, dump_tree
+from .tree_dump import StepTreeDumper, dump_tree, dump_markdown, dump_json
 
 
-__all__ = ["StepTreeDumper", "dump_tree"]
+__all__ = ["StepTreeDumper", "dump_tree", "dump_markdown", "dump_json"]

+ 297 - 0
agent/debug/tree_dump.py

@@ -23,6 +23,7 @@ from typing import Any, Dict, List, Optional
 # 默认输出路径
 # 默认输出路径
 DEFAULT_DUMP_PATH = ".trace/tree.txt"
 DEFAULT_DUMP_PATH = ".trace/tree.txt"
 DEFAULT_JSON_PATH = ".trace/tree.json"
 DEFAULT_JSON_PATH = ".trace/tree.json"
+DEFAULT_MD_PATH = ".trace/tree.md"
 
 
 
 
 class StepTreeDumper:
 class StepTreeDumper:
@@ -228,6 +229,258 @@ class StepTreeDumper:
 
 
         return "\n".join(lines)
         return "\n".join(lines)
 
 
+    def dump_markdown(
+        self,
+        trace: Optional[Dict[str, Any]] = None,
+        steps: Optional[List[Dict[str, Any]]] = None,
+        title: str = "Step Tree Debug",
+        output_path: Optional[str] = None,
+    ) -> str:
+        """
+        输出 Markdown 格式(支持折叠,完整内容)
+
+        Args:
+            trace: Trace 字典(可选)
+            steps: Step 字典列表
+            title: 输出标题
+            output_path: 输出路径(默认 .trace/tree.md)
+
+        Returns:
+            输出的 Markdown 内容
+        """
+        lines = []
+
+        # 标题
+        lines.append(f"# {title}")
+        lines.append("")
+        lines.append(f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
+        lines.append("")
+
+        # Trace 信息
+        if trace:
+            lines.append("## Trace")
+            lines.append("")
+            lines.append(f"- **trace_id**: `{trace.get('trace_id', 'N/A')}`")
+            lines.append(f"- **task**: {trace.get('task', 'N/A')}")
+            lines.append(f"- **status**: {trace.get('status', 'N/A')}")
+            lines.append(f"- **total_steps**: {trace.get('total_steps', 0)}")
+            lines.append(f"- **total_tokens**: {trace.get('total_tokens', 0)}")
+            lines.append(f"- **total_cost**: ${trace.get('total_cost', 0.0):.4f}")
+            lines.append("")
+
+        # Steps
+        if steps:
+            lines.append("## Steps")
+            lines.append("")
+
+            # 构建树并渲染为 Markdown
+            tree = self._build_tree(steps)
+            step_map = {s.get("step_id"): s for s in steps}
+            md_output = self._render_markdown_tree(tree, step_map, level=3)
+            lines.append(md_output)
+
+        content = "\n".join(lines)
+
+        # 写入文件
+        if output_path is None:
+            output_path = str(self.output_path).replace(".txt", ".md")
+
+        Path(output_path).write_text(content, encoding="utf-8")
+        return content
+
+    def _render_markdown_tree(
+        self,
+        tree: Dict[str, List[str]],
+        step_map: Dict[str, Dict[str, Any]],
+        parent_id: str = "__root__",
+        level: int = 3,
+    ) -> str:
+        """递归渲染 Markdown 树"""
+        lines = []
+        child_ids = tree.get(parent_id, [])
+
+        for step_id in child_ids:
+            step = step_map.get(step_id, {})
+
+            # 渲染节点
+            node_md = self._render_markdown_node(step, level)
+            lines.append(node_md)
+
+            # 递归子节点
+            if step_id in tree:
+                child_md = self._render_markdown_tree(tree, step_map, step_id, level + 1)
+                lines.append(child_md)
+
+        return "\n".join(lines)
+
+    def _render_markdown_node(self, step: Dict[str, Any], level: int) -> str:
+        """渲染单个节点的 Markdown"""
+        lines = []
+
+        # 标题
+        status = step.get("status", "unknown")
+        status_icons = {
+            "completed": "✓",
+            "in_progress": "→",
+            "planned": "○",
+            "failed": "✗",
+            "skipped": "⊘",
+        }
+        icon = status_icons.get(status, "?")
+
+        step_type = step.get("step_type", "unknown")
+        description = step.get("description", "")
+        heading = "#" * level
+
+        lines.append(f"{heading} [{icon}] {step_type}: {description}")
+        lines.append("")
+
+        # 基本信息
+        lines.append("**基本信息**")
+        lines.append("")
+        step_id = step.get("step_id", "")[:16]
+        lines.append(f"- **id**: `{step_id}...`")
+
+        if step.get("duration_ms") is not None:
+            lines.append(f"- **duration**: {step.get('duration_ms')}ms")
+        if step.get("tokens") is not None:
+            lines.append(f"- **tokens**: {step.get('tokens')}")
+        if step.get("cost") is not None:
+            lines.append(f"- **cost**: ${step.get('cost'):.4f}")
+
+        created_at = step.get("created_at", "")
+        if created_at:
+            if isinstance(created_at, str):
+                time_part = created_at.split("T")[-1][:8] if "T" in created_at else created_at
+            else:
+                time_part = created_at.strftime("%H:%M:%S")
+            lines.append(f"- **time**: {time_part}")
+
+        lines.append("")
+
+        # Summary
+        if step.get("summary"):
+            lines.append("<details>")
+            lines.append("<summary><b>📝 Summary</b></summary>")
+            lines.append("")
+            lines.append(f"```\n{step.get('summary')}\n```")
+            lines.append("")
+            lines.append("</details>")
+            lines.append("")
+
+        # Data(完整输出,不截断)
+        data = step.get("data", {})
+        if data:
+            lines.append(self._render_markdown_data(data))
+            lines.append("")
+
+        return "\n".join(lines)
+
+    def _render_markdown_data(self, data: Dict[str, Any]) -> str:
+        """渲染 data 字典为可折叠的 Markdown"""
+        lines = []
+
+        # 定义输出顺序(重要的放前面)
+        key_order = ["messages", "tools", "response", "content", "tool_calls", "model"]
+
+        # 先按顺序输出重要的 key
+        remaining_keys = set(data.keys())
+        for key in key_order:
+            if key in data:
+                lines.append(self._render_data_item(key, data[key]))
+                remaining_keys.remove(key)
+
+        # 再输出剩余的 key
+        for key in sorted(remaining_keys):
+            lines.append(self._render_data_item(key, data[key]))
+
+        return "\n".join(lines)
+
+    def _render_data_item(self, key: str, value: Any) -> str:
+        """渲染单个 data 项"""
+        # 确定图标
+        icon_map = {
+            "messages": "📨",
+            "response": "🤖",
+            "tools": "🛠️",
+            "tool_calls": "🔧",
+            "model": "🎯",
+            "error": "❌",
+            "content": "💬",
+        }
+        icon = icon_map.get(key, "📄")
+
+        # 特殊处理:跳过 None 值
+        if value is None:
+            return ""
+
+        # 判断是否需要折叠(长内容或复杂结构)
+        needs_collapse = False
+        if isinstance(value, str):
+            needs_collapse = len(value) > 100 or "\n" in value
+        elif isinstance(value, (dict, list)):
+            needs_collapse = True
+
+        if needs_collapse:
+            lines = []
+            # 可折叠块
+            lines.append("<details>")
+            lines.append(f"<summary><b>{icon} {key.capitalize()}</b></summary>")
+            lines.append("")
+
+            # 格式化内容
+            if isinstance(value, str):
+                # 检查是否包含图片 base64
+                if "data:image" in value or (isinstance(value, str) and len(value) > 10000):
+                    lines.append("```")
+                    lines.append(f"[IMAGE DATA: {len(value)} chars, truncated for display]")
+                    lines.append(value[:200] + "...")
+                    lines.append("```")
+                else:
+                    lines.append("```")
+                    lines.append(value)
+                    lines.append("```")
+            elif isinstance(value, (dict, list)):
+                # 递归截断图片 base64
+                truncated_value = self._truncate_image_data(value)
+                lines.append("```json")
+                lines.append(json.dumps(truncated_value, ensure_ascii=False, indent=2))
+                lines.append("```")
+
+            lines.append("")
+            lines.append("</details>")
+            return "\n".join(lines)
+        else:
+            # 简单值,直接显示
+            return f"- **{icon} {key}**: `{value}`"
+
+    def _truncate_image_data(self, obj: Any, max_length: int = 200) -> Any:
+        """递归截断对象中的图片 base64 数据"""
+        if isinstance(obj, dict):
+            result = {}
+            for key, value in obj.items():
+                # 检测图片 URL(data:image/...;base64,...)
+                if isinstance(value, str) and value.startswith("data:image"):
+                    # 提取 MIME 类型和数据长度
+                    header_end = value.find(",")
+                    if header_end > 0:
+                        header = value[:header_end]
+                        data = value[header_end+1:]
+                        data_size_kb = len(data) / 1024
+                        result[key] = f"<IMAGE_DATA: {data_size_kb:.1f}KB, {header}, preview: {data[:50]}...>"
+                    else:
+                        result[key] = value[:max_length] + f"... ({len(value)} chars)"
+                else:
+                    result[key] = self._truncate_image_data(value, max_length)
+            return result
+        elif isinstance(obj, list):
+            return [self._truncate_image_data(item, max_length) for item in obj]
+        elif isinstance(obj, str) and len(obj) > 100000:
+            # 超长字符串(可能是未检测到的 base64)
+            return obj[:max_length] + f"... (TRUNCATED: {len(obj)} chars total)"
+        else:
+            return obj
+
 
 
 def dump_tree(
 def dump_tree(
     trace: Optional[Any] = None,
     trace: Optional[Any] = None,
@@ -315,3 +568,47 @@ def dump_json(
     path.write_text(content, encoding="utf-8")
     path.write_text(content, encoding="utf-8")
 
 
     return content
     return content
+
+
+def dump_markdown(
+    trace: Optional[Any] = None,
+    steps: Optional[List[Any]] = None,
+    output_path: str = DEFAULT_MD_PATH,
+    title: str = "Step Tree Debug",
+) -> str:
+    """
+    便捷函数:输出 Markdown 格式(支持折叠,完整内容)
+
+    Args:
+        trace: Trace 对象或字典
+        steps: Step 对象或字典列表
+        output_path: 输出文件路径(默认 .trace/tree.md)
+        title: 输出标题
+
+    Returns:
+        输出的 Markdown 内容
+
+    示例:
+        from agent.debug import dump_markdown
+
+        # 输出完整可折叠的 Markdown
+        dump_markdown(trace, steps)
+
+        # 自定义路径
+        dump_markdown(trace, steps, output_path=".debug/debug.md")
+    """
+    # 转换为字典
+    trace_dict = None
+    if trace is not None:
+        trace_dict = trace.to_dict() if hasattr(trace, "to_dict") else trace
+
+    steps_list = []
+    if steps:
+        for step in steps:
+            if hasattr(step, "to_dict"):
+                steps_list.append(step.to_dict())
+            else:
+                steps_list.append(step)
+
+    dumper = StepTreeDumper(output_path)
+    return dumper.dump_markdown(trace_dict, steps_list, title, output_path)

+ 5 - 0
agent/llm/providers/__init__.py

@@ -3,3 +3,8 @@ LLM Providers
 
 
 各个 LLM 提供商的适配器
 各个 LLM 提供商的适配器
 """
 """
+
+from .gemini import create_gemini_llm_call
+from .openrouter import create_openrouter_llm_call
+
+__all__ = ["create_gemini_llm_call", "create_openrouter_llm_call"]

+ 96 - 25
agent/llm/providers/gemini.py

@@ -8,10 +8,60 @@ Gemini Provider (HTTP API)
 
 
 import os
 import os
 import json
 import json
+import sys
 import httpx
 import httpx
 from typing import List, Dict, Any, Optional
 from typing import List, Dict, Any, Optional
 
 
 
 
+def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
+    """
+    Dump完整的LLM请求用于调试(需要设置 AGENT_DEBUG=1)
+
+    特别处理:
+    - 图片base64数据:只显示前50字符 + 长度信息
+    - Tools schema:完整显示
+    - 输出到stderr,避免污染正常输出
+    """
+    if not os.getenv("AGENT_DEBUG"):
+        return
+
+    def truncate_images(obj):
+        """递归处理对象,truncate图片base64数据"""
+        if isinstance(obj, dict):
+            result = {}
+            for key, value in obj.items():
+                # 处理 inline_data 中的 base64 图片
+                if key == "inline_data" and isinstance(value, dict):
+                    mime_type = value.get("mime_type", "unknown")
+                    data = value.get("data", "")
+                    data_size_kb = len(data) / 1024 if data else 0
+                    result[key] = {
+                        "mime_type": mime_type,
+                        "data": f"<BASE64_IMAGE: {data_size_kb:.1f}KB, preview: {data[:50]}...>"
+                    }
+                else:
+                    result[key] = truncate_images(value)
+            return result
+        elif isinstance(obj, list):
+            return [truncate_images(item) for item in obj]
+        else:
+            return obj
+
+    # 构造完整的调试信息
+    debug_info = {
+        "endpoint": endpoint,
+        "model": model,
+        "payload": truncate_images(payload)
+    }
+
+    # 输出到stderr
+    print("\n" + "="*80, file=sys.stderr)
+    print("[AGENT_DEBUG] LLM Request Dump", file=sys.stderr)
+    print("="*80, file=sys.stderr)
+    print(json.dumps(debug_info, indent=2, ensure_ascii=False), file=sys.stderr)
+    print("="*80 + "\n", file=sys.stderr)
+
+
 def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optional[str]]:
 def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optional[str]]:
     """
     """
     将 OpenAI 格式消息转换为 Gemini 格式
     将 OpenAI 格式消息转换为 Gemini 格式
@@ -299,6 +349,9 @@ def create_gemini_llm_call(
             if gemini_tools:
             if gemini_tools:
                 payload["tools"] = gemini_tools
                 payload["tools"] = gemini_tools
 
 
+        # Debug: dump完整请求(需要设置 AGENT_DEBUG=1)
+        _dump_llm_request(endpoint, payload, model)
+
         # 调用 API
         # 调用 API
         try:
         try:
             response = await client.post(endpoint, json=payload)
             response = await client.post(endpoint, json=payload)
@@ -313,37 +366,55 @@ def create_gemini_llm_call(
             print(f"[Gemini HTTP] Request failed: {e}")
             print(f"[Gemini HTTP] Request failed: {e}")
             raise
             raise
 
 
+        # Debug: 输出原始响应(如果启用)
+        if os.getenv("AGENT_DEBUG"):
+            print("\n[AGENT_DEBUG] Gemini Response:", file=sys.stderr)
+            print(json.dumps(gemini_resp, ensure_ascii=False, indent=2)[:2000], file=sys.stderr)
+            print("\n", file=sys.stderr)
+
         # 解析响应
         # 解析响应
         content = ""
         content = ""
         tool_calls = None
         tool_calls = None
 
 
         candidates = gemini_resp.get("candidates", [])
         candidates = gemini_resp.get("candidates", [])
         if candidates:
         if candidates:
-            parts = candidates[0].get("content", {}).get("parts", [])
-
-            # 提取文本
-            for part in parts:
-                if "text" in part:
-                    content += part.get("text", "")
-
-            # 提取 functionCall
-            for i, part in enumerate(parts):
-                if "functionCall" in part:
-                    if tool_calls is None:
-                        tool_calls = []
-
-                    fc = part["functionCall"]
-                    name = fc.get("name", "")
-                    args = fc.get("args", {})
-
-                    tool_calls.append({
-                        "id": f"call_{i}",
-                        "type": "function",
-                        "function": {
-                            "name": name,
-                            "arguments": json.dumps(args, ensure_ascii=False)
-                        }
-                    })
+            candidate = candidates[0]
+
+            # 检查是否有错误
+            finish_reason = candidate.get("finishReason")
+            if finish_reason == "MALFORMED_FUNCTION_CALL":
+                # Gemini 返回了格式错误的函数调用
+                # 提取 finishMessage 中的内容作为 content
+                finish_message = candidate.get("finishMessage", "")
+                print(f"[Gemini HTTP] Warning: MALFORMED_FUNCTION_CALL\n{finish_message}")
+                content = f"[模型尝试调用工具但格式错误]\n\n{finish_message}"
+            else:
+                # 正常解析
+                parts = candidate.get("content", {}).get("parts", [])
+
+                # 提取文本
+                for part in parts:
+                    if "text" in part:
+                        content += part.get("text", "")
+
+                # 提取 functionCall
+                for i, part in enumerate(parts):
+                    if "functionCall" in part:
+                        if tool_calls is None:
+                            tool_calls = []
+
+                        fc = part["functionCall"]
+                        name = fc.get("name", "")
+                        args = fc.get("args", {})
+
+                        tool_calls.append({
+                            "id": f"call_{i}",
+                            "type": "function",
+                            "function": {
+                                "name": name,
+                                "arguments": json.dumps(args, ensure_ascii=False)
+                            }
+                        })
 
 
         # 提取 usage
         # 提取 usage
         usage_meta = gemini_resp.get("usageMetadata", {})
         usage_meta = gemini_resp.get("usageMetadata", {})

+ 130 - 0
agent/llm/providers/openrouter.py

@@ -0,0 +1,130 @@
+"""
+OpenRouter Provider
+
+使用 OpenRouter API 调用各种模型(包括 Claude Sonnet 4.5)
+支持 OpenAI 兼容的 API 格式
+"""
+
+import os
+import json
+import httpx
+from typing import List, Dict, Any, Optional
+
+
+async def openrouter_llm_call(
+    messages: List[Dict[str, Any]],
+    model: str = "anthropic/claude-sonnet-4.5",
+    tools: Optional[List[Dict]] = None,
+    **kwargs
+) -> Dict[str, Any]:
+    """
+    OpenRouter LLM 调用函数
+
+    Args:
+        messages: OpenAI 格式消息列表
+        model: 模型名称(如 "anthropic/claude-sonnet-4.5")
+        tools: OpenAI 格式工具定义
+        **kwargs: 其他参数(temperature, max_tokens 等)
+
+    Returns:
+        {
+            "content": str,
+            "tool_calls": List[Dict] | None,
+            "prompt_tokens": int,
+            "completion_tokens": int,
+            "cost": float
+        }
+    """
+    api_key = os.getenv("OPEN_ROUTER_API_KEY")
+    if not api_key:
+        raise ValueError("OPEN_ROUTER_API_KEY environment variable not set")
+
+    base_url = "https://openrouter.ai/api/v1"
+    endpoint = f"{base_url}/chat/completions"
+
+    # 构建请求
+    payload = {
+        "model": model,
+        "messages": messages,
+    }
+
+    # 添加可选参数
+    if tools:
+        payload["tools"] = tools
+
+    if "temperature" in kwargs:
+        payload["temperature"] = kwargs["temperature"]
+    if "max_tokens" in kwargs:
+        payload["max_tokens"] = kwargs["max_tokens"]
+
+    # OpenRouter 特定参数
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "HTTP-Referer": "https://github.com/your-repo",  # 可选,用于统计
+        "X-Title": "Agent Framework",  # 可选,显示在 OpenRouter dashboard
+    }
+
+    # 调用 API
+    async with httpx.AsyncClient(timeout=120.0) as client:
+        try:
+            response = await client.post(endpoint, json=payload, headers=headers)
+            response.raise_for_status()
+            result = response.json()
+
+        except httpx.HTTPStatusError as e:
+            error_body = e.response.text
+            print(f"[OpenRouter] Error {e.response.status_code}: {error_body}")
+            raise
+        except Exception as e:
+            print(f"[OpenRouter] Request failed: {e}")
+            raise
+
+    # 解析响应(OpenAI 格式)
+    choice = result["choices"][0] if result.get("choices") else {}
+    message = choice.get("message", {})
+
+    content = message.get("content", "")
+    tool_calls = message.get("tool_calls")
+
+    # 提取 usage
+    usage = result.get("usage", {})
+    prompt_tokens = usage.get("prompt_tokens", 0)
+    completion_tokens = usage.get("completion_tokens", 0)
+
+    # 计算成本(OpenRouter 通常在响应中提供,但这里简化为 0)
+    cost = 0.0
+
+    return {
+        "content": content,
+        "tool_calls": tool_calls,
+        "prompt_tokens": prompt_tokens,
+        "completion_tokens": completion_tokens,
+        "cost": cost
+    }
+
+
+def create_openrouter_llm_call(
+    model: str = "anthropic/claude-sonnet-4.5"
+):
+    """
+    创建 OpenRouter LLM 调用函数
+
+    Args:
+        model: 模型名称
+            - "anthropic/claude-sonnet-4.5"
+            - "anthropic/claude-opus-4.5"
+            - "openai/gpt-4o"
+            等等
+
+    Returns:
+        异步 LLM 调用函数
+    """
+    async def llm_call(
+        messages: List[Dict[str, Any]],
+        model: str = model,
+        tools: Optional[List[Dict]] = None,
+        **kwargs
+    ) -> Dict[str, Any]:
+        return await openrouter_llm_call(messages, model, tools, **kwargs)
+
+    return llm_call

+ 4 - 3
agent/models/__init__.py

@@ -1,8 +1,9 @@
 """
 """
-Models 包
+Models 包 - 记忆相关模型
+
+Trace/Step 模型已移动到 agent.trace 模块
 """
 """
 
 
-from agent.models.trace import Trace, Step, StepType
 from agent.models.memory import Experience, Skill
 from agent.models.memory import Experience, Skill
 
 
-__all__ = ["Trace", "Step", "StepType", "Experience", "Skill"]
+__all__ = ["Experience", "Skill"]

+ 14 - 1
agent/models/memory.py

@@ -102,6 +102,7 @@ class Skill:
     parent_id: Optional[str] = None
     parent_id: Optional[str] = None
 
 
     # 内容
     # 内容
+    content: Optional[str] = None  # 完整的 skill 内容(Markdown)
     guidelines: List[str] = field(default_factory=list)
     guidelines: List[str] = field(default_factory=list)
     derived_from: List[str] = field(default_factory=list)  # experience_ids
     derived_from: List[str] = field(default_factory=list)  # experience_ids
 
 
@@ -119,6 +120,7 @@ class Skill:
         name: str,
         name: str,
         description: str,
         description: str,
         category: str = "general",
         category: str = "general",
+        content: Optional[str] = None,
         guidelines: List[str] = None,
         guidelines: List[str] = None,
         derived_from: List[str] = None,
         derived_from: List[str] = None,
         parent_id: Optional[str] = None,
         parent_id: Optional[str] = None,
@@ -132,6 +134,7 @@ class Skill:
             description=description,
             description=description,
             category=category,
             category=category,
             parent_id=parent_id,
             parent_id=parent_id,
+            content=content,
             guidelines=guidelines or [],
             guidelines=guidelines or [],
             derived_from=derived_from or [],
             derived_from=derived_from or [],
             created_at=now,
             created_at=now,
@@ -147,6 +150,7 @@ class Skill:
             "description": self.description,
             "description": self.description,
             "category": self.category,
             "category": self.category,
             "parent_id": self.parent_id,
             "parent_id": self.parent_id,
+            "content": self.content,
             "guidelines": self.guidelines,
             "guidelines": self.guidelines,
             "derived_from": self.derived_from,
             "derived_from": self.derived_from,
             "version": self.version,
             "version": self.version,
@@ -155,7 +159,16 @@ class Skill:
         }
         }
 
 
     def to_prompt_text(self) -> str:
     def to_prompt_text(self) -> str:
-        """转换为可注入 Prompt 的文本"""
+        """
+        转换为可注入 Prompt 的文本
+
+        优先使用完整的 content(如果有),否则使用 description + guidelines
+        """
+        # 如果有完整的 content,直接使用
+        if self.content:
+            return self.content.strip()
+
+        # 否则使用旧的格式(向后兼容)
         lines = [f"### {self.name}", self.description]
         lines = [f"### {self.name}", self.description]
         if self.guidelines:
         if self.guidelines:
             lines.append("指导原则:")
             lines.append("指导原则:")

+ 63 - 11
agent/runner.py

@@ -14,16 +14,29 @@ from datetime import datetime
 from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal
 from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal
 
 
 from agent.events import AgentEvent
 from agent.events import AgentEvent
-from agent.models.trace import Trace, Step
+from agent.trace import Trace, Step, TraceStore
 from agent.models.memory import Experience, Skill
 from agent.models.memory import Experience, Skill
-from agent.storage.protocols import TraceStore, MemoryStore, StateStore
+from agent.storage.protocols import MemoryStore, StateStore
 from agent.storage.skill_loader import load_skills_from_dir
 from agent.storage.skill_loader import load_skills_from_dir
 from agent.tools import ToolRegistry, get_tool_registry
 from agent.tools import ToolRegistry, get_tool_registry
-from agent.debug import dump_tree
+from agent.debug import dump_tree, dump_markdown
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+# 内置工具列表(始终自动加载)
+BUILTIN_TOOLS = [
+    "read_file",
+    "edit_file",
+    "write_file",
+    "glob_files",
+    "grep_content",
+    "bash_command",
+    "skill",
+    "list_skills",
+]
+
+
 @dataclass
 @dataclass
 class AgentConfig:
 class AgentConfig:
     """Agent 配置"""
     """Agent 配置"""
@@ -61,6 +74,7 @@ class AgentRunner:
         tool_registry: Optional[ToolRegistry] = None,
         tool_registry: Optional[ToolRegistry] = None,
         llm_call: Optional[Callable] = None,
         llm_call: Optional[Callable] = None,
         config: Optional[AgentConfig] = None,
         config: Optional[AgentConfig] = None,
+        skills_dir: Optional[str] = None,
         debug: bool = False,
         debug: bool = False,
     ):
     ):
         """
         """
@@ -73,6 +87,7 @@ class AgentRunner:
             tool_registry: 工具注册表(可选,默认使用全局注册表)
             tool_registry: 工具注册表(可选,默认使用全局注册表)
             llm_call: LLM 调用函数(必须提供,用于实际调用 LLM)
             llm_call: LLM 调用函数(必须提供,用于实际调用 LLM)
             config: Agent 配置
             config: Agent 配置
+            skills_dir: Skills 目录路径(可选,不提供则不加载 skills)
             debug: 是否启用 debug 模式(输出 step tree 到 .trace/tree.txt)
             debug: 是否启用 debug 模式(输出 step tree 到 .trace/tree.txt)
         """
         """
         self.trace_store = trace_store
         self.trace_store = trace_store
@@ -81,6 +96,7 @@ class AgentRunner:
         self.tools = tool_registry or get_tool_registry()
         self.tools = tool_registry or get_tool_registry()
         self.llm_call = llm_call
         self.llm_call = llm_call
         self.config = config or AgentConfig()
         self.config = config or AgentConfig()
+        self.skills_dir = skills_dir
         self.debug = debug
         self.debug = debug
 
 
     def _generate_id(self) -> str:
     def _generate_id(self) -> str:
@@ -89,13 +105,18 @@ class AgentRunner:
         return str(uuid.uuid4())
         return str(uuid.uuid4())
 
 
     async def _dump_debug(self, trace_id: str) -> None:
     async def _dump_debug(self, trace_id: str) -> None:
-        """Debug 模式下输出 step tree"""
+        """Debug 模式下输出 step tree(txt + markdown 两种格式)"""
         if not self.debug or not self.trace_store:
         if not self.debug or not self.trace_store:
             return
             return
         trace = await self.trace_store.get_trace(trace_id)
         trace = await self.trace_store.get_trace(trace_id)
         steps = await self.trace_store.get_trace_steps(trace_id)
         steps = await self.trace_store.get_trace_steps(trace_id)
+
+        # 输出 tree.txt(简洁格式,兼容旧版)
         dump_tree(trace, steps)
         dump_tree(trace, steps)
 
 
+        # 输出 tree.md(完整可折叠格式)
+        dump_markdown(trace, steps)
+
     # ===== 单次调用 =====
     # ===== 单次调用 =====
 
 
     async def call(
     async def call(
@@ -137,9 +158,15 @@ class AgentRunner:
             trace_id = await self.trace_store.create_trace(trace_obj)
             trace_id = await self.trace_store.create_trace(trace_obj)
 
 
         # 准备工具 Schema
         # 准备工具 Schema
-        tool_schemas = None
+        # 合并内置工具 + 用户指定工具
+        tool_names = BUILTIN_TOOLS.copy()
         if tools:
         if tools:
-            tool_schemas = self.tools.get_schemas(tools)
+            # 添加用户指定的工具(去重)
+            for tool in tools:
+                if tool not in tool_names:
+                    tool_names.append(tool)
+
+        tool_schemas = self.tools.get_schemas(tool_names)
 
 
         # 调用 LLM
         # 调用 LLM
         result = await self.llm_call(
         result = await self.llm_call(
@@ -161,6 +188,7 @@ class AgentRunner:
                     "messages": messages,
                     "messages": messages,
                     "response": result.get("content", ""),
                     "response": result.get("content", ""),
                     "model": model,
                     "model": model,
+                    "tools": tool_schemas,  # 记录传给模型的 tools schema
                     "tool_calls": result.get("tool_calls"),
                     "tool_calls": result.get("tool_calls"),
                 },
                 },
                 tokens=result.get("prompt_tokens", 0) + result.get("completion_tokens", 0),
                 tokens=result.get("prompt_tokens", 0) + result.get("completion_tokens", 0),
@@ -254,8 +282,9 @@ class AgentRunner:
         })
         })
 
 
         try:
         try:
-            # 加载记忆(Experience)
+            # 加载记忆(Experience 和 Skill
             experiences_text = ""
             experiences_text = ""
+            skills_text = ""
 
 
             if enable_memory and self.memory_store:
             if enable_memory and self.memory_store:
                 scope = f"agent:{agent_type}"
                 scope = f"agent:{agent_type}"
@@ -282,13 +311,22 @@ class AgentRunner:
                     "experiences_count": len(experiences)
                     "experiences_count": len(experiences)
                 })
                 })
 
 
+            # 加载 Skills(如果提供了 skills_dir)
+            if self.skills_dir:
+                skills = load_skills_from_dir(self.skills_dir)
+                if skills:
+                    skills_text = self._format_skills(skills)
+                    logger.info(f"加载 {len(skills)} 个 skills 从 {self.skills_dir}")
+
             # 构建初始消息
             # 构建初始消息
             if messages is None:
             if messages is None:
                 messages = []
                 messages = []
 
 
             if system_prompt:
             if system_prompt:
-                # 注入记忆到 system prompt
+                # 注入记忆和 skills 到 system prompt
                 full_system = system_prompt
                 full_system = system_prompt
+                if skills_text:
+                    full_system += f"\n\n## Skills\n{skills_text}"
                 if experiences_text:
                 if experiences_text:
                     full_system += f"\n\n## 相关经验\n{experiences_text}"
                     full_system += f"\n\n## 相关经验\n{experiences_text}"
 
 
@@ -297,10 +335,16 @@ class AgentRunner:
             # 添加任务描述
             # 添加任务描述
             messages.append({"role": "user", "content": task})
             messages.append({"role": "user", "content": task})
 
 
-            # 准备工具
-            tool_schemas = None
+            # 准备工具 Schema
+            # 合并内置工具 + 用户指定工具
+            tool_names = BUILTIN_TOOLS.copy()
             if tools:
             if tools:
-                tool_schemas = self.tools.get_schemas(tools)
+                # 添加用户指定的工具(去重)
+                for tool in tools:
+                    if tool not in tool_names:
+                        tool_names.append(tool)
+
+            tool_schemas = self.tools.get_schemas(tool_names)
 
 
             # 执行循环
             # 执行循环
             current_goal_id = None  # 当前焦点 goal
             current_goal_id = None  # 当前焦点 goal
@@ -349,8 +393,10 @@ class AgentRunner:
                         parent_id=current_goal_id,
                         parent_id=current_goal_id,
                         description=response_content[:100] + "..." if len(response_content) > 100 else response_content,
                         description=response_content[:100] + "..." if len(response_content) > 100 else response_content,
                         data={
                         data={
+                            "messages": messages,  # 记录完整的 messages(包含 system prompt)
                             "content": response_content,
                             "content": response_content,
                             "model": model,
                             "model": model,
+                            "tools": tool_schemas,  # 记录传给模型的 tools schema
                             "tool_calls": tool_calls,
                             "tool_calls": tool_calls,
                         },
                         },
                         tokens=step_tokens,
                         tokens=step_tokens,
@@ -617,3 +663,9 @@ class AgentRunner:
         if not experiences:
         if not experiences:
             return ""
             return ""
         return "\n".join(f"- {e.to_prompt_text()}" for e in experiences)
         return "\n".join(f"- {e.to_prompt_text()}" for e in experiences)
+
+    def _format_skills(self, skills: List[Skill]) -> str:
+        """格式化 Skills 为 Prompt 文本"""
+        if not skills:
+            return ""
+        return "\n\n".join(s.to_prompt_text() for s in skills)

+ 4 - 4
agent/storage/__init__.py

@@ -1,15 +1,15 @@
 """
 """
 Storage 包 - 存储接口和实现
 Storage 包 - 存储接口和实现
+
+TraceStore 和 MemoryTraceStore 已移动到 agent.trace 模块
 """
 """
 
 
-from agent.storage.protocols import TraceStore, MemoryStore, StateStore
-from agent.storage.memory_impl import MemoryTraceStore, MemoryMemoryStore, MemoryStateStore
+from agent.storage.protocols import MemoryStore, StateStore
+from agent.storage.memory_impl import MemoryMemoryStore, MemoryStateStore
 
 
 __all__ = [
 __all__ = [
-    "TraceStore",
     "MemoryStore",
     "MemoryStore",
     "StateStore",
     "StateStore",
-    "MemoryTraceStore",
     "MemoryMemoryStore",
     "MemoryMemoryStore",
     "MemoryStateStore",
     "MemoryStateStore",
 ]
 ]

+ 2 - 81
agent/storage/memory_impl.py

@@ -2,95 +2,16 @@
 Memory Implementation - 内存存储实现
 Memory Implementation - 内存存储实现
 
 
 用于测试和简单场景,数据不持久化
 用于测试和简单场景,数据不持久化
+
+MemoryTraceStore 已移动到 agent.trace.memory_store
 """
 """
 
 
 from typing import Dict, List, Optional, Any
 from typing import Dict, List, Optional, Any
 from datetime import datetime
 from datetime import datetime
 
 
-from agent.models.trace import Trace, Step
 from agent.models.memory import Experience, Skill
 from agent.models.memory import Experience, Skill
 
 
 
 
-class MemoryTraceStore:
-    """内存 Trace 存储"""
-
-    def __init__(self):
-        self._traces: Dict[str, Trace] = {}
-        self._steps: Dict[str, Step] = {}
-        self._trace_steps: Dict[str, List[str]] = {}  # trace_id -> [step_ids]
-
-    async def create_trace(self, trace: Trace) -> str:
-        self._traces[trace.trace_id] = trace
-        self._trace_steps[trace.trace_id] = []
-        return trace.trace_id
-
-    async def get_trace(self, trace_id: str) -> Optional[Trace]:
-        return self._traces.get(trace_id)
-
-    async def update_trace(self, trace_id: str, **updates) -> None:
-        trace = self._traces.get(trace_id)
-        if trace:
-            for key, value in updates.items():
-                if hasattr(trace, key):
-                    setattr(trace, key, value)
-
-    async def list_traces(
-        self,
-        mode: Optional[str] = None,
-        agent_type: Optional[str] = None,
-        uid: Optional[str] = None,
-        status: Optional[str] = None,
-        limit: int = 50
-    ) -> List[Trace]:
-        traces = list(self._traces.values())
-
-        # 过滤
-        if mode:
-            traces = [t for t in traces if t.mode == mode]
-        if agent_type:
-            traces = [t for t in traces if t.agent_type == agent_type]
-        if uid:
-            traces = [t for t in traces if t.uid == uid]
-        if status:
-            traces = [t for t in traces if t.status == status]
-
-        # 排序(最新的在前)
-        traces.sort(key=lambda t: t.created_at, reverse=True)
-
-        return traces[:limit]
-
-    async def add_step(self, step: Step) -> str:
-        self._steps[step.step_id] = step
-
-        # 添加到 trace 的 steps 列表
-        if step.trace_id in self._trace_steps:
-            self._trace_steps[step.trace_id].append(step.step_id)
-
-        # 更新 trace 的 total_steps
-        trace = self._traces.get(step.trace_id)
-        if trace:
-            trace.total_steps += 1
-
-        return step.step_id
-
-    async def get_step(self, step_id: str) -> Optional[Step]:
-        return self._steps.get(step_id)
-
-    async def get_trace_steps(self, trace_id: str) -> List[Step]:
-        step_ids = self._trace_steps.get(trace_id, [])
-        steps = [self._steps[sid] for sid in step_ids if sid in self._steps]
-        steps.sort(key=lambda s: s.sequence)
-        return steps
-
-    async def get_step_children(self, step_id: str) -> List[Step]:
-        children = []
-        for step in self._steps.values():
-            if step.parent_id == step_id:
-                children.append(step)
-        children.sort(key=lambda s: s.sequence)
-        return children
-
-
 class MemoryMemoryStore:
 class MemoryMemoryStore:
     """内存 Memory 存储(Experience + Skill)"""
     """内存 Memory 存储(Experience + Skill)"""
 
 

+ 2 - 71
agent/storage/protocols.py

@@ -2,84 +2,15 @@
 Storage Protocols - 存储接口定义
 Storage Protocols - 存储接口定义
 
 
 使用 Protocol 定义接口,允许不同的存储实现(内存、PostgreSQL、Neo4j 等)
 使用 Protocol 定义接口,允许不同的存储实现(内存、PostgreSQL、Neo4j 等)
+
+TraceStore 已移动到 agent.trace.protocols
 """
 """
 
 
 from typing import Protocol, List, Optional, Dict, Any, runtime_checkable
 from typing import Protocol, List, Optional, Dict, Any, runtime_checkable
 
 
-from agent.models.trace import Trace, Step
 from agent.models.memory import Experience, Skill
 from agent.models.memory import Experience, Skill
 
 
 
 
-@runtime_checkable
-class TraceStore(Protocol):
-    """Trace + Step 存储接口"""
-
-    # ===== Trace 操作 =====
-
-    async def create_trace(self, trace: Trace) -> str:
-        """
-        创建新的 Trace
-
-        Args:
-            trace: Trace 对象
-
-        Returns:
-            trace_id
-        """
-        ...
-
-    async def get_trace(self, trace_id: str) -> Optional[Trace]:
-        """获取 Trace"""
-        ...
-
-    async def update_trace(self, trace_id: str, **updates) -> None:
-        """
-        更新 Trace
-
-        Args:
-            trace_id: Trace ID
-            **updates: 要更新的字段
-        """
-        ...
-
-    async def list_traces(
-        self,
-        mode: Optional[str] = None,
-        agent_type: Optional[str] = None,
-        uid: Optional[str] = None,
-        status: Optional[str] = None,
-        limit: int = 50
-    ) -> List[Trace]:
-        """列出 Traces"""
-        ...
-
-    # ===== Step 操作 =====
-
-    async def add_step(self, step: Step) -> str:
-        """
-        添加 Step
-
-        Args:
-            step: Step 对象
-
-        Returns:
-            step_id
-        """
-        ...
-
-    async def get_step(self, step_id: str) -> Optional[Step]:
-        """获取 Step"""
-        ...
-
-    async def get_trace_steps(self, trace_id: str) -> List[Step]:
-        """获取 Trace 的所有 Steps(按 sequence 排序)"""
-        ...
-
-    async def get_step_children(self, step_id: str) -> List[Step]:
-        """获取 Step 的子节点"""
-        ...
-
-
 @runtime_checkable
 @runtime_checkable
 class MemoryStore(Protocol):
 class MemoryStore(Protocol):
     """Experience + Skill 存储接口"""
     """Experience + Skill 存储接口"""

+ 25 - 0
agent/storage/skill_loader.py

@@ -200,12 +200,16 @@ class SkillLoader:
         # 提取 Guidelines
         # 提取 Guidelines
         guidelines = self._extract_list_items(remaining_lines, "Guidelines")
         guidelines = self._extract_list_items(remaining_lines, "Guidelines")
 
 
+        # 保存完整的内容(去掉 frontmatter)
+        content = remaining_content.strip()
+
         # 创建 Skill
         # 创建 Skill
         return Skill.create(
         return Skill.create(
             scope=scope,
             scope=scope,
             name=name,
             name=name,
             description=description.strip(),
             description=description.strip(),
             category=category,
             category=category,
+            content=content,  # 完整的 Markdown 内容
             guidelines=guidelines,
             guidelines=guidelines,
             parent_id=parent_id,
             parent_id=parent_id,
         )
         )
@@ -242,12 +246,33 @@ class SkillLoader:
         # 提取指导原则
         # 提取指导原则
         guidelines = self._extract_list_items(lines, "Guidelines")
         guidelines = self._extract_list_items(lines, "Guidelines")
 
 
+        # 提取完整内容(去掉元数据行和标题行)
+        content_lines = []
+        skip_metadata = False
+        for line in lines:
+            stripped = line.strip()
+            # 跳过标题
+            if stripped.startswith("# "):
+                continue
+            # 跳过元数据
+            if stripped.startswith(">"):
+                skip_metadata = True
+                continue
+            # 如果之前是元数据,跳过后续的空行
+            if skip_metadata and not stripped:
+                skip_metadata = False
+                continue
+            content_lines.append(line)
+
+        content = "\n".join(content_lines).strip()
+
         # 创建 Skill
         # 创建 Skill
         return Skill.create(
         return Skill.create(
             scope=scope,
             scope=scope,
             name=name,
             name=name,
             description=description.strip(),
             description=description.strip(),
             category=category,
             category=category,
+            content=content,  # 完整的 Markdown 内容
             guidelines=guidelines,
             guidelines=guidelines,
             parent_id=parent_id,
             parent_id=parent_id,
         )
         )

+ 3 - 0
agent/tools/__init__.py

@@ -6,6 +6,9 @@ from agent.tools.registry import ToolRegistry, tool, get_tool_registry
 from agent.tools.schema import SchemaGenerator
 from agent.tools.schema import SchemaGenerator
 from agent.tools.models import ToolResult, ToolContext, ToolContextImpl
 from agent.tools.models import ToolResult, ToolContext, ToolContextImpl
 
 
+# 导入 builtin 工具以触发 @tool 装饰器注册
+# noqa: F401 表示这是故意的副作用导入
+import agent.tools.builtin  # noqa: F401
 
 
 __all__ = [
 __all__ = [
 	"ToolRegistry",
 	"ToolRegistry",

+ 66 - 0
agent/trace/__init__.py

@@ -0,0 +1,66 @@
+"""
+Trace 模块 - Context 管理 + 可视化
+
+核心职责:
+1. Trace/Step 模型定义
+2. 存储接口和实现(内存/数据库)
+3. RESTful API(可视化查询)
+4. WebSocket 推送(实时更新)
+"""
+
+# 模型(核心,无依赖)
+from agent.trace.models import Trace, Step, StepType, Status
+
+# 存储接口(核心,无依赖)
+from agent.trace.protocols import TraceStore
+
+# 内存存储实现(核心,无依赖)
+from agent.trace.memory_store import MemoryTraceStore
+
+
+# API 路由(可选,需要 FastAPI)
+def _get_api_router():
+    """延迟导入 API Router(避免强制依赖 FastAPI)"""
+    from agent.trace.api import router
+    return router
+
+
+def _get_ws_router():
+    """延迟导入 WebSocket Router(避免强制依赖 FastAPI)"""
+    from agent.trace.websocket import router
+    return router
+
+
+# WebSocket 广播函数(可选,需要 FastAPI)
+def _get_broadcast_functions():
+    """延迟导入 WebSocket 广播函数"""
+    from agent.trace.websocket import (
+        broadcast_step_added,
+        broadcast_step_updated,
+        broadcast_trace_completed,
+    )
+    return broadcast_step_added, broadcast_step_updated, broadcast_trace_completed
+
+
+# 便捷属性(仅在访问时导入)
+@property
+def api_router():
+    return _get_api_router()
+
+
+@property
+def ws_router():
+    return _get_ws_router()
+
+
+__all__ = [
+    # 模型
+    "Trace",
+    "Step",
+    "StepType",
+    "Status",
+    # 存储
+    "TraceStore",
+    "MemoryTraceStore",
+]
+

+ 275 - 0
agent/trace/api.py

@@ -0,0 +1,275 @@
+"""
+Step 树 RESTful API
+
+提供 Trace 和 Step 的查询接口,支持懒加载
+"""
+
+from typing import List, Optional, Dict, Any
+from fastapi import APIRouter, HTTPException, Query
+from pydantic import BaseModel
+
+from agent.trace.protocols import TraceStore
+
+
+router = APIRouter(prefix="/api/traces", tags=["traces"])
+
+
+# ===== Response 模型 =====
+
+
+class TraceListResponse(BaseModel):
+    """Trace 列表响应"""
+    traces: List[Dict[str, Any]]
+
+
+class TraceResponse(BaseModel):
+    """Trace 元数据响应"""
+    trace_id: str
+    mode: str
+    task: Optional[str] = None
+    agent_type: Optional[str] = None
+    status: str
+    total_steps: int
+    total_tokens: int
+    total_cost: float
+    created_at: str
+    completed_at: Optional[str] = None
+
+
+class StepNode(BaseModel):
+    """Step 节点(递归结构)"""
+    step_id: str
+    step_type: str
+    status: str
+    description: str
+    sequence: int
+    parent_id: Optional[str] = None
+    data: Optional[Dict[str, Any]] = None
+    summary: Optional[str] = None
+    duration_ms: Optional[int] = None
+    tokens: Optional[int] = None
+    cost: Optional[float] = None
+    created_at: str
+    children: List["StepNode"] = []
+
+
+class TreeResponse(BaseModel):
+    """完整树响应"""
+    trace_id: str
+    root_steps: List[StepNode]
+
+
+class NodeResponse(BaseModel):
+    """节点响应"""
+    step_id: Optional[str]
+    step_type: Optional[str]
+    description: Optional[str]
+    children: List[StepNode]
+
+
+# ===== 全局 TraceStore(由 api_server.py 注入)=====
+
+
+_trace_store: Optional[TraceStore] = None
+
+
+def set_trace_store(store: TraceStore):
+    """设置 TraceStore 实例"""
+    global _trace_store
+    _trace_store = store
+
+
+def get_trace_store() -> TraceStore:
+    """获取 TraceStore 实例"""
+    if _trace_store is None:
+        raise RuntimeError("TraceStore not initialized")
+    return _trace_store
+
+
+# ===== 路由 =====
+
+
+@router.get("", response_model=TraceListResponse)
+async def list_traces(
+    mode: Optional[str] = None,
+    agent_type: Optional[str] = None,
+    uid: Optional[str] = None,
+    status: Optional[str] = None,
+    limit: int = Query(20, le=100)
+):
+    """
+    列出 Traces
+
+    Args:
+        mode: 模式过滤(call/agent)
+        agent_type: Agent 类型过滤
+        uid: 用户 ID 过滤
+        status: 状态过滤(running/completed/failed)
+        limit: 最大返回数量
+    """
+    store = get_trace_store()
+    traces = await store.list_traces(
+        mode=mode,
+        agent_type=agent_type,
+        uid=uid,
+        status=status,
+        limit=limit
+    )
+    return TraceListResponse(
+        traces=[t.to_dict() for t in traces]
+    )
+
+
+@router.get("/{trace_id}", response_model=TraceResponse)
+async def get_trace(trace_id: str):
+    """
+    获取 Trace 元数据
+
+    Args:
+        trace_id: Trace ID
+    """
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    return TraceResponse(**trace.to_dict())
+
+
+@router.get("/{trace_id}/tree", response_model=TreeResponse)
+async def get_full_tree(trace_id: str):
+    """
+    获取完整 Step 树(小型 Trace 推荐)
+
+    Args:
+        trace_id: Trace ID
+    """
+    store = get_trace_store()
+
+    # 验证 Trace 存在
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    # 获取所有 Steps
+    steps = await store.get_trace_steps(trace_id)
+
+    # 构建树结构
+    root_nodes = await _build_tree(store, trace_id, None, expand=True, max_depth=999)
+
+    return TreeResponse(
+        trace_id=trace_id,
+        root_steps=root_nodes
+    )
+
+
+@router.get("/{trace_id}/node/{step_id}", response_model=NodeResponse)
+async def get_node(
+    trace_id: str,
+    step_id: str,
+    expand: bool = Query(False, description="是否加载子节点"),
+    max_depth: int = Query(1, ge=1, le=10, description="递归深度")
+):
+    """
+    懒加载节点 + 子节点(大型 Trace 推荐)
+
+    Args:
+        trace_id: Trace ID
+        step_id: Step ID("null" 表示根节点)
+        expand: 是否加载子节点
+        max_depth: 递归深度
+    """
+    store = get_trace_store()
+
+    # 验证 Trace 存在
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    # step_id = "null" 表示根节点
+    actual_step_id = None if step_id == "null" else step_id
+
+    # 验证 Step 存在(非根节点)
+    if actual_step_id:
+        step = await store.get_step(actual_step_id)
+        if not step or step.trace_id != trace_id:
+            raise HTTPException(status_code=404, detail="Step not found")
+
+    # 构建节点树
+    children = await _build_tree(store, trace_id, actual_step_id, expand, max_depth)
+
+    # 如果是根节点,返回所有根 Steps
+    if actual_step_id is None:
+        return NodeResponse(
+            step_id=None,
+            step_type=None,
+            description=None,
+            children=children
+        )
+
+    # 否则返回当前节点 + 子节点
+    step = await store.get_step(actual_step_id)
+    return NodeResponse(
+        step_id=step.step_id,
+        step_type=step.step_type,
+        description=step.description,
+        children=children
+    )
+
+
+# ===== 核心算法:懒加载树构建 =====
+
+
+async def _build_tree(
+    store: TraceStore,
+    trace_id: str,
+    step_id: Optional[str],
+    expand: bool = False,
+    max_depth: int = 1,
+    current_depth: int = 0
+) -> List[StepNode]:
+    """
+    懒加载核心逻辑(简洁版本)
+
+    没有"批次计算"、没有"同层完整性检查"
+    只有简单的递归遍历
+
+    Args:
+        store: TraceStore 实例
+        trace_id: Trace ID
+        step_id: 当前 Step ID(None 表示根节点)
+        expand: 是否展开子节点
+        max_depth: 最大递归深度
+        current_depth: 当前递归深度
+
+    Returns:
+        List[StepNode]: 节点列表
+    """
+    # 1. 获取当前层节点
+    if step_id is None:
+        # 根节点:获取所有 parent_id=None 的 Steps
+        steps = await store.get_trace_steps(trace_id)
+        current_nodes = [s for s in steps if s.parent_id is None]
+    else:
+        # 非根节点:获取子节点
+        current_nodes = await store.get_step_children(step_id)
+
+    # 2. 构建响应
+    result_nodes = []
+    for step in current_nodes:
+        node_dict = step.to_dict()
+        node_dict["children"] = []
+
+        # 3. 递归加载子节点(可选)
+        if expand and current_depth < max_depth:
+            children = await store.get_step_children(step.step_id)
+            if children:
+                node_dict["children"] = await _build_tree(
+                    store, trace_id, step.step_id,
+                    expand=True, max_depth=max_depth,
+                    current_depth=current_depth + 1
+                )
+
+        result_nodes.append(StepNode(**node_dict))
+
+    return result_nodes

+ 89 - 0
agent/trace/memory_store.py

@@ -0,0 +1,89 @@
+"""
+Memory Trace Store - 内存存储实现
+
+用于测试和简单场景,数据不持久化
+"""
+
+from typing import Dict, List, Optional
+
+from agent.trace.models import Trace, Step
+
+
+class MemoryTraceStore:
+    """内存 Trace 存储"""
+
+    def __init__(self):
+        self._traces: Dict[str, Trace] = {}
+        self._steps: Dict[str, Step] = {}
+        self._trace_steps: Dict[str, List[str]] = {}  # trace_id -> [step_ids]
+
+    async def create_trace(self, trace: Trace) -> str:
+        self._traces[trace.trace_id] = trace
+        self._trace_steps[trace.trace_id] = []
+        return trace.trace_id
+
+    async def get_trace(self, trace_id: str) -> Optional[Trace]:
+        return self._traces.get(trace_id)
+
+    async def update_trace(self, trace_id: str, **updates) -> None:
+        trace = self._traces.get(trace_id)
+        if trace:
+            for key, value in updates.items():
+                if hasattr(trace, key):
+                    setattr(trace, key, value)
+
+    async def list_traces(
+        self,
+        mode: Optional[str] = None,
+        agent_type: Optional[str] = None,
+        uid: Optional[str] = None,
+        status: Optional[str] = None,
+        limit: int = 50
+    ) -> List[Trace]:
+        traces = list(self._traces.values())
+
+        # 过滤
+        if mode:
+            traces = [t for t in traces if t.mode == mode]
+        if agent_type:
+            traces = [t for t in traces if t.agent_type == agent_type]
+        if uid:
+            traces = [t for t in traces if t.uid == uid]
+        if status:
+            traces = [t for t in traces if t.status == status]
+
+        # 排序(最新的在前)
+        traces.sort(key=lambda t: t.created_at, reverse=True)
+
+        return traces[:limit]
+
+    async def add_step(self, step: Step) -> str:
+        self._steps[step.step_id] = step
+
+        # 添加到 trace 的 steps 列表
+        if step.trace_id in self._trace_steps:
+            self._trace_steps[step.trace_id].append(step.step_id)
+
+        # 更新 trace 的 total_steps
+        trace = self._traces.get(step.trace_id)
+        if trace:
+            trace.total_steps += 1
+
+        return step.step_id
+
+    async def get_step(self, step_id: str) -> Optional[Step]:
+        return self._steps.get(step_id)
+
+    async def get_trace_steps(self, trace_id: str) -> List[Step]:
+        step_ids = self._trace_steps.get(trace_id, [])
+        steps = [self._steps[sid] for sid in step_ids if sid in self._steps]
+        steps.sort(key=lambda s: s.sequence)
+        return steps
+
+    async def get_step_children(self, step_id: str) -> List[Step]:
+        children = []
+        for step in self._steps.values():
+            if step.parent_id == step_id:
+                children.append(step)
+        children.sort(key=lambda s: s.sequence)
+        return children

+ 0 - 0
agent/models/trace.py → agent/trace/models.py


+ 79 - 0
agent/trace/protocols.py

@@ -0,0 +1,79 @@
+"""
+Trace Storage Protocol - Trace 存储接口定义
+
+使用 Protocol 定义接口,允许不同的存储实现(内存、PostgreSQL、Neo4j 等)
+"""
+
+from typing import Protocol, List, Optional, runtime_checkable
+
+from agent.trace.models import Trace, Step
+
+
+@runtime_checkable
+class TraceStore(Protocol):
+    """Trace + Step 存储接口"""
+
+    # ===== Trace 操作 =====
+
+    async def create_trace(self, trace: Trace) -> str:
+        """
+        创建新的 Trace
+
+        Args:
+            trace: Trace 对象
+
+        Returns:
+            trace_id
+        """
+        ...
+
+    async def get_trace(self, trace_id: str) -> Optional[Trace]:
+        """获取 Trace"""
+        ...
+
+    async def update_trace(self, trace_id: str, **updates) -> None:
+        """
+        更新 Trace
+
+        Args:
+            trace_id: Trace ID
+            **updates: 要更新的字段
+        """
+        ...
+
+    async def list_traces(
+        self,
+        mode: Optional[str] = None,
+        agent_type: Optional[str] = None,
+        uid: Optional[str] = None,
+        status: Optional[str] = None,
+        limit: int = 50
+    ) -> List[Trace]:
+        """列出 Traces"""
+        ...
+
+    # ===== Step 操作 =====
+
+    async def add_step(self, step: Step) -> str:
+        """
+        添加 Step
+
+        Args:
+            step: Step 对象
+
+        Returns:
+            step_id
+        """
+        ...
+
+    async def get_step(self, step_id: str) -> Optional[Step]:
+        """获取 Step"""
+        ...
+
+    async def get_trace_steps(self, trace_id: str) -> List[Step]:
+        """获取 Trace 的所有 Steps(按 sequence 排序)"""
+        ...
+
+    async def get_step_children(self, step_id: str) -> List[Step]:
+        """获取 Step 的子节点"""
+        ...

+ 181 - 0
agent/trace/websocket.py

@@ -0,0 +1,181 @@
+"""
+Step 树 WebSocket 推送
+
+实时推送进行中 Trace 的 Step 更新
+"""
+
+from typing import Dict, Set
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+
+from agent.trace.protocols import TraceStore
+
+
+router = APIRouter(prefix="/api/traces", tags=["websocket"])
+
+
+# ===== 全局状态 =====
+
+
+_trace_store: TraceStore = None
+_active_connections: Dict[str, Set[WebSocket]] = {}  # trace_id -> Set[WebSocket]
+
+
+def set_trace_store(store: TraceStore):
+    """设置 TraceStore 实例"""
+    global _trace_store
+    _trace_store = store
+
+
+def get_trace_store() -> TraceStore:
+    """获取 TraceStore 实例"""
+    if _trace_store is None:
+        raise RuntimeError("TraceStore not initialized")
+    return _trace_store
+
+
+# ===== WebSocket 路由 =====
+
+
+@router.websocket("/{trace_id}/watch")
+async def watch_trace(websocket: WebSocket, trace_id: str):
+    """
+    监听 Trace 的 Step 更新
+
+    Args:
+        trace_id: Trace ID
+    """
+    await websocket.accept()
+
+    # 验证 Trace 存在
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        await websocket.send_json({
+            "event": "error",
+            "message": "Trace not found"
+        })
+        await websocket.close()
+        return
+
+    # 注册连接
+    if trace_id not in _active_connections:
+        _active_connections[trace_id] = set()
+    _active_connections[trace_id].add(websocket)
+
+    try:
+        # 发送连接成功消息
+        await websocket.send_json({
+            "event": "connected",
+            "trace_id": trace_id
+        })
+
+        # 保持连接(等待客户端断开或接收消息)
+        while True:
+            try:
+                # 接收客户端消息(心跳检测)
+                data = await websocket.receive_text()
+                # 可以处理客户端请求(如请求完整状态)
+                if data == "ping":
+                    await websocket.send_json({"event": "pong"})
+            except WebSocketDisconnect:
+                break
+
+    finally:
+        # 清理连接
+        if trace_id in _active_connections:
+            _active_connections[trace_id].discard(websocket)
+            if not _active_connections[trace_id]:
+                del _active_connections[trace_id]
+
+
+# ===== 广播函数(由 AgentRunner 调用)=====
+
+
+async def broadcast_step_added(trace_id: str, step_dict: Dict):
+    """
+    广播 Step 添加事件
+
+    Args:
+        trace_id: Trace ID
+        step_dict: Step 字典(from step.to_dict())
+    """
+    if trace_id not in _active_connections:
+        return
+
+    message = {
+        "event": "step_added",
+        "step": step_dict
+    }
+
+    # 发送给所有监听该 Trace 的客户端
+    disconnected = []
+    for websocket in _active_connections[trace_id]:
+        try:
+            await websocket.send_json(message)
+        except Exception:
+            disconnected.append(websocket)
+
+    # 清理断开的连接
+    for ws in disconnected:
+        _active_connections[trace_id].discard(ws)
+
+
+async def broadcast_step_updated(trace_id: str, step_id: str, updates: Dict):
+    """
+    广播 Step 更新事件
+
+    Args:
+        trace_id: Trace ID
+        step_id: Step ID
+        updates: 更新字段
+    """
+    if trace_id not in _active_connections:
+        return
+
+    message = {
+        "event": "step_updated",
+        "step_id": step_id,
+        "updates": updates
+    }
+
+    disconnected = []
+    for websocket in _active_connections[trace_id]:
+        try:
+            await websocket.send_json(message)
+        except Exception:
+            disconnected.append(websocket)
+
+    for ws in disconnected:
+        _active_connections[trace_id].discard(ws)
+
+
+async def broadcast_trace_completed(trace_id: str, total_steps: int):
+    """
+    广播 Trace 完成事件
+
+    Args:
+        trace_id: Trace ID
+        total_steps: 总 Step 数
+    """
+    if trace_id not in _active_connections:
+        return
+
+    message = {
+        "event": "trace_completed",
+        "trace_id": trace_id,
+        "total_steps": total_steps
+    }
+
+    disconnected = []
+    for websocket in _active_connections[trace_id]:
+        try:
+            await websocket.send_json(message)
+        except Exception:
+            disconnected.append(websocket)
+
+    for ws in disconnected:
+        _active_connections[trace_id].discard(ws)
+
+    # 完成后清理所有连接
+    if trace_id in _active_connections:
+        del _active_connections[trace_id]

+ 85 - 0
api_server.py

@@ -0,0 +1,85 @@
+"""
+API Server - FastAPI 应用入口
+
+聚合所有模块的 API 路由(step_tree、未来的 memory 等)
+"""
+
+import logging
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+import uvicorn
+
+from agent.trace import MemoryTraceStore
+from agent.trace.api import router as api_router, set_trace_store as set_api_trace_store
+from agent.trace.websocket import router as ws_router, set_trace_store as set_ws_trace_store
+
+
+# ===== 日志配置 =====
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
+)
+logger = logging.getLogger(__name__)
+
+
+# ===== FastAPI 应用 =====
+
+app = FastAPI(
+    title="Agent Step Tree API",
+    description="Step 树可视化 API",
+    version="1.0.0"
+)
+
+# CORS 配置(允许前端跨域访问)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],  # 生产环境应限制具体域名
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+# ===== 初始化存储 =====
+
+# 使用内存存储(后续可替换为 PostgreSQL)
+trace_store = MemoryTraceStore()
+
+# 注入到 step_tree 模块
+set_api_trace_store(trace_store)
+set_ws_trace_store(trace_store)
+
+
+# ===== 注册路由 =====
+
+# Step 树 RESTful API
+app.include_router(api_router)
+
+# Step 树 WebSocket
+app.include_router(ws_router)
+
+
+# ===== 健康检查 =====
+
+@app.get("/health")
+async def health_check():
+    """健康检查"""
+    return {
+        "status": "ok",
+        "service": "Agent Step Tree API",
+        "version": "1.0.0"
+    }
+
+
+# ===== 启动服务 =====
+
+if __name__ == "__main__":
+    logger.info("Starting API server...")
+    uvicorn.run(
+        "api_server:app",
+        host="0.0.0.0",
+        port=8000,
+        reload=True,  # 开发模式
+        log_level="info"
+    )

+ 177 - 24
docs/step-tree.md

@@ -369,29 +369,75 @@ def read_progress() -> str:
 
 
 ### 实时查看 Step 树
 ### 实时查看 Step 树
 
 
-开发调试时,可通过 `dump_tree` 将完整的 Step 树输出到文件
+开发调试时,系统自动输出三种格式的 Step 树
 
 
 ```python
 ```python
-from agent.debug import dump_tree
+from agent.debug import dump_tree, dump_markdown, dump_json
 
 
-# 每次 step 变化后调用
-dump_tree(trace, steps)
+# 1. 文本格式(简洁,带截断)
+dump_tree(trace, steps)  # 输出到 .trace/tree.txt
 
 
-# 自定义路径
-dump_tree(trace, steps, output_path=".debug/my_trace.txt")
+# 2. Markdown 格式(完整,可折叠)
+dump_markdown(trace, steps)  # 输出到 .trace/tree.md
+
+# 3. JSON 格式(程序化分析)
+dump_json(trace, steps)  # 输出到 .trace/tree.json
+```
+
+**自动生成**:在 `AgentRunner` 的 debug 模式下,会自动生成 `tree.txt` 和 `tree.md` 两个文件。
+
+### 三种格式对比
+
+| 格式 | 文件大小 | 内容完整性 | 适用场景 |
+|-----|---------|----------|---------|
+| **tree.txt** | 小(1-2KB) | 截断长内容 | 快速预览、终端查看 |
+| **tree.md** | 中(5-10KB) | 完整内容 | 详细调试、编辑器查看 |
+| **tree.json** | 大(可能>10KB) | 完整结构化 | 程序化分析、工具处理 |
+
+### Markdown 格式特性
+
+**完整可折叠**:使用 HTML `<details>` 标签实现原生折叠
+
+```markdown
+<details>
+<summary><b>📨 Messages</b></summary>
+
+```json
+[完整的 messages 内容]
+```
+
+</details>
+```
+
+**智能截断**:
+- ✅ **文本内容**:完整显示,不截断
+- ✅ **工具调用**:完整显示 JSON schema
+- ✅ **图片 base64**:智能截断,显示大小和预览
+
+示例输出:
+```json
+{
+  "type": "image_url",
+  "image_url": {
+    "url": "<IMAGE_DATA: 2363.7KB, data:image/png;base64, preview: iVBORw0KGgo...>"
+  }
+}
 ```
 ```
 
 
 ### 查看方式
 ### 查看方式
 
 
 ```bash
 ```bash
-# 方式1:终端实时刷新
+# 方式1:终端实时刷新(tree.txt)
 watch -n 0.5 cat .trace/tree.txt
 watch -n 0.5 cat .trace/tree.txt
 
 
-# 方式2:VS Code 打开(自动刷新)
-code .trace/tree.txt
+# 方式2:VS Code 打开(tree.md,支持折叠)
+code .trace/tree.md
+
+# 方式3:浏览器预览(tree.md)
+# 在 VS Code 中右键 → "Open Preview" 或使用 Markdown 预览插件
 ```
 ```
 
 
-### 输出示例
+### tree.txt 输出示例
 
 
 ```
 ```
 ============================================================
 ============================================================
@@ -443,29 +489,136 @@ code .trace/tree.txt
     time: 14:30:15
     time: 14:30:15
 ```
 ```
 
 
-### JSON 格式输出
-
-用于程序化分析:
-
-```python
-from agent.debug import dump_json
-
-dump_json(trace, steps)  # 输出到 .trace/tree.json
-```
-
 **实现**:`agent/debug/tree_dump.py`
 **实现**:`agent/debug/tree_dump.py`
 
 
 ---
 ---
 
 
 ## 实现位置
 ## 实现位置
 
 
-- Step 模型:`agent/models/trace.py:Step`(待更新)
+- Step 模型:`agent/models/trace.py:Step`(已实现)
+- Trace 模型:`agent/models/trace.py:Trace`(已实现)
+- 存储接口:`agent/storage/protocols.py:TraceStore`(已实现)
+- 内存存储:`agent/storage/memory_impl.py:MemoryTraceStore`(已实现)
+- Debug 工具:`agent/debug/tree_dump.py`(已实现)
+- **Core Skill**:`agent/skills/core.md`(已实现)
 - step 工具:`agent/tools/builtin/step.py`(待实现)
 - step 工具:`agent/tools/builtin/step.py`(待实现)
 - read_progress 工具:`agent/tools/builtin/step.py`(待实现)
 - read_progress 工具:`agent/tools/builtin/step.py`(待实现)
 - Context 压缩:`agent/context/compressor.py`(待实现)
 - Context 压缩:`agent/context/compressor.py`(待实现)
-- Debug 工具:`agent/debug/tree_dump.py`(已实现)
-- **Core Skill**:`agent/skills/core.md`(已实现)
+
+---
+
+## 可视化 API
+
+### 设计目标
+
+为前端提供 Step 树的查询和实时推送接口,支持:
+1. 历史任务和进行中任务的查询
+2. 大型 Trace(上千 Step)的按需加载
+3. WebSocket 实时推送进行中任务的更新
+
+### 核心设计
+
+**简化原则**:消除"批次计算"和"同层完整性检查"的复杂逻辑,使用简单的层级懒加载
+
+**数据结构**:返回树形 JSON,前端无需自行构建
+
+**性能策略**:
+- 小型 Trace(<100 Steps):用 `/tree` 一次性返回完整树
+- 大型 Trace(>100 Steps):用 `/node/{step_id}` 按需懒加载
+- 进行中任务:WebSocket 推送增量更新
+
+### API 端点
+
+```
+GET  /api/traces                          # 列出 Traces(支持过滤)
+GET  /api/traces/{trace_id}               # 获取 Trace 元数据
+GET  /api/traces/{trace_id}/tree          # 获取完整树(小型 Trace)
+GET  /api/traces/{trace_id}/node/{step_id}  # 懒加载节点 + 子节点
+WS   /api/traces/{trace_id}/watch         # 监听进行中的更新
+```
+
+### 懒加载核心逻辑
+
+```python
+async def get_node_with_children(
+    store: TraceStore,
+    step_id: Optional[str],  # None = 根节点
+    trace_id: str,
+    expand: bool = False,
+    max_depth: int = 1
+) -> dict:
+    # 1. 获取当前层节点
+    if step_id is None:
+        steps = await store.get_trace_steps(trace_id)
+        current_nodes = [s for s in steps if s.parent_id is None]
+    else:
+        current_nodes = await store.get_step_children(step_id)
+
+    # 2. 构建响应
+    result = []
+    for step in current_nodes:
+        node = step.to_dict()
+        node["children"] = []
+
+        # 3. 递归加载子节点(可选)
+        if expand and current_depth < max_depth:
+            children = await store.get_step_children(step.step_id)
+            if children:
+                node["children"] = [...]  # 递归
+
+        result.append(node)
+
+    return result
+```
+
+**品味评分**:🟢 好品味(逻辑清晰,< 30 行,无特殊情况)
+
+### WebSocket 事件
+
+```json
+// 新增 Step
+{"event": "step_added", "step": {...}}
+
+// Step 更新
+{"event": "step_updated", "step_id": "...", "updates": {...}}
+
+// Trace 完成
+{"event": "trace_completed", "trace_id": "..."}
+```
+
+### 实现位置(待定)
+
+两种方案:
+
+**方案 1:独立 API 模块**(推荐,如果未来需要多种 API)
+```
+agent/api/
+├── server.py           # FastAPI 应用
+├── routes/
+│   ├── traces.py       # Step 树路由
+│   └── websocket.py    # WebSocket 推送
+└── schemas.py          # Pydantic 模型
+```
+
+**方案 2:Step 树专用模块**(推荐,如果只用于 Step 树可视化)
+```
+agent/step_tree/
+├── api.py              # FastAPI 路由
+├── websocket.py        # WebSocket 推送
+└── server.py           # 独立服务入口
+```
+
+决策依据:
+- 如果系统未来需要提供多种 API(Experience 管理、Agent 控制等)→ 方案 1
+- 如果 API 仅用于 Step 树可视化 → 方案 2
+
+**详细设计**:参见 `/Users/sunlit/.claude/plans/starry-yawning-zebra.md`
+
+---
 
 
 ## 未来扩展
 ## 未来扩展
+
 - 重试原因、重试次数、是否降级/兜底
 - 重试原因、重试次数、是否降级/兜底
-- 为什么选择某个动作\是否触发了skills、系统prompt中的策略
+- 为什么选择某个动作、是否触发了 skills、系统 prompt 中的策略
+- 数据库持久化(PostgreSQL/Neo4j)
+- 递归查询优化(PostgreSQL CTE)

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

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

+ 89 - 28
examples/feature_extract/run.py

@@ -1,7 +1,7 @@
 """
 """
 特征提取示例
 特征提取示例
 
 
-使用 Agent 框架 + Prompt loader + 多模态支持
+使用 Agent 模式 + Skills + 多模态支持
 """
 """
 
 
 import os
 import os
@@ -18,20 +18,24 @@ load_dotenv()
 from agent.prompts import SimplePrompt
 from agent.prompts import SimplePrompt
 from agent.runner import AgentRunner
 from agent.runner import AgentRunner
 from agent.storage import MemoryTraceStore
 from agent.storage import MemoryTraceStore
-from agent.llm.providers.gemini import create_gemini_llm_call
+from agent.llm.providers.openrouter import create_openrouter_llm_call
 
 
 
 
 async def main():
 async def main():
     # 路径配置
     # 路径配置
     base_dir = Path(__file__).parent
     base_dir = Path(__file__).parent
+    project_root = base_dir.parent.parent
     prompt_path = base_dir / "test.prompt"
     prompt_path = base_dir / "test.prompt"
     feature_md_path = base_dir / "input_1" / "feature.md"
     feature_md_path = base_dir / "input_1" / "feature.md"
     image_path = base_dir / "input_1" / "image.png"
     image_path = base_dir / "input_1" / "image.png"
     output_dir = base_dir / "output_1"
     output_dir = base_dir / "output_1"
     output_dir.mkdir(exist_ok=True)
     output_dir.mkdir(exist_ok=True)
 
 
+    # Skills 目录
+    skills_dir = project_root / "agent" / "skills"
+
     print("=" * 60)
     print("=" * 60)
-    print("特征提取任务")
+    print("特征提取任务 (Agent 模式)")
     print("=" * 60)
     print("=" * 60)
     print()
     print()
 
 
@@ -39,62 +43,119 @@ async def main():
     print("1. 加载 prompt...")
     print("1. 加载 prompt...")
     prompt = SimplePrompt(prompt_path)
     prompt = SimplePrompt(prompt_path)
 
 
+    # 提取 system prompt 和 user template
+    system_prompt = prompt._messages.get("system", "")
+    user_template = prompt._messages.get("user", "")
+
     # 2. 读取特征描述
     # 2. 读取特征描述
     print("2. 读取特征描述...")
     print("2. 读取特征描述...")
     with open(feature_md_path, 'r', encoding='utf-8') as f:
     with open(feature_md_path, 'r', encoding='utf-8') as f:
         feature_text = f.read()
         feature_text = f.read()
 
 
-    # 3. 构建多模态消息
-    print("3. 构建多模态消息(文本 + 图片)...")
-    messages = prompt.build_messages(
+    # 3. 构建任务文本(包含图片)
+    print("3. 构建任务(文本 + 图片)...")
+
+    # 使用 prompt 构建多模态消息
+    temp_messages = prompt.build_messages(
         text=feature_text,
         text=feature_text,
-        images=image_path  # 框架自动处理图片
+        images=image_path
     )
     )
 
 
-    print(f"   - 消息数量: {len(messages)}")
-    print(f"   - 图片: {image_path.name}")
+    # 提取用户消息(包含文本和图片)
+    user_message_with_image = None
+    for msg in temp_messages:
+        if msg["role"] == "user":
+            user_message_with_image = msg
+            break
+
+    if not user_message_with_image:
+        raise ValueError("No user message found in prompt")
+
+    print(f"   - 任务已构建(包含图片: {image_path.name})")
 
 
-    # 4. 创建 Agent Runner
+    # 4. 创建 Agent Runner(配置 skills)
     print("4. 创建 Agent Runner...")
     print("4. 创建 Agent Runner...")
+    print(f"   - Skills 目录: {skills_dir}")
+    print(f"   - 模型: Claude Sonnet 4.5 (via OpenRouter)")
+
     runner = AgentRunner(
     runner = AgentRunner(
         trace_store=MemoryTraceStore(),
         trace_store=MemoryTraceStore(),
-        llm_call=create_gemini_llm_call(),
-        debug=True  # 启用 debug,输出到 .trace/tree.txt
+        llm_call=create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5"),
+        skills_dir=str(skills_dir),  # 恢复加载 skills,测试 Claude 是否能处理
+        debug=True  # 启用 debug,输出到 .trace/
     )
     )
 
 
-    # 5. 调用 Agent
-    print(f"5. 调用模型: {prompt.config.get('model', 'gemini-2.5-flash')}...")
+    # 5. Agent 模式执行
+    # 注意:使用 OpenRouter 时,模型在创建 llm_call 时已指定
+    # 这里传入的 model 参数会被忽略(由 llm_call 内部控制)
+    print(f"5. 启动 Agent 模式...")
     print()
     print()
 
 
-    result = await runner.call(
-        messages=messages,
-        model=prompt.config.get('model', 'gemini-2.5-flash'),
+    final_response = ""
+
+    async for event in runner.run(
+        task="[图片和特征描述已包含在 messages 中]",  # 占位符
+        messages=[user_message_with_image],  # 传入包含图片的用户消息
+        system_prompt=system_prompt,
+        model="anthropic/claude-sonnet-4.5",  # OpenRouter 模型名称
         temperature=float(prompt.config.get('temperature', 0.3)),
         temperature=float(prompt.config.get('temperature', 0.3)),
-        trace=True  # 启用 trace,配合 debug 输出 step tree
-    )
+        max_iterations=10,
+        # tools 参数不传入,测试自动加载内置工具
+    ):
+        event_type = event.type
+        event_data = event.data
+
+        if event_type == "trace_started":
+            print(f"[Trace] 开始: {event_data.get('trace_id', '')[:8]}")
+
+        elif event_type == "memory_loaded":
+            exp_count = event_data.get('experiences_count', 0)
+            if exp_count > 0:
+                print(f"[Memory] 加载 {exp_count} 条经验")
+
+        elif event_type == "step_started":
+            step_type = event_data.get('step_type', '')
+            print(f"[Step] {step_type}...")
+
+        elif event_type == "thought":
+            content = event_data.get('content', '')
+            if content:
+                print(f"[Thought] {content[:100]}...")
+
+        elif event_type == "tool_execution":
+            tool_name = event_data.get('tool_name', '')
+            print(f"[Tool] 执行 {tool_name}")
+
+        elif event_type == "conclusion":
+            final_response = event_data.get('content', '')  # 修正:字段名是 content 不是 response
+            print(f"[Conclusion] Agent 完成")
+
+        elif event_type == "trace_completed":
+            print(f"[Trace] 完成")
+            print(f"  - Total tokens: {event_data.get('total_tokens', 0)}")
+            print(f"  - Total cost: ${event_data.get('total_cost', 0.0):.4f}")
 
 
     # 6. 输出结果
     # 6. 输出结果
+    print()
     print("=" * 60)
     print("=" * 60)
-    print("模型响应:")
+    print("Agent 响应:")
     print("=" * 60)
     print("=" * 60)
-    print(result.reply)
+    print(final_response)
     print("=" * 60)
     print("=" * 60)
     print()
     print()
 
 
     # 7. 保存结果
     # 7. 保存结果
     output_file = output_dir / "result.txt"
     output_file = output_dir / "result.txt"
     with open(output_file, 'w', encoding='utf-8') as f:
     with open(output_file, 'w', encoding='utf-8') as f:
-        f.write(result.reply)
+        f.write(final_response)
 
 
     print(f"✓ 结果已保存到: {output_file}")
     print(f"✓ 结果已保存到: {output_file}")
     print()
     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}")
+    # 8. 提示查看 debug 文件
+    print("Debug 文件:")
+    print(f"  - 完整可折叠: {Path.cwd() / '.trace' / 'tree.md'}")
+    print(f"  - 简洁文本: {Path.cwd() / '.trace' / 'tree.txt'}")
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":

+ 1 - 1
examples/feature_extract/test.prompt

@@ -8,7 +8,7 @@ $system$
 你是一位计算机视觉专家,也是一位才华横溢的社媒博主、内容创作者。
 你是一位计算机视觉专家,也是一位才华横溢的社媒博主、内容创作者。
 
 
 # 任务
 # 任务
-分析一个优质内容的指定特征适合如何表示,并完成该特征的提取。
+分析一个优质内容的指定特征适合什么样的表示(仅仅语言描述是不够的),并完成该特征的提取。
 提取的特征将用于在生成类似内容时作为参考内容(所以要保留重要信息),也会和其他内容的同一维度的特征放在一起聚类发现规律(所以特征表示要尽量精简、不要过于具体),或用于模型训练。
 提取的特征将用于在生成类似内容时作为参考内容(所以要保留重要信息),也会和其他内容的同一维度的特征放在一起聚类发现规律(所以特征表示要尽量精简、不要过于具体),或用于模型训练。
 
 
 # 工具
 # 工具

+ 5 - 0
requirements.txt

@@ -6,3 +6,8 @@ python-dotenv>=1.0.0
 # 推荐安装方式: uv add browser-use && uv sync
 # 推荐安装方式: uv add browser-use && uv sync
 # 或使用: pip install browser-use
 # 或使用: pip install browser-use
 browser-use>=0.11.0
 browser-use>=0.11.0
+
+# API Server (Step Tree Visualization)
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+websockets>=13.0