Bladeren bron

feat: step tree visualization (without test)

Talegorithm 1 maand geleden
bovenliggende
commit
a78494920f

+ 2 - 2
agent/__init__.py

@@ -13,10 +13,10 @@ Reson Agent - 可扩展、可学习的 Agent 框架
 
 from agent.runner import AgentRunner, AgentConfig
 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.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"
 

+ 2 - 2
agent/debug/__init__.py

@@ -4,6 +4,6 @@ Debug 工具模块
 提供 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_JSON_PATH = ".trace/tree.json"
+DEFAULT_MD_PATH = ".trace/tree.md"
 
 
 class StepTreeDumper:
@@ -228,6 +229,258 @@ class StepTreeDumper:
 
         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(
     trace: Optional[Any] = None,
@@ -315,3 +568,47 @@ def dump_json(
     path.write_text(content, encoding="utf-8")
 
     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 提供商的适配器
 """
+
+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 json
+import sys
 import httpx
 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]]:
     """
     将 OpenAI 格式消息转换为 Gemini 格式
@@ -299,6 +349,9 @@ def create_gemini_llm_call(
             if gemini_tools:
                 payload["tools"] = gemini_tools
 
+        # Debug: dump完整请求(需要设置 AGENT_DEBUG=1)
+        _dump_llm_request(endpoint, payload, model)
+
         # 调用 API
         try:
             response = await client.post(endpoint, json=payload)
@@ -313,37 +366,55 @@ def create_gemini_llm_call(
             print(f"[Gemini HTTP] Request failed: {e}")
             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 = ""
         tool_calls = None
 
         candidates = gemini_resp.get("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_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
 
-__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
 
     # 内容
+    content: Optional[str] = None  # 完整的 skill 内容(Markdown)
     guidelines: List[str] = field(default_factory=list)
     derived_from: List[str] = field(default_factory=list)  # experience_ids
 
@@ -119,6 +120,7 @@ class Skill:
         name: str,
         description: str,
         category: str = "general",
+        content: Optional[str] = None,
         guidelines: List[str] = None,
         derived_from: List[str] = None,
         parent_id: Optional[str] = None,
@@ -132,6 +134,7 @@ class Skill:
             description=description,
             category=category,
             parent_id=parent_id,
+            content=content,
             guidelines=guidelines or [],
             derived_from=derived_from or [],
             created_at=now,
@@ -147,6 +150,7 @@ class Skill:
             "description": self.description,
             "category": self.category,
             "parent_id": self.parent_id,
+            "content": self.content,
             "guidelines": self.guidelines,
             "derived_from": self.derived_from,
             "version": self.version,
@@ -155,7 +159,16 @@ class Skill:
         }
 
     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]
         if self.guidelines:
             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 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.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.tools import ToolRegistry, get_tool_registry
-from agent.debug import dump_tree
+from agent.debug import dump_tree, dump_markdown
 
 logger = logging.getLogger(__name__)
 
 
+# 内置工具列表(始终自动加载)
+BUILTIN_TOOLS = [
+    "read_file",
+    "edit_file",
+    "write_file",
+    "glob_files",
+    "grep_content",
+    "bash_command",
+    "skill",
+    "list_skills",
+]
+
+
 @dataclass
 class AgentConfig:
     """Agent 配置"""
@@ -61,6 +74,7 @@ class AgentRunner:
         tool_registry: Optional[ToolRegistry] = None,
         llm_call: Optional[Callable] = None,
         config: Optional[AgentConfig] = None,
+        skills_dir: Optional[str] = None,
         debug: bool = False,
     ):
         """
@@ -73,6 +87,7 @@ class AgentRunner:
             tool_registry: 工具注册表(可选,默认使用全局注册表)
             llm_call: LLM 调用函数(必须提供,用于实际调用 LLM)
             config: Agent 配置
+            skills_dir: Skills 目录路径(可选,不提供则不加载 skills)
             debug: 是否启用 debug 模式(输出 step tree 到 .trace/tree.txt)
         """
         self.trace_store = trace_store
@@ -81,6 +96,7 @@ class AgentRunner:
         self.tools = tool_registry or get_tool_registry()
         self.llm_call = llm_call
         self.config = config or AgentConfig()
+        self.skills_dir = skills_dir
         self.debug = debug
 
     def _generate_id(self) -> str:
@@ -89,13 +105,18 @@ class AgentRunner:
         return str(uuid.uuid4())
 
     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:
             return
         trace = await self.trace_store.get_trace(trace_id)
         steps = await self.trace_store.get_trace_steps(trace_id)
+
+        # 输出 tree.txt(简洁格式,兼容旧版)
         dump_tree(trace, steps)
 
+        # 输出 tree.md(完整可折叠格式)
+        dump_markdown(trace, steps)
+
     # ===== 单次调用 =====
 
     async def call(
@@ -137,9 +158,15 @@ class AgentRunner:
             trace_id = await self.trace_store.create_trace(trace_obj)
 
         # 准备工具 Schema
-        tool_schemas = None
+        # 合并内置工具 + 用户指定工具
+        tool_names = BUILTIN_TOOLS.copy()
         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
         result = await self.llm_call(
@@ -161,6 +188,7 @@ class AgentRunner:
                     "messages": messages,
                     "response": result.get("content", ""),
                     "model": model,
+                    "tools": tool_schemas,  # 记录传给模型的 tools schema
                     "tool_calls": result.get("tool_calls"),
                 },
                 tokens=result.get("prompt_tokens", 0) + result.get("completion_tokens", 0),
@@ -254,8 +282,9 @@ class AgentRunner:
         })
 
         try:
-            # 加载记忆(Experience)
+            # 加载记忆(Experience 和 Skill
             experiences_text = ""
+            skills_text = ""
 
             if enable_memory and self.memory_store:
                 scope = f"agent:{agent_type}"
@@ -282,13 +311,22 @@ class AgentRunner:
                     "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:
                 messages = []
 
             if system_prompt:
-                # 注入记忆到 system prompt
+                # 注入记忆和 skills 到 system prompt
                 full_system = system_prompt
+                if skills_text:
+                    full_system += f"\n\n## Skills\n{skills_text}"
                 if experiences_text:
                     full_system += f"\n\n## 相关经验\n{experiences_text}"
 
@@ -297,10 +335,16 @@ class AgentRunner:
             # 添加任务描述
             messages.append({"role": "user", "content": task})
 
-            # 准备工具
-            tool_schemas = None
+            # 准备工具 Schema
+            # 合并内置工具 + 用户指定工具
+            tool_names = BUILTIN_TOOLS.copy()
             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
@@ -349,8 +393,10 @@ class AgentRunner:
                         parent_id=current_goal_id,
                         description=response_content[:100] + "..." if len(response_content) > 100 else response_content,
                         data={
+                            "messages": messages,  # 记录完整的 messages(包含 system prompt)
                             "content": response_content,
                             "model": model,
+                            "tools": tool_schemas,  # 记录传给模型的 tools schema
                             "tool_calls": tool_calls,
                         },
                         tokens=step_tokens,
@@ -617,3 +663,9 @@ class AgentRunner:
         if not experiences:
             return ""
         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 包 - 存储接口和实现
+
+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__ = [
-    "TraceStore",
     "MemoryStore",
     "StateStore",
-    "MemoryTraceStore",
     "MemoryMemoryStore",
     "MemoryStateStore",
 ]

+ 2 - 81
agent/storage/memory_impl.py

@@ -2,95 +2,16 @@
 Memory Implementation - 内存存储实现
 
 用于测试和简单场景,数据不持久化
+
+MemoryTraceStore 已移动到 agent.trace.memory_store
 """
 
 from typing import Dict, List, Optional, Any
 from datetime import datetime
 
-from agent.models.trace import Trace, Step
 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:
     """内存 Memory 存储(Experience + Skill)"""
 

+ 2 - 71
agent/storage/protocols.py

@@ -2,84 +2,15 @@
 Storage Protocols - 存储接口定义
 
 使用 Protocol 定义接口,允许不同的存储实现(内存、PostgreSQL、Neo4j 等)
+
+TraceStore 已移动到 agent.trace.protocols
 """
 
 from typing import Protocol, List, Optional, Dict, Any, runtime_checkable
 
-from agent.models.trace import Trace, Step
 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
 class MemoryStore(Protocol):
     """Experience + Skill 存储接口"""

+ 25 - 0
agent/storage/skill_loader.py

@@ -200,12 +200,16 @@ class SkillLoader:
         # 提取 Guidelines
         guidelines = self._extract_list_items(remaining_lines, "Guidelines")
 
+        # 保存完整的内容(去掉 frontmatter)
+        content = remaining_content.strip()
+
         # 创建 Skill
         return Skill.create(
             scope=scope,
             name=name,
             description=description.strip(),
             category=category,
+            content=content,  # 完整的 Markdown 内容
             guidelines=guidelines,
             parent_id=parent_id,
         )
@@ -242,12 +246,33 @@ class SkillLoader:
         # 提取指导原则
         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
         return Skill.create(
             scope=scope,
             name=name,
             description=description.strip(),
             category=category,
+            content=content,  # 完整的 Markdown 内容
             guidelines=guidelines,
             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.models import ToolResult, ToolContext, ToolContextImpl
 
+# 导入 builtin 工具以触发 @tool 装饰器注册
+# noqa: F401 表示这是故意的副作用导入
+import agent.tools.builtin  # noqa: F401
 
 __all__ = [
 	"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 树
 
-开发调试时,可通过 `dump_tree` 将完整的 Step 树输出到文件
+开发调试时,系统自动输出三种格式的 Step 树
 
 ```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
-# 方式1:终端实时刷新
+# 方式1:终端实时刷新(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
 ```
 
-### JSON 格式输出
-
-用于程序化分析:
-
-```python
-from agent.debug import dump_json
-
-dump_json(trace, steps)  # 输出到 .trace/tree.json
-```
-
 **实现**:`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`(待实现)
 - read_progress 工具:`agent/tools/builtin/step.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
@@ -18,20 +18,24 @@ load_dotenv()
 from agent.prompts import SimplePrompt
 from agent.runner import AgentRunner
 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():
     # 路径配置
     base_dir = Path(__file__).parent
+    project_root = base_dir.parent.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)
 
+    # Skills 目录
+    skills_dir = project_root / "agent" / "skills"
+
     print("=" * 60)
-    print("特征提取任务")
+    print("特征提取任务 (Agent 模式)")
     print("=" * 60)
     print()
 
@@ -39,62 +43,119 @@ async def main():
     print("1. 加载 prompt...")
     prompt = SimplePrompt(prompt_path)
 
+    # 提取 system prompt 和 user template
+    system_prompt = prompt._messages.get("system", "")
+    user_template = prompt._messages.get("user", "")
+
     # 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(
+    # 3. 构建任务文本(包含图片)
+    print("3. 构建任务(文本 + 图片)...")
+
+    # 使用 prompt 构建多模态消息
+    temp_messages = prompt.build_messages(
         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(f"   - Skills 目录: {skills_dir}")
+    print(f"   - 模型: Claude Sonnet 4.5 (via OpenRouter)")
+
     runner = AgentRunner(
         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()
 
-    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)),
-        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. 输出结果
+    print()
     print("=" * 60)
-    print("模型响应:")
+    print("Agent 响应:")
     print("=" * 60)
-    print(result.reply)
+    print(final_response)
     print("=" * 60)
     print()
 
     # 7. 保存结果
     output_file = output_dir / "result.txt"
     with open(output_file, 'w', encoding='utf-8') as f:
-        f.write(result.reply)
+        f.write(final_response)
 
     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}")
+    # 8. 提示查看 debug 文件
+    print("Debug 文件:")
+    print(f"  - 完整可折叠: {Path.cwd() / '.trace' / 'tree.md'}")
+    print(f"  - 简洁文本: {Path.cwd() / '.trace' / 'tree.txt'}")
 
 
 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
 # 或使用: pip install browser-use
 browser-use>=0.11.0
+
+# API Server (Step Tree Visualization)
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+websockets>=13.0