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

合并(agent治理): 在当前框架能力上整合模块化治理成果

以当前 main 的脚本构建接口和编排语义为基线,合入 framework-governance 分支的 Runner、TaskCoordinator、Trace、模型协议、工具注册、浏览器、内容工具和 IM 模块治理。

冲突处理保留 ValidationPlan.preflight_rule_ids 与确定性预检短路;Coordinator 使用拆分后的 DecisionEngine、TaskGraph、GoalProjection,同时维持现有公开调用形状和 Host 工具替换合同。治理变更严格限定在 agent/,未纳入工作区中已有的 script_build_host 改动。
SamLee 1 день назад
Родитель
Сommit
c68d06ffd8
90 измененных файлов с 6386 добавлено и 4591 удалено
  1. 1 1
      agent/agent/cli/extraction_review.py
  2. 9 7
      agent/agent/cli/interactive.py
  3. 0 2
      agent/agent/core/dream.py
  4. 71 0
      agent/agent/core/knowledge_config.py
  5. 1 1
      agent/agent/core/memory.py
  6. 73 808
      agent/agent/core/runner.py
  7. 636 0
      agent/agent/core/runner_images.py
  8. 140 0
      agent/agent/core/runner_prompts.py
  9. 187 0
      agent/agent/core/runner_tools.py
  10. 21 0
      agent/agent/debug/__init__.py
  11. 825 0
      agent/agent/debug/tree_dump.py
  12. 14 0
      agent/agent/im_client/__init__.py
  13. 348 0
      agent/agent/im_client/client.py
  14. 22 0
      agent/agent/im_client/notifier.py
  15. 23 0
      agent/agent/im_client/protocol.py
  16. 416 0
      agent/agent/llm/anthropic_protocol.py
  17. 12 42
      agent/agent/llm/claude.py
  18. 29 16
      agent/agent/llm/gemini.py
  19. 29 391
      agent/agent/llm/openrouter.py
  20. 8 3
      agent/agent/llm/pricing.py
  21. 1 1
      agent/agent/llm/prompts/wrapper.py
  22. 2 5
      agent/agent/llm/usage.py
  23. 73 393
      agent/agent/llm/yescode.py
  24. 245 0
      agent/agent/orchestration/_decision_engine.py
  25. 175 0
      agent/agent/orchestration/_goal_projection.py
  26. 353 0
      agent/agent/orchestration/_task_graph.py
  27. 286 285
      agent/agent/orchestration/coordinator.py
  28. 6 1
      agent/agent/orchestration/executor.py
  29. 3 1
      agent/agent/orchestration/operations.py
  30. 0 2
      agent/agent/skill/skill_loader.py
  31. 18 28
      agent/agent/tools/builtin/__init__.py
  32. 11 1
      agent/agent/tools/builtin/browser/__init__.py
  33. 43 248
      agent/agent/tools/builtin/browser/baseClass.py
  34. 110 0
      agent/agent/tools/builtin/browser/downloads.py
  35. 192 0
      agent/agent/tools/builtin/browser/providers.py
  36. 7 3
      agent/agent/tools/builtin/content/cache.py
  37. 69 0
      agent/agent/tools/builtin/content/collage.py
  38. 5 1
      agent/agent/tools/builtin/content/media.py
  39. 26 75
      agent/agent/tools/builtin/content/platforms/aigc_channel.py
  40. 31 79
      agent/agent/tools/builtin/content/platforms/x.py
  41. 23 54
      agent/agent/tools/builtin/content/platforms/youtube.py
  42. 6 3
      agent/agent/tools/builtin/content/tools.py
  43. 50 26
      agent/agent/tools/builtin/feishu/chat.py
  44. 0 79
      agent/agent/tools/builtin/feishu/chat_test.py
  45. 24 14
      agent/agent/tools/builtin/feishu/feishu_agent.py
  46. 1 3
      agent/agent/tools/builtin/feishu/feishu_client.py
  47. 0 92
      agent/agent/tools/builtin/feishu/websocket_event.py
  48. 22 0
      agent/agent/tools/builtin/file/diff_utils.py
  49. 1 28
      agent/agent/tools/builtin/file/edit.py
  50. 7 2
      agent/agent/tools/builtin/file/glob.py
  51. 5 1
      agent/agent/tools/builtin/file/grep.py
  52. 1 1
      agent/agent/tools/builtin/file/image_cdn.py
  53. 0 1
      agent/agent/tools/builtin/file/read.py
  54. 1 20
      agent/agent/tools/builtin/file/write.py
  55. 1 1
      agent/agent/tools/builtin/file/write_json.py
  56. 3 106
      agent/agent/tools/builtin/glob_tool.py
  57. 1 10
      agent/agent/tools/builtin/im/chat.py
  58. 8 78
      agent/agent/tools/builtin/knowledge.py
  59. 1 1
      agent/agent/tools/builtin/resource.py
  60. 1 2
      agent/agent/tools/builtin/skill.py
  61. 1 1
      agent/agent/tools/builtin/subagent.py
  62. 0 5
      agent/agent/tools/builtin/toolhub.py
  63. 129 1
      agent/agent/tools/registry.py
  64. 8 4
      agent/agent/tools/utils/image.py
  65. 6 0
      agent/agent/trace/__init__.py
  66. 255 0
      agent/agent/trace/_cognition_store.py
  67. 9 2
      agent/agent/trace/api.py
  68. 0 1
      agent/agent/trace/examples_api.py
  69. 4 4
      agent/agent/trace/goal_tool.py
  70. 2 2
      agent/agent/trace/run_api.py
  71. 57 211
      agent/agent/trace/store.py
  72. 21 825
      agent/agent/trace/tree_dump.py
  73. 2 2
      agent/agent/trace/upload_api.py
  74. 22 0
      agent/im-client/_framework_import.py
  75. 6 331
      agent/im-client/client.py
  76. 0 248
      agent/im-client/client.py.bak
  77. 6 18
      agent/im-client/notifier.py
  78. 6 19
      agent/im-client/protocol.py
  79. 4 0
      agent/pyproject.toml
  80. 93 0
      agent/tests/test_anthropic_protocol.py
  81. 166 0
      agent/tests/test_browser_governance.py
  82. 76 0
      agent/tests/test_builtin_cleanup_compatibility.py
  83. 107 0
      agent/tests/test_content_collage.py
  84. 105 0
      agent/tests/test_governance_public_contract.py
  85. 53 0
      agent/tests/test_import_side_effects.py
  86. 0 1
      agent/tests/test_legacy_feishu_credentials.py
  87. 166 0
      agent/tests/test_runner_components.py
  88. 17 0
      agent/tests/test_shared_diff_utils.py
  89. 311 0
      agent/tests/test_tool_registry_contract.py
  90. 77 0
      agent/tests/test_trace_cognition_store.py

+ 1 - 1
agent/agent/cli/extraction_review.py

@@ -32,7 +32,7 @@ import asyncio
 import json
 import sys
 from pathlib import Path
-from typing import List, Optional
+from typing import Optional
 
 from agent.trace.store import FileSystemTraceStore
 from agent.trace.extraction_review import (

+ 9 - 7
agent/agent/cli/interactive.py

@@ -4,8 +4,8 @@
 提供暂停/继续、交互式菜单、经验总结等功能。
 """
 
+import logging
 import sys
-import asyncio
 from typing import Optional, Dict, Any
 from pathlib import Path
 
@@ -13,6 +13,8 @@ from agent.core.runner import AgentRunner
 from agent.trace import TraceStore
 from agent.trace.models import Message, Trace
 
+logger = logging.getLogger(__name__)
+
 
 # ===== 非阻塞 stdin 检测 =====
 
@@ -43,8 +45,8 @@ def check_stdin() -> Optional[str]:
                 return 'pause'
             if cmd in ('q', 'quit'):
                 return 'quit'
-        except Exception:
-            pass
+        except (OSError, UnicodeError) as exc:
+            logger.debug("Failed to read agent control file: %s", exc)
 
     # 2. 检查终端/管道输入
     if sys.platform == 'win32':
@@ -68,8 +70,8 @@ def check_stdin() -> Optional[str]:
                         return 'pause'
                     if line in ('q', 'quit'):
                         return 'quit'
-            except Exception:
-                pass
+            except (OSError, ValueError) as exc:
+                logger.debug("Failed to poll stdin: %s", exc)
         return None
     else:
         # Unix/Mac: 使用 select(支持终端和管道)
@@ -191,7 +193,7 @@ class InteractiveController:
                     print("未输入任何内容,取消操作")
                     continue
 
-                print(f"\n将插入干预消息并继续执行...")
+                print("\n将插入干预消息并继续执行...")
                 # 从 store 读取实际的 last_sequence
                 live_trace = await self.store.get_trace(trace_id)
                 actual_sequence = live_trace.last_sequence if live_trace and live_trace.last_sequence else current_sequence
@@ -431,7 +433,7 @@ class InteractiveController:
             messages_to_add = []
             if intervention_msg:
                 messages_to_add.append({"role": "user", "content": intervention_msg})
-                print(f"\n✓ 将插入干预消息")
+                print("\n✓ 将插入干预消息")
 
             # 调用 runner.run() 续跑
             print("\n开始执行...")

+ 0 - 2
agent/agent/core/dream.py

@@ -229,8 +229,6 @@ async def cross_trace_integrate(
 
     # 读当前记忆文件
     existing_files = load_memory_files(memory_config)
-    existing_by_path = {rel: (purpose, content) for rel, purpose, content in existing_files}
-
     user_content = _build_dream_input(reflections, existing_files, memory_config)
     prompt = memory_config.dream_prompt or DEFAULT_DREAM_PROMPT
 

+ 71 - 0
agent/agent/core/knowledge_config.py

@@ -0,0 +1,71 @@
+"""Knowledge extraction and injection configuration.
+
+This value object belongs to the runner configuration layer.  Keeping it out
+of the builtin HTTP tool module prevents core configuration imports from
+triggering builtin tool registration.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+from agent.core.prompts import COMPLETION_REFLECT_PROMPT, build_reflect_prompt
+
+
+@dataclass
+class KnowledgeConfig:
+    """Configure knowledge extraction, review, and prompt injection."""
+
+    enable_extraction: bool = True
+    reflect_prompt: str = ""
+    enable_completion_extraction: bool = True
+    completion_reflect_prompt: str = ""
+    reflect_auto_commit: bool = False
+    enable_injection: bool = True
+    owner: str = ""
+    default_tags: Optional[Dict[str, str]] = None
+    default_scopes: Optional[List[str]] = None
+    default_search_types: Optional[List[str]] = None
+    default_search_owner: str = ""
+
+    def get_reflect_prompt(self) -> str:
+        """Return the configured compression-reflection prompt."""
+
+        return self.reflect_prompt or build_reflect_prompt()
+
+    def get_completion_reflect_prompt(self) -> str:
+        """Return the configured completion-reflection prompt."""
+
+        return self.completion_reflect_prompt or COMPLETION_REFLECT_PROMPT
+
+    @property
+    def resolved_owner(self) -> str:
+        """Resolve owner from explicit config, Git identity, then ``agent``."""
+
+        if self.owner:
+            return self.owner
+        try:
+            result = subprocess.run(
+                ["git", "config", "user.email"],
+                capture_output=True,
+                text=True,
+                timeout=2,
+            )
+            if result.returncode == 0 and result.stdout.strip():
+                return result.stdout.strip()
+        except (OSError, subprocess.SubprocessError):
+            pass
+        return "agent"
+
+    def get_owner(self, agent_id: str = "agent") -> str:
+        """Return the resolved owner, namespaced for non-default agents."""
+
+        owner = self.resolved_owner
+        if owner == "agent" and agent_id != "agent":
+            return f"agent:{agent_id}"
+        return owner
+
+
+__all__ = ["KnowledgeConfig"]

+ 1 - 1
agent/agent/core/memory.py

@@ -13,7 +13,7 @@ from __future__ import annotations
 
 import glob as _glob
 import logging
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from pathlib import Path
 from typing import Dict, List, Optional, Tuple
 

+ 73 - 808
agent/agent/core/runner.py

@@ -1,17 +1,8 @@
-"""
-Agent Runner - Agent 执行引擎
-
-核心职责:
-1. 执行 Agent 任务(循环调用 LLM + 工具)
-2. 记录执行轨迹(Trace + Messages + GoalTree)
-3. 加载和注入技能(Skill)
-4. 管理执行计划(GoalTree)
-5. 支持续跑(continue)和回溯重跑(rewind)
-
-参数分层:
-- Infrastructure: AgentRunner 构造时设置(trace_store, llm_call 等)
-- RunConfig: 每次 run 时指定(model, trace_id, after_sequence 等)
-- Messages: OpenAI SDK 格式的任务消息
+"""Public execution facade for Agent loops and resumable Trace state.
+
+``AgentRunner`` retains the stable calling surface while delegating prompt and
+tool-policy concerns to focused internal components.  It still owns loop state,
+Trace continuity, context management, and rewind semantics.
 """
 
 import asyncio
@@ -48,28 +39,22 @@ from agent.trace.compaction import (
     estimate_tokens,
 )
 from agent.skill.models import Skill
-from agent.skill.skill_loader import load_skills_from_dir
 from agent.tools import ToolRegistry, get_tool_registry
-from agent.tools.builtin.knowledge import KnowledgeConfig
+from agent.core.knowledge_config import KnowledgeConfig
 from agent.core.memory import MemoryConfig
-from agent.core.presets import get_preset
 from agent.orchestration.models import AgentRole, CompletionPolicy
 from agent.orchestration.policy import (
     DefaultToolPolicy,
     ResolvedAgentPolicy,
-    PLANNER_TOOLS,
-    WORKER_TOOLS,
-    VALIDATOR_TOOLS,
 )
+from agent.core.runner_tools import RunnerToolRuntime
+from agent.core.runner_prompts import RunnerPromptRuntime
+from agent.core.runner_images import RunnerImageRuntime
 from agent.core.prompts import (
-    DEFAULT_SYSTEM_PREFIX,
     TRUNCATION_HINT,
     AGENT_INTERRUPTED_SUMMARY,
-    TASK_NAME_GENERATION_SYSTEM_PROMPT,
-    TASK_NAME_FALLBACK,
     build_tool_interrupted_message,
     build_agent_continue_hint,
-    ROLE_CONTRACTS,
 )
 
 logger = logging.getLogger(__name__)
@@ -197,8 +182,7 @@ class RunConfig:
     # None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
     memory: Optional["MemoryConfig"] = None
 
-    # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
-    # 工具可用性现在由 @tool(groups=[...]) 声明 + RunConfig.tool_groups 过滤控制。
+    # Tool availability is resolved from registry groups and role policy.
 
 
 @dataclass
@@ -281,12 +265,17 @@ class AgentRunner:
         # 图片优化缓存(避免重复处理)
         # key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
         self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
+        self._image_runtime = RunnerImageRuntime(self)
 
         # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
         # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
         self._current_memory_config: Optional[MemoryConfig] = None
         self.task_coordinator = task_coordinator
         self.tool_policy = tool_policy or DefaultToolPolicy()
+        self._tool_runtime = RunnerToolRuntime(self.tools, self.tool_policy, self.log)
+        self._prompt_runtime = RunnerPromptRuntime(
+            logger=self.log,
+        )
 
     # ===== 核心公开方法 =====
 
@@ -713,7 +702,7 @@ class AgentRunner:
 
             await broadcast_trace_status_changed(config.trace_id, "running")
         except Exception:
-            pass
+            self.log.debug("Trace status broadcast failed", exc_info=True)
 
         return trace_obj, goal_tree, sequence
 
@@ -1279,7 +1268,7 @@ class AgentRunner:
 
                         await broadcast_trace_status_changed(trace_id, "stopped")
                     except Exception:
-                        pass
+                        self.log.debug("Stopped status broadcast failed", exc_info=True)
                     trace_obj = await self.trace_store.get_trace(trace_id)
                     if trace_obj:
                         yield trace_obj
@@ -2301,8 +2290,11 @@ class AgentRunner:
                                         }
                                     )
                             img_count = len(tool_content_for_llm) - 1  # 减去 text 块
-                            print(
-                                f"[Runner] 多模态工具反馈: tool={tool_name}, images={img_count}, text_len={len(tool_result_text)}"
+                            self.log.debug(
+                                "Multimodal tool result: tool=%s images=%d text_len=%d",
+                                tool_name,
+                                img_count,
+                                len(tool_result_text),
                             )
                         else:
                             tool_result_text = tool_text
@@ -3184,645 +3176,47 @@ class AgentRunner:
     # ===== 辅助方法 =====
 
     async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
-        """
-        分级优化已处理的图片,节省 token
-
-        策略(基于图片距离最后一条 assistant 的"轮次"):
-        1. 最近 1-2 轮:保留原图
-        2. 3-5 轮:降低分辨率和压缩(节省 token 但保留视觉信息)
-        3. 5 轮以上:调用小模型生成文本描述 + 保留 URL
-
-        处理结果会缓存,避免重复的 PIL 解码/编码和 LLM 调用。
-
-        Args:
-            messages: 原始消息列表
-            model: 当前使用的模型(用于选择描述生成模型)
-
-        Returns:
-            优化后的消息列表(深拷贝)
-        """
-        if not messages:
-            return messages
-
-        # 找到最后一条 assistant message 的位置
-        last_assistant_idx = -1
-        for i in range(len(messages) - 1, -1, -1):
-            if messages[i].get("role") == "assistant":
-                last_assistant_idx = i
-                break
-
-        # 如果没有 assistant message,说明还没开始对话,不优化
-        if last_assistant_idx == -1:
-            return messages
-
-        # 统计从每个位置到最后一条 assistant 之间的 assistant 数量(作为"轮次")
-        assistant_count_after = [0] * len(messages)
-        count = 0
-        for i in range(len(messages) - 1, -1, -1):
-            assistant_count_after[i] = count
-            if messages[i].get("role") == "assistant":
-                count += 1
-
-        # 深拷贝避免修改原始数据
-        import copy
-        import hashlib
-        import asyncio
-        import base64 as b64mod
-        import httpx
-        import mimetypes
-
-        messages = copy.deepcopy(messages)
-
-        # 预处理:将所有 HTTP(S) URL 图片下载并转为 base64 data URL
-        # Qwen API 无法访问外部签名 URL(如 BFL、火山引擎 TOS),必须在本地转换
-        url_download_jobs = []  # [(msg_idx, block_idx, url)]
-        for i, msg in enumerate(messages):
-            if msg.get("role") != "tool":
-                continue
-            content = msg.get("content")
-            if not isinstance(content, list):
-                continue
-            for block_idx, block in enumerate(content):
-                if isinstance(block, dict) and block.get("type") == "image_url":
-                    url = block.get("image_url", {}).get("url", "")
-                    if url.startswith(("http://", "https://")):
-                        url_download_jobs.append((i, block_idx, url))
-
-        if url_download_jobs:
-
-            async def _download_image_to_data_url(url: str) -> str | None:
-                try:
-                    async with httpx.AsyncClient(timeout=60, trust_env=False) as client:
-                        resp = await client.get(url)
-                        resp.raise_for_status()
-                        ct = resp.headers.get("content-type", "").split(";")[0].strip()
-                        if not ct.startswith("image/"):
-                            ct = (
-                                mimetypes.guess_type(url.split("?")[0])[0]
-                                or "image/png"
-                            )
-                        b64 = b64mod.b64encode(resp.content).decode()
-                        return f"data:{ct};base64,{b64}"
-                except Exception:
-                    return None
-
-            results = await asyncio.gather(
-                *[_download_image_to_data_url(url) for _, _, url in url_download_jobs],
-                return_exceptions=True,
-            )
-            converted = 0
-            for (msg_idx, block_idx, original_url), result in zip(
-                url_download_jobs, results
-            ):
-                if isinstance(result, str) and result.startswith("data:"):
-                    messages[msg_idx]["content"][block_idx]["image_url"]["url"] = result
-                    converted += 1
-            if converted:
-                self.log.info(
-                    f"[Image Optimization] URL→base64 预转换: {converted}/{len(url_download_jobs)} 张"
-                )
-
-        # 统计优化情况
-        stats = {"kept": 0, "downscaled": 0, "described": 0, "cache_hit": 0}
-
-        # 收集需要降分辨率或尺寸补齐的图片(用于并发处理)
-        process_jobs = []  # [(msg_idx, block_idx, image_url, cache_key, max_size, cache_field)]
-
-        # 第一遍:扫描并收集需要处理的图片
-        for i in range(last_assistant_idx):
-            msg = messages[i]
-            if msg.get("role") != "tool":
-                continue
-
-            content = msg.get("content")
-            if not isinstance(content, list):
-                continue
-
-            rounds_ago = assistant_count_after[i]
-
-            for block_idx, block in enumerate(content):
-                if isinstance(block, dict) and block.get("type") == "image_url":
-                    image_url_obj = block.get("image_url", {})
-                    image_url = image_url_obj.get("url", "")
-
-                    if image_url.startswith("data:"):
-                        cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
-                    else:
-                        cache_key = hashlib.md5(image_url.encode()).hexdigest()
-
-                    # 1-5 轮都需要检查尺寸
-                    if rounds_ago <= 5:
-                        cached = self._image_opt_cache.get(cache_key, {})
-                        cache_field = "pad_only" if rounds_ago <= 2 else "downscaled"
-
-                        if cache_field not in cached and image_url.startswith("data:"):
-                            max_size = None if rounds_ago <= 2 else 512
-                            process_jobs.append(
-                                (
-                                    i,
-                                    block_idx,
-                                    image_url,
-                                    cache_key,
-                                    max_size,
-                                    cache_field,
-                                )
-                            )
-
-        # 并发处理所有尺寸任务
-        if process_jobs:
-            process_results = await asyncio.gather(
-                *[
-                    self._process_image_size(url, max_size=ms)
-                    for _, _, url, _, ms, _ in process_jobs
-                ],
-                return_exceptions=True,
-            )
-            for (_, _, _, cache_key, _, cache_field), result in zip(
-                process_jobs, process_results
-            ):
-                if not isinstance(result, Exception) and result is not None:
-                    self._image_opt_cache.setdefault(cache_key, {})[cache_field] = (
-                        result
-                    )
-
-        # 第二遍:应用处理结果
-        for i in range(last_assistant_idx):
-            msg = messages[i]
-            if msg.get("role") != "tool":
-                continue
-
-            content = msg.get("content")
-            if not isinstance(content, list):
-                continue
-
-            # 计算这条消息距离最后一条 assistant 的"轮次"
-            rounds_ago = assistant_count_after[i]
-
-            # 处理每个 content block
-            new_content = []
-            for block in content:
-                if isinstance(block, dict) and block.get("type") == "image_url":
-                    image_url_obj = block.get("image_url", {})
-                    image_url = image_url_obj.get("url", "")
-
-                    # 生成缓存 key(URL 图片用 URL 本身,base64 用前 64 字符 hash)
-                    if image_url.startswith("data:"):
-                        cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
-                    else:
-                        cache_key = hashlib.md5(image_url.encode()).hexdigest()
-
-                    # 根据距离决定处理策略
-                    if rounds_ago <= 2:
-                        # 最近 1-2 轮:只补齐过小图片,保留原分辨率
-                        cached = self._image_opt_cache.get(cache_key, {})
-                        if "pad_only" in cached:
-                            new_content.append(
-                                {
-                                    "type": "image_url",
-                                    "image_url": {"url": cached["pad_only"]},
-                                }
-                            )
-                            stats["kept"] += 1
-                            stats["cache_hit"] += 1
-                        elif image_url.startswith("data:"):
-                            processed = await self._process_image_size(
-                                image_url, max_size=None
-                            )
-                            if processed:
-                                self._image_opt_cache.setdefault(cache_key, {})[
-                                    "pad_only"
-                                ] = processed
-                                new_content.append(
-                                    {
-                                        "type": "image_url",
-                                        "image_url": {"url": processed},
-                                    }
-                                )
-                            else:
-                                new_content.append(block)
-                            stats["kept"] += 1
-                        else:
-                            new_content.append(block)
-                            stats["kept"] += 1
-
-                    elif rounds_ago <= 5:
-                        # 3-5 轮:降低分辨率(优先从缓存取)
-                        cached = self._image_opt_cache.get(cache_key, {})
-                        if "downscaled" in cached:
-                            new_content.append(
-                                {
-                                    "type": "image_url",
-                                    "image_url": {"url": cached["downscaled"]},
-                                }
-                            )
-                            stats["downscaled"] += 1
-                            stats["cache_hit"] += 1
-                        elif image_url.startswith("data:"):
-                            processed = await self._process_image_size(
-                                image_url, max_size=512
-                            )
-                            if processed:
-                                # 缓存结果
-                                self._image_opt_cache.setdefault(cache_key, {})[
-                                    "downscaled"
-                                ] = processed
-                                new_content.append(
-                                    {
-                                        "type": "image_url",
-                                        "image_url": {"url": processed},
-                                    }
-                                )
-                                stats["downscaled"] += 1
-                            else:
-                                new_content.append(block)
-                                stats["kept"] += 1
-                        else:
-                            # URL 图片:无法直接处理,保留原图
-                            new_content.append(block)
-                            stats["kept"] += 1
-
-                    else:
-                        # 5 轮以上:生成文本描述(优先从缓存取)
-                        cached = self._image_opt_cache.get(cache_key, {})
-                        if "description" in cached:
-                            new_content.append(cached["description"])
-                            stats["described"] += 1
-                            stats["cache_hit"] += 1
-                        else:
-                            description = await self._generate_image_description(
-                                image_url, model
-                            )
-                            url_info = (
-                                f" (URL: {image_url[:100]}...)"
-                                if not image_url.startswith("data:")
-                                else ""
-                            )
-                            desc_block = {
-                                "type": "text",
-                                "text": f"[Image description: {description}]{url_info}",
-                            }
-                            # 缓存结果
-                            self._image_opt_cache.setdefault(cache_key, {})[
-                                "description"
-                            ] = desc_block
-                            new_content.append(desc_block)
-                            stats["described"] += 1
-                else:
-                    new_content.append(block)
-
-            msg["content"] = new_content
-        # print(f"[Image Opt Check] 扫描到 {stats['kept'] + stats['downscaled'] + stats['described']} 张图片上下文")
-        if stats["downscaled"] > 0 or stats["described"] > 0:
-            self.log.info(
-                f"[Image Optimization] 保留 {stats['kept']} 张,"
-                f"降分辨率 {stats['downscaled']} 张,"
-                f"文本描述 {stats['described']} 张,"
-                f"缓存命中 {stats['cache_hit']} 次"
-            )
-
-        return messages
+        return await self._image_runtime._optimize_images(messages, model)
 
     async def _process_image_size(
         self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
     ) -> Optional[str]:
-        """
-        处理 base64 图片的尺寸:
-        - 若 max_size 不为 None 且大于该值,则等比例缩放
-        - 若任意一边小于 min_size,则补充白边 (Padding)
-        """
-        try:
-            from PIL import Image
-            import io
-            import base64
-
-            # 解析 base64 数据
-            if not base64_url.startswith("data:"):
-                return None
-
-            _header, data = base64_url.split(",", 1)
-
-            # 解码图片
-            img_data = base64.b64decode(data)
-            img = Image.open(io.BytesIO(img_data))
-
-            width, height = img.size
-
-            needs_downscale = max_size is not None and (
-                width > max_size or height > max_size
-            )
-            needs_pad = width < min_size or height < min_size
-
-            # 尺寸正常,无需处理
-            if not needs_downscale and not needs_pad:
-                return base64_url
-
-            new_width, new_height = width, height
-
-            # 1. 降分辨率
-            if needs_downscale:
-                if width > height:
-                    new_width = max_size
-                    new_height = int(height * max_size / width)
-                else:
-                    new_height = max_size
-                    new_width = int(width * max_size / height)
-
-            if (new_width, new_height) != (width, height):
-                img_resized = img.resize(
-                    (new_width, new_height), Image.Resampling.BILINEAR
-                )
-            else:
-                img_resized = img
-
-            # 2. 补齐白边 (Padding)
-            pad_width = max(new_width, min_size)
-            pad_height = max(new_height, min_size)
-
-            if pad_width > new_width or pad_height > new_height:
-                # 创建白色背景
-                padded_img = Image.new(
-                    "RGBA" if img_resized.mode in ("RGBA", "P") else "RGB",
-                    (pad_width, pad_height),
-                    (255, 255, 255, 255),
-                )
-                offset_x = (pad_width - new_width) // 2
-                offset_y = (pad_height - new_height) // 2
-                padded_img.paste(img_resized, (offset_x, offset_y))
-                img_resized = padded_img
-
-            # 转换为 RGB(JPEG不支持 RGBA, P 等具有透明度或索引的模式)
-            if img_resized.mode != "RGB":
-                if img_resized.mode == "RGBA" or img_resized.mode == "P":
-                    # Create a white background for transparent images
-                    background = Image.new("RGB", img_resized.size, (255, 255, 255))
-                    if img_resized.mode == "P" and "transparency" in img_resized.info:
-                        img_resized = img_resized.convert("RGBA")
-                    if img_resized.mode == "RGBA":
-                        background.paste(img_resized, mask=img_resized.split()[3])
-                        img_resized = background
-                img_resized = img_resized.convert("RGB")
-
-            # 重新编码为 JPEG(如果只是补齐没有缩放,可以稍微保留高点质量)
-            buffer = io.BytesIO()
-            quality = 60 if needs_downscale else 85
-            img_resized.save(buffer, format="JPEG", quality=quality, optimize=False)
-            new_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
-
-            return f"data:image/jpeg;base64,{new_data}"
-
-        except Exception as e:
-            self.log.warning(f"[Image Process] 处理图片尺寸失败: {e}")
-            return None
+        return await self._image_runtime._process_image_size(
+            base64_url,
+            max_size=max_size,
+            min_size=min_size,
+        )
 
     async def _generate_image_description(
         self, image_url: str, current_model: str
     ) -> str:
-        """
-        使用小模型生成图片的文本描述
-
-        Args:
-            image_url: 图片 URL(base64 或 http(s))
-            current_model: 当前使用的模型
-
-        Returns:
-            图片描述文本
-        """
-        try:
-            # 使用 qwen-vl-max(通义千问视觉模型)生成描述
-            # 注意:qwen-vl 系列专门支持视觉输入
-            description_model = "qwen-vl-max"
-
-            # 构建描述请求
-            messages = [
-                {
-                    "role": "user",
-                    "content": [
-                        {"type": "image_url", "image_url": {"url": image_url}},
-                        {
-                            "type": "text",
-                            "text": "请用 1-2 句话简洁描述这张图片的主要内容。",
-                        },
-                    ],
-                }
-            ]
-
-            # 调用 LLM
-            result = await self.llm_call(
-                messages=messages,
-                model=description_model,
-                tools=None,
-                temperature=0.3,
-            )
-
-            description = result.get("content", "").strip()
-            return description if description else "图片内容"
-
-        except Exception as e:
-            self.log.warning(f"[Image Description] 生成描述失败: {e}")
-            return "图片内容"
+        return await self._image_runtime._generate_image_description(
+            image_url,
+            current_model,
+        )
 
     def _add_cache_control(
         self, messages: List[Dict], model: str, enable: bool
     ) -> List[Dict]:
-        """
-        为支持的模型添加 Prompt Caching 标记
-
-        策略:固定位置 + 延迟缓存
-        1. 如果有未处理的图片(最后一条 assistant 之后的 tool messages 中有图片),跳过缓存
-        2. system message 添加缓存(如果足够长)
-        3. 固定位置缓存点(20, 40, 60, 80),确保每个缓存点间隔 >= 1024 tokens
-        4. 最多使用 4 个缓存点(含 system)
-
-        Args:
-            messages: 原始消息列表
-            model: 模型名称
-            enable: 是否启用缓存
-
-        Returns:
-            添加了 cache_control 的消息列表(深拷贝)
-        """
-        if not enable:
-            return messages
-
-        # 只对 Claude 模型启用
-        if "claude" not in model.lower():
-            return messages
-
-        # 延迟缓存:检查是否有未处理的图片
-        last_assistant_idx = -1
-        for i in range(len(messages) - 1, -1, -1):
-            if messages[i].get("role") == "assistant":
-                last_assistant_idx = i
-                break
-
-        # 检查最后一条 assistant 之后是否有包含图片的 tool messages
-        has_unprocessed_images = False
-        if last_assistant_idx >= 0:
-            for i in range(last_assistant_idx + 1, len(messages)):
-                msg = messages[i]
-                if msg.get("role") == "tool":
-                    content = msg.get("content")
-                    if isinstance(content, list):
-                        has_unprocessed_images = any(
-                            isinstance(block, dict) and block.get("type") == "image_url"
-                            for block in content
-                        )
-                        if has_unprocessed_images:
-                            break
-
-        if has_unprocessed_images:
-            self.log.debug("[Cache] 检测到未处理的图片,延迟缓存建立")
-            return messages
-
-        # 深拷贝避免修改原始数据
-        import copy
-
-        messages = copy.deepcopy(messages)
-
-        # 策略 1: 为 system message 添加缓存
-        system_cached = False
-        for msg in messages:
-            if msg.get("role") == "system":
-                content = msg.get("content", "")
-                if isinstance(content, str) and len(content) > 1000:
-                    msg["content"] = [
-                        {
-                            "type": "text",
-                            "text": content,
-                            "cache_control": {"type": "ephemeral"},
-                        }
-                    ]
-                    system_cached = True
-                    self.log.debug(
-                        f"[Cache] 为 system message 添加缓存标记 (len={len(content)})"
-                    )
-                break
-
-        # 策略 2: 固定位置缓存点
-        CACHE_INTERVAL = 20
-        MAX_POINTS = 3 if system_cached else 4
-        MIN_TOKENS = 1024
-        AVG_TOKENS_PER_MSG = 70
-
-        total_msgs = len(messages)
-        if total_msgs == 0:
-            return messages
-
-        cache_positions = []
-        last_cache_pos = 0
-
-        for i in range(1, MAX_POINTS + 1):
-            target_pos = i * CACHE_INTERVAL - 1  # 19, 39, 59, 79
-
-            if target_pos >= total_msgs:
-                break
-
-            # 从目标位置开始查找合适的 user/assistant 消息
-            for j in range(target_pos, total_msgs):
-                msg = messages[j]
-
-                if msg.get("role") not in ("user", "assistant"):
-                    continue
-
-                content = msg.get("content", "")
-                if not content:
-                    continue
-
-                # 检查 content 是否非空
-                is_valid = False
-                if isinstance(content, str):
-                    is_valid = len(content) > 0
-                elif isinstance(content, list):
-                    is_valid = any(
-                        isinstance(block, dict)
-                        and block.get("type") == "text"
-                        and len(block.get("text", "")) > 0
-                        for block in content
-                    )
-
-                if not is_valid:
-                    continue
-
-                # 检查 token 距离
-                msg_count = j - last_cache_pos
-                estimated_tokens = msg_count * AVG_TOKENS_PER_MSG
-
-                if estimated_tokens >= MIN_TOKENS:
-                    cache_positions.append(j)
-                    last_cache_pos = j
-                    self.log.debug(
-                        f"[Cache] 在位置 {j} 添加缓存点 (估算 {estimated_tokens} tokens)"
-                    )
-                    break
-
-        # 应用缓存标记
-        for idx in cache_positions:
-            msg = messages[idx]
-            content = msg.get("content", "")
-
-            if isinstance(content, str):
-                msg["content"] = [
-                    {
-                        "type": "text",
-                        "text": content,
-                        "cache_control": {"type": "ephemeral"},
-                    }
-                ]
-                self.log.debug(
-                    f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
-                )
-            elif isinstance(content, list):
-                # 在最后一个 text block 添加 cache_control
-                for block in reversed(content):
-                    if isinstance(block, dict) and block.get("type") == "text":
-                        block["cache_control"] = {"type": "ephemeral"}
-                        self.log.debug(
-                            f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
-                        )
-                        break
-
-        self.log.debug(
-            f"[Cache] 总消息: {total_msgs}, "
-            f"缓存点: {len(cache_positions)} at {cache_positions}"
-        )
-        return messages
+        return self._image_runtime._add_cache_control(messages, model, enable)
 
     def _resolve_run_policy(
         self,
         config: RunConfig,
         trace: Optional[Trace] = None,
     ) -> ResolvedAgentPolicy:
-        """Resolve role and effective tools from the preset, never caller context."""
-        preset = get_preset(config.agent_type)
-        policy = self.tool_policy.resolve(config, preset, self.tools)
-        if trace and trace.agent_role != policy.role.value:
-            raise ValueError(
-                f"Trace role mismatch: stored={trace.agent_role}, requested={policy.role.value}"
-            )
-        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
-            if policy.role == AgentRole.LEGACY:
-                raise ValueError(
-                    f"Preset '{config.agent_type}' has legacy role and cannot run explicit_validation"
-                )
-        return policy
+        return self._tool_runtime.resolve_policy(config, trace)
 
     def _get_run_tool_schemas(
         self,
         config: RunConfig,
         policy: Optional[ResolvedAgentPolicy] = None,
     ) -> List[Dict]:
-        policy = policy or self._resolve_run_policy(config)
-        if policy.completion_policy == CompletionPolicy.LEGACY_AUTO:
-            schemas = self._get_tool_schemas(
-                config.tools, config.tool_groups, config.exclude_tools
-            )
-            explicit_only = PLANNER_TOOLS | WORKER_TOOLS | VALIDATOR_TOOLS
-            return [
-                schema
-                for schema in schemas
-                if schema.get("function", {}).get("name") not in explicit_only
-            ]
-        return self.tools.get_schemas(sorted(policy.effective_tools))
+        return self._tool_runtime.get_run_tool_schemas(
+            config,
+            policy or self._resolve_run_policy(config),
+            get_tool_schemas=self._get_tool_schemas,
+        )
 
     def _build_protected_tool_context(
         self,
@@ -3832,31 +3226,16 @@ class AgentRunner:
         sequence: int,
         side_branch: Optional[Dict[str, Any]] = None,
     ) -> Dict[str, Any]:
-        """Merge host context first; framework identity fields always win."""
-        context = dict(config.context or {})
-        trace_context = trace.context or {}
-        context.update(
-            {
-                "store": self.trace_store,
-                "trace_id": trace.trace_id,
-                "root_trace_id": trace_context.get("root_trace_id", trace.trace_id),
-                "goal_id": goal_tree.current_id if goal_tree else None,
-                "runner": self,
-                "goal_tree": goal_tree,
-                "knowledge_config": config.knowledge,
-                "sequence": sequence,
-                "side_branch": side_branch,
-                "role": trace.agent_role,
-                "completion_policy": CompletionPolicy(config.completion_policy).value,
-                "task_id": trace_context.get("task_id"),
-                "spec_version": trace_context.get("spec_version"),
-                "attempt_id": trace_context.get("attempt_id"),
-                "snapshot_id": trace_context.get("snapshot_id"),
-                "validation_id": trace_context.get("validation_id"),
-                "coordinator": self.task_coordinator,
-            }
+        return self._tool_runtime.build_protected_context(
+            config,
+            trace,
+            goal_tree,
+            sequence,
+            runner=self,
+            trace_store=self.trace_store,
+            task_coordinator=self.task_coordinator,
+            side_branch=side_branch,
         )
-        return context
 
     async def _execute_authorized_tool(
         self,
@@ -3869,26 +3248,17 @@ class AgentRunner:
         sequence: int,
         side_branch: Optional[Dict[str, Any]] = None,
     ) -> Any:
-        policy = self._resolve_run_policy(config, trace)
-        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
-            authorization = self.tool_policy.authorize(policy.role, tool_name, policy)
-            if not authorization.allowed:
-                self.log.warning(
-                    "Rejected unauthorized tool call: %s", authorization.reason
-                )
-                return {
-                    "text": f"Error: {authorization.reason}",
-                    "error": "unauthorized_tool",
-                }
-        context = self._build_protected_tool_context(
-            config, trace, goal_tree, sequence, side_branch=side_branch
-        )
-        context["tool_call_id"] = tool_call_id
-        return await self.tools.execute(
+        return await self._tool_runtime.execute_authorized_tool(
             tool_name,
             tool_args,
-            uid=config.uid or "",
-            context=context,
+            tool_call_id,
+            config,
+            trace,
+            goal_tree,
+            sequence,
+            resolve_policy=self._resolve_run_policy,
+            build_context=self._build_protected_tool_context,
+            side_branch=side_branch,
         )
 
     def _get_tool_schemas(
@@ -3897,28 +3267,11 @@ class AgentRunner:
         tool_groups: Optional[List[str]] = None,
         exclude_tools: Optional[List[str]] = None,
     ) -> List[Dict]:
-        """
-        获取工具 Schema
-
-        合并策略(取并集):
-        - tool_groups 非空: 按分组白名单过滤得到基础工具集
-        - tools 非空: 追加指定的工具名(与 tool_groups 结果取并集)
-        - 两者都为 None: 返回所有已注册工具
-
-        最后再用 exclude_tools 减去禁用的工具(如远程 agent 禁止 agent/evaluate)。
-        """
-        if tool_groups is not None:
-            tool_names = set(self.tools.get_tool_names(groups=tool_groups))
-        else:
-            tool_names = set(self.tools.get_tool_names())
-        if tools is not None:
-            tool_names |= set(tools)
-        if exclude_tools:
-            tool_names -= set(exclude_tools)
-        return self.tools.get_schemas(list(tool_names))
-
-    # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
-    # 注意:此常量已迁移到 agent.core.prompts,这里保留引用以保持向后兼容
+        return self._tool_runtime.get_tool_schemas(
+            tools,
+            tool_groups,
+            exclude_tools,
+        )
 
     async def _build_system_prompt(
         self, config: RunConfig, base_prompt: Optional[str] = None
@@ -3940,108 +3293,20 @@ class AgentRunner:
             base_prompt: 已有 system 内容(来自消息),
                          None 时使用 config.system_prompt 或 preset.system_prompt
         """
-        from agent.core.presets import AGENT_PRESETS
-
-        # 确定 system_prompt 来源
-        if base_prompt is not None:
-            system_prompt = base_prompt
-        elif config.system_prompt is not None:
-            system_prompt = config.system_prompt
-        else:
-            # 尝试从 preset 获取 system_prompt
-            preset = AGENT_PRESETS.get(config.agent_type)
-            system_prompt = (
-                preset.system_prompt if preset and preset.system_prompt else None
-            )
-
-        # 确定要加载哪些 skills
-        skills_filter: Optional[List[str]] = config.skills
-        if skills_filter is None:
-            preset = AGENT_PRESETS.get(config.agent_type)
-            if preset is not None:
-                skills_filter = preset.skills  # 可能仍为 None(加载全部)
-
-        # 加载并过滤
-        all_skills = load_skills_from_dir(self.skills_dir)
-        if skills_filter is not None:
-            skills = [s for s in all_skills if s.name in skills_filter]
-        else:
-            skills = all_skills
-
-        skills_text = self._format_skills(skills) if skills else ""
-
-        if system_prompt:
-            if skills_text:
-                system_prompt += f"\n\n## Skills\n{skills_text}"
-        else:
-            system_prompt = DEFAULT_SYSTEM_PREFIX
-            if skills_text:
-                system_prompt += f"\n\n## Skills\n{skills_text}"
-
-        if config.max_iterations and config.max_iterations > 0:
-            system_prompt += f"\n\n## Execution Constraint\n这是一项有严格步数限制的任务。你最多可以用 {config.max_iterations} 轮交互来解决问题。\n请务必【边查边写、随时存档】!每当你收集或得出一个有价值的独立结果(如收集到一个独立 Case),请立刻调用工具写入或追加到结果文件中,绝对不要等到所有任务都做完再最后一次性输出。这样即使触达步数上限被强制打断,你已经收集的成果也能安全保留!"
-        # Memory 注入(memory-bearing Agent)——在 system prompt 末尾追加
-        # 初版选择 system prompt 追加(见 agent/docs/memory.md 待定问题 1)。
-        # 好处:run 启动一次性注入、所有后续轮次都能看到、与 skills 注入方式一致。
-        # 代价:若记忆文件很大会持续占 prompt tokens —— 待观察后决定是否切换方案。
-        if config.memory:
-            try:
-                from agent.core.memory import load_memory_files, format_memory_injection
-
-                files = load_memory_files(config.memory)
-                memory_text = format_memory_injection(files)
-                if memory_text:
-                    system_prompt += f"\n\n{memory_text}"
-            except Exception as e:
-                self.log.warning(f"[Memory] 加载记忆失败,跳过注入: {e}")
-
-        # 框架角色契约最后追加,Host prompt、skills 和 memory 都不能覆盖不变量。
-        policy = self._resolve_run_policy(config)
-        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
-            contract = ROLE_CONTRACTS.get(policy.role.value)
-            if contract:
-                system_prompt += f"\n\n{contract}"
-
-        return system_prompt
+        return await self._prompt_runtime.build_system_prompt(
+            config,
+            base_prompt,
+            skills_dir=self.skills_dir,
+            resolve_policy=self._resolve_run_policy,
+            format_skills=self._format_skills,
+        )
 
     async def _generate_task_name(self, messages: List[Dict]) -> str:
         """生成任务名称:优先使用 utility_llm,fallback 到文本截取"""
-        # 提取 messages 中的文本内容
-        text_parts = []
-        for msg in messages:
-            content = msg.get("content", "")
-            if isinstance(content, str):
-                text_parts.append(content)
-            elif isinstance(content, list):
-                for part in content:
-                    if isinstance(part, dict) and part.get("type") == "text":
-                        text_parts.append(part.get("text", ""))
-        raw_text = " ".join(text_parts).strip()
-
-        if not raw_text:
-            return TASK_NAME_FALLBACK
-
-        # 尝试使用 utility_llm 生成标题
-        if self.utility_llm_call:
-            try:
-                result = await self.utility_llm_call(
-                    messages=[
-                        {
-                            "role": "system",
-                            "content": TASK_NAME_GENERATION_SYSTEM_PROMPT,
-                        },
-                        {"role": "user", "content": raw_text[:2000]},
-                    ],
-                    model="gpt-4o-mini",  # 使用便宜模型
-                )
-                title = result.get("content", "").strip()
-                if title and len(title) < 100:
-                    return title
-            except Exception:
-                pass
-
-        # Fallback: 截取前 50 字符
-        return raw_text[:50] + ("..." if len(raw_text) > 50 else "")
+        return await self._prompt_runtime.generate_task_name(
+            messages,
+            utility_llm_call=self.utility_llm_call,
+        )
 
     def _format_skills(self, skills: List[Skill]) -> str:
         if not skills:

+ 636 - 0
agent/agent/core/runner_images.py

@@ -0,0 +1,636 @@
+"""Image optimization and prompt-cache support for AgentRunner."""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+
+class RunnerImageRuntime:
+    """Own image processing while preserving AgentRunner compatibility hooks."""
+
+    def __init__(self, owner: Any) -> None:
+        self._owner = owner
+
+    @property
+    def _image_opt_cache(self) -> Dict[str, Dict[str, Any]]:
+        return self._owner._image_opt_cache
+
+    @property
+    def log(self):
+        return self._owner.log
+
+    @property
+    def llm_call(self):
+        return self._owner.llm_call
+
+    async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
+        """
+        分级优化已处理的图片,节省 token
+
+        策略(基于图片距离最后一条 assistant 的"轮次"):
+        1. 最近 1-2 轮:保留原图
+        2. 3-5 轮:降低分辨率和压缩(节省 token 但保留视觉信息)
+        3. 5 轮以上:调用小模型生成文本描述 + 保留 URL
+
+        处理结果会缓存,避免重复的 PIL 解码/编码和 LLM 调用。
+
+        Args:
+            messages: 原始消息列表
+            model: 当前使用的模型(用于选择描述生成模型)
+
+        Returns:
+            优化后的消息列表(深拷贝)
+        """
+        if not messages:
+            return messages
+
+        # 找到最后一条 assistant message 的位置
+        last_assistant_idx = -1
+        for i in range(len(messages) - 1, -1, -1):
+            if messages[i].get("role") == "assistant":
+                last_assistant_idx = i
+                break
+
+        # 如果没有 assistant message,说明还没开始对话,不优化
+        if last_assistant_idx == -1:
+            return messages
+
+        # 统计从每个位置到最后一条 assistant 之间的 assistant 数量(作为"轮次")
+        assistant_count_after = [0] * len(messages)
+        count = 0
+        for i in range(len(messages) - 1, -1, -1):
+            assistant_count_after[i] = count
+            if messages[i].get("role") == "assistant":
+                count += 1
+
+        # 深拷贝避免修改原始数据
+        import copy
+        import hashlib
+        import asyncio
+        import base64 as b64mod
+        import httpx
+        import mimetypes
+
+        messages = copy.deepcopy(messages)
+
+        # 预处理:将所有 HTTP(S) URL 图片下载并转为 base64 data URL
+        # Qwen API 无法访问外部签名 URL(如 BFL、火山引擎 TOS),必须在本地转换
+        url_download_jobs = []  # [(msg_idx, block_idx, url)]
+        for i, msg in enumerate(messages):
+            if msg.get("role") != "tool":
+                continue
+            content = msg.get("content")
+            if not isinstance(content, list):
+                continue
+            for block_idx, block in enumerate(content):
+                if isinstance(block, dict) and block.get("type") == "image_url":
+                    url = block.get("image_url", {}).get("url", "")
+                    if url.startswith(("http://", "https://")):
+                        url_download_jobs.append((i, block_idx, url))
+
+        if url_download_jobs:
+
+            async def _download_image_to_data_url(url: str) -> str | None:
+                try:
+                    async with httpx.AsyncClient(timeout=60, trust_env=False) as client:
+                        resp = await client.get(url)
+                        resp.raise_for_status()
+                        ct = resp.headers.get("content-type", "").split(";")[0].strip()
+                        if not ct.startswith("image/"):
+                            ct = (
+                                mimetypes.guess_type(url.split("?")[0])[0]
+                                or "image/png"
+                            )
+                        b64 = b64mod.b64encode(resp.content).decode()
+                        return f"data:{ct};base64,{b64}"
+                except Exception:
+                    self.log.debug(
+                        "Remote image normalization failed: %s", url, exc_info=True
+                    )
+                    return None
+
+            results = await asyncio.gather(
+                *[_download_image_to_data_url(url) for _, _, url in url_download_jobs],
+                return_exceptions=True,
+            )
+            converted = 0
+            for (msg_idx, block_idx, original_url), result in zip(
+                url_download_jobs, results
+            ):
+                if isinstance(result, str) and result.startswith("data:"):
+                    messages[msg_idx]["content"][block_idx]["image_url"]["url"] = result
+                    converted += 1
+            if converted:
+                self.log.info(
+                    f"[Image Optimization] URL→base64 预转换: {converted}/{len(url_download_jobs)} 张"
+                )
+
+        # 统计优化情况
+        stats = {"kept": 0, "downscaled": 0, "described": 0, "cache_hit": 0}
+
+        # 收集需要降分辨率或尺寸补齐的图片(用于并发处理)
+        process_jobs = []  # [(msg_idx, block_idx, image_url, cache_key, max_size, cache_field)]
+
+        # 第一遍:扫描并收集需要处理的图片
+        for i in range(last_assistant_idx):
+            msg = messages[i]
+            if msg.get("role") != "tool":
+                continue
+
+            content = msg.get("content")
+            if not isinstance(content, list):
+                continue
+
+            rounds_ago = assistant_count_after[i]
+
+            for block_idx, block in enumerate(content):
+                if isinstance(block, dict) and block.get("type") == "image_url":
+                    image_url_obj = block.get("image_url", {})
+                    image_url = image_url_obj.get("url", "")
+
+                    if image_url.startswith("data:"):
+                        cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
+                    else:
+                        cache_key = hashlib.md5(image_url.encode()).hexdigest()
+
+                    # 1-5 轮都需要检查尺寸
+                    if rounds_ago <= 5:
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        cache_field = "pad_only" if rounds_ago <= 2 else "downscaled"
+
+                        if cache_field not in cached and image_url.startswith("data:"):
+                            max_size = None if rounds_ago <= 2 else 512
+                            process_jobs.append(
+                                (
+                                    i,
+                                    block_idx,
+                                    image_url,
+                                    cache_key,
+                                    max_size,
+                                    cache_field,
+                                )
+                            )
+
+        # 并发处理所有尺寸任务
+        if process_jobs:
+            process_results = await asyncio.gather(
+                *[
+                    self._owner._process_image_size(url, max_size=ms)
+                    for _, _, url, _, ms, _ in process_jobs
+                ],
+                return_exceptions=True,
+            )
+            for (_, _, _, cache_key, _, cache_field), result in zip(
+                process_jobs, process_results
+            ):
+                if not isinstance(result, Exception) and result is not None:
+                    self._image_opt_cache.setdefault(cache_key, {})[cache_field] = (
+                        result
+                    )
+
+        # 第二遍:应用处理结果
+        for i in range(last_assistant_idx):
+            msg = messages[i]
+            if msg.get("role") != "tool":
+                continue
+
+            content = msg.get("content")
+            if not isinstance(content, list):
+                continue
+
+            # 计算这条消息距离最后一条 assistant 的"轮次"
+            rounds_ago = assistant_count_after[i]
+
+            # 处理每个 content block
+            new_content = []
+            for block in content:
+                if isinstance(block, dict) and block.get("type") == "image_url":
+                    image_url_obj = block.get("image_url", {})
+                    image_url = image_url_obj.get("url", "")
+
+                    # 生成缓存 key(URL 图片用 URL 本身,base64 用前 64 字符 hash)
+                    if image_url.startswith("data:"):
+                        cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
+                    else:
+                        cache_key = hashlib.md5(image_url.encode()).hexdigest()
+
+                    # 根据距离决定处理策略
+                    if rounds_ago <= 2:
+                        # 最近 1-2 轮:只补齐过小图片,保留原分辨率
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        if "pad_only" in cached:
+                            new_content.append(
+                                {
+                                    "type": "image_url",
+                                    "image_url": {"url": cached["pad_only"]},
+                                }
+                            )
+                            stats["kept"] += 1
+                            stats["cache_hit"] += 1
+                        elif image_url.startswith("data:"):
+                            processed = await self._owner._process_image_size(
+                                image_url, max_size=None
+                            )
+                            if processed:
+                                self._image_opt_cache.setdefault(cache_key, {})[
+                                    "pad_only"
+                                ] = processed
+                                new_content.append(
+                                    {
+                                        "type": "image_url",
+                                        "image_url": {"url": processed},
+                                    }
+                                )
+                            else:
+                                new_content.append(block)
+                            stats["kept"] += 1
+                        else:
+                            new_content.append(block)
+                            stats["kept"] += 1
+
+                    elif rounds_ago <= 5:
+                        # 3-5 轮:降低分辨率(优先从缓存取)
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        if "downscaled" in cached:
+                            new_content.append(
+                                {
+                                    "type": "image_url",
+                                    "image_url": {"url": cached["downscaled"]},
+                                }
+                            )
+                            stats["downscaled"] += 1
+                            stats["cache_hit"] += 1
+                        elif image_url.startswith("data:"):
+                            processed = await self._owner._process_image_size(
+                                image_url, max_size=512
+                            )
+                            if processed:
+                                # 缓存结果
+                                self._image_opt_cache.setdefault(cache_key, {})[
+                                    "downscaled"
+                                ] = processed
+                                new_content.append(
+                                    {
+                                        "type": "image_url",
+                                        "image_url": {"url": processed},
+                                    }
+                                )
+                                stats["downscaled"] += 1
+                            else:
+                                new_content.append(block)
+                                stats["kept"] += 1
+                        else:
+                            # URL 图片:无法直接处理,保留原图
+                            new_content.append(block)
+                            stats["kept"] += 1
+
+                    else:
+                        # 5 轮以上:生成文本描述(优先从缓存取)
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        if "description" in cached:
+                            new_content.append(cached["description"])
+                            stats["described"] += 1
+                            stats["cache_hit"] += 1
+                        else:
+                            description = await self._owner._generate_image_description(
+                                image_url, model
+                            )
+                            url_info = (
+                                f" (URL: {image_url[:100]}...)"
+                                if not image_url.startswith("data:")
+                                else ""
+                            )
+                            desc_block = {
+                                "type": "text",
+                                "text": f"[Image description: {description}]{url_info}",
+                            }
+                            # 缓存结果
+                            self._image_opt_cache.setdefault(cache_key, {})[
+                                "description"
+                            ] = desc_block
+                            new_content.append(desc_block)
+                            stats["described"] += 1
+                else:
+                    new_content.append(block)
+
+            msg["content"] = new_content
+        if stats["downscaled"] > 0 or stats["described"] > 0:
+            self.log.info(
+                f"[Image Optimization] 保留 {stats['kept']} 张,"
+                f"降分辨率 {stats['downscaled']} 张,"
+                f"文本描述 {stats['described']} 张,"
+                f"缓存命中 {stats['cache_hit']} 次"
+            )
+
+        return messages
+
+    async def _process_image_size(
+        self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
+    ) -> Optional[str]:
+        """
+        处理 base64 图片的尺寸:
+        - 若 max_size 不为 None 且大于该值,则等比例缩放
+        - 若任意一边小于 min_size,则补充白边 (Padding)
+        """
+        try:
+            from PIL import Image
+            import io
+            import base64
+
+            # 解析 base64 数据
+            if not base64_url.startswith("data:"):
+                return None
+
+            _header, data = base64_url.split(",", 1)
+
+            # 解码图片
+            img_data = base64.b64decode(data)
+            img = Image.open(io.BytesIO(img_data))
+
+            width, height = img.size
+
+            needs_downscale = max_size is not None and (
+                width > max_size or height > max_size
+            )
+            needs_pad = width < min_size or height < min_size
+
+            # 尺寸正常,无需处理
+            if not needs_downscale and not needs_pad:
+                return base64_url
+
+            new_width, new_height = width, height
+
+            # 1. 降分辨率
+            if needs_downscale:
+                if width > height:
+                    new_width = max_size
+                    new_height = int(height * max_size / width)
+                else:
+                    new_height = max_size
+                    new_width = int(width * max_size / height)
+
+            if (new_width, new_height) != (width, height):
+                img_resized = img.resize(
+                    (new_width, new_height), Image.Resampling.BILINEAR
+                )
+            else:
+                img_resized = img
+
+            # 2. 补齐白边 (Padding)
+            pad_width = max(new_width, min_size)
+            pad_height = max(new_height, min_size)
+
+            if pad_width > new_width or pad_height > new_height:
+                # 创建白色背景
+                padded_img = Image.new(
+                    "RGBA" if img_resized.mode in ("RGBA", "P") else "RGB",
+                    (pad_width, pad_height),
+                    (255, 255, 255, 255),
+                )
+                offset_x = (pad_width - new_width) // 2
+                offset_y = (pad_height - new_height) // 2
+                padded_img.paste(img_resized, (offset_x, offset_y))
+                img_resized = padded_img
+
+            # 转换为 RGB(JPEG不支持 RGBA, P 等具有透明度或索引的模式)
+            if img_resized.mode != "RGB":
+                if img_resized.mode == "RGBA" or img_resized.mode == "P":
+                    # Create a white background for transparent images
+                    background = Image.new("RGB", img_resized.size, (255, 255, 255))
+                    if img_resized.mode == "P" and "transparency" in img_resized.info:
+                        img_resized = img_resized.convert("RGBA")
+                    if img_resized.mode == "RGBA":
+                        background.paste(img_resized, mask=img_resized.split()[3])
+                        img_resized = background
+                img_resized = img_resized.convert("RGB")
+
+            # 重新编码为 JPEG(如果只是补齐没有缩放,可以稍微保留高点质量)
+            buffer = io.BytesIO()
+            quality = 60 if needs_downscale else 85
+            img_resized.save(buffer, format="JPEG", quality=quality, optimize=False)
+            new_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
+
+            return f"data:image/jpeg;base64,{new_data}"
+
+        except Exception as e:
+            self.log.warning(f"[Image Process] 处理图片尺寸失败: {e}")
+            return None
+
+    async def _generate_image_description(
+        self, image_url: str, current_model: str
+    ) -> str:
+        """
+        使用小模型生成图片的文本描述
+
+        Args:
+            image_url: 图片 URL(base64 或 http(s))
+            current_model: 当前使用的模型
+
+        Returns:
+            图片描述文本
+        """
+        # The parameter remains part of AgentRunner's compatibility hook; the
+        # description itself always uses the dedicated vision model below.
+        del current_model
+        try:
+            # 使用 qwen-vl-max(通义千问视觉模型)生成描述
+            # 注意:qwen-vl 系列专门支持视觉输入
+            description_model = "qwen-vl-max"
+
+            # 构建描述请求
+            messages = [
+                {
+                    "role": "user",
+                    "content": [
+                        {"type": "image_url", "image_url": {"url": image_url}},
+                        {
+                            "type": "text",
+                            "text": "请用 1-2 句话简洁描述这张图片的主要内容。",
+                        },
+                    ],
+                }
+            ]
+
+            # 调用 LLM
+            result = await self.llm_call(
+                messages=messages,
+                model=description_model,
+                tools=None,
+                temperature=0.3,
+            )
+
+            description = result.get("content", "").strip()
+            return description if description else "图片内容"
+
+        except Exception as e:
+            self.log.warning(f"[Image Description] 生成描述失败: {e}")
+            return "图片内容"
+
+    def _add_cache_control(
+        self, messages: List[Dict], model: str, enable: bool
+    ) -> List[Dict]:
+        """
+        为支持的模型添加 Prompt Caching 标记
+
+        策略:固定位置 + 延迟缓存
+        1. 如果有未处理的图片(最后一条 assistant 之后的 tool messages 中有图片),跳过缓存
+        2. system message 添加缓存(如果足够长)
+        3. 固定位置缓存点(20, 40, 60, 80),确保每个缓存点间隔 >= 1024 tokens
+        4. 最多使用 4 个缓存点(含 system)
+
+        Args:
+            messages: 原始消息列表
+            model: 模型名称
+            enable: 是否启用缓存
+
+        Returns:
+            添加了 cache_control 的消息列表(深拷贝)
+        """
+        if not enable:
+            return messages
+
+        # 只对 Claude 模型启用
+        if "claude" not in model.lower():
+            return messages
+
+        # 延迟缓存:检查是否有未处理的图片
+        last_assistant_idx = -1
+        for i in range(len(messages) - 1, -1, -1):
+            if messages[i].get("role") == "assistant":
+                last_assistant_idx = i
+                break
+
+        # 检查最后一条 assistant 之后是否有包含图片的 tool messages
+        has_unprocessed_images = False
+        if last_assistant_idx >= 0:
+            for i in range(last_assistant_idx + 1, len(messages)):
+                msg = messages[i]
+                if msg.get("role") == "tool":
+                    content = msg.get("content")
+                    if isinstance(content, list):
+                        has_unprocessed_images = any(
+                            isinstance(block, dict) and block.get("type") == "image_url"
+                            for block in content
+                        )
+                        if has_unprocessed_images:
+                            break
+
+        if has_unprocessed_images:
+            self.log.debug("[Cache] 检测到未处理的图片,延迟缓存建立")
+            return messages
+
+        # 深拷贝避免修改原始数据
+        import copy
+
+        messages = copy.deepcopy(messages)
+
+        # 策略 1: 为 system message 添加缓存
+        system_cached = False
+        for msg in messages:
+            if msg.get("role") == "system":
+                content = msg.get("content", "")
+                if isinstance(content, str) and len(content) > 1000:
+                    msg["content"] = [
+                        {
+                            "type": "text",
+                            "text": content,
+                            "cache_control": {"type": "ephemeral"},
+                        }
+                    ]
+                    system_cached = True
+                    self.log.debug(
+                        f"[Cache] 为 system message 添加缓存标记 (len={len(content)})"
+                    )
+                break
+
+        # 策略 2: 固定位置缓存点
+        CACHE_INTERVAL = 20
+        MAX_POINTS = 3 if system_cached else 4
+        MIN_TOKENS = 1024
+        AVG_TOKENS_PER_MSG = 70
+
+        total_msgs = len(messages)
+        if total_msgs == 0:
+            return messages
+
+        cache_positions = []
+        last_cache_pos = 0
+
+        for i in range(1, MAX_POINTS + 1):
+            target_pos = i * CACHE_INTERVAL - 1  # 19, 39, 59, 79
+
+            if target_pos >= total_msgs:
+                break
+
+            # 从目标位置开始查找合适的 user/assistant 消息
+            for j in range(target_pos, total_msgs):
+                msg = messages[j]
+
+                if msg.get("role") not in ("user", "assistant"):
+                    continue
+
+                content = msg.get("content", "")
+                if not content:
+                    continue
+
+                # 检查 content 是否非空
+                is_valid = False
+                if isinstance(content, str):
+                    is_valid = len(content) > 0
+                elif isinstance(content, list):
+                    is_valid = any(
+                        isinstance(block, dict)
+                        and block.get("type") == "text"
+                        and len(block.get("text", "")) > 0
+                        for block in content
+                    )
+
+                if not is_valid:
+                    continue
+
+                # 检查 token 距离
+                msg_count = j - last_cache_pos
+                estimated_tokens = msg_count * AVG_TOKENS_PER_MSG
+
+                if estimated_tokens >= MIN_TOKENS:
+                    cache_positions.append(j)
+                    last_cache_pos = j
+                    self.log.debug(
+                        f"[Cache] 在位置 {j} 添加缓存点 (估算 {estimated_tokens} tokens)"
+                    )
+                    break
+
+        # 应用缓存标记
+        for idx in cache_positions:
+            msg = messages[idx]
+            content = msg.get("content", "")
+
+            if isinstance(content, str):
+                msg["content"] = [
+                    {
+                        "type": "text",
+                        "text": content,
+                        "cache_control": {"type": "ephemeral"},
+                    }
+                ]
+                self.log.debug(
+                    f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
+                )
+            elif isinstance(content, list):
+                # 在最后一个 text block 添加 cache_control
+                for block in reversed(content):
+                    if isinstance(block, dict) and block.get("type") == "text":
+                        block["cache_control"] = {"type": "ephemeral"}
+                        self.log.debug(
+                            f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
+                        )
+                        break
+
+        self.log.debug(
+            f"[Cache] 总消息: {total_msgs}, "
+            f"缓存点: {len(cache_positions)} at {cache_positions}"
+        )
+        return messages
+
+
+__all__ = ["RunnerImageRuntime"]

+ 140 - 0
agent/agent/core/runner_prompts.py

@@ -0,0 +1,140 @@
+"""Prompt assembly and task naming for ``AgentRunner``."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Awaitable, Callable, Dict, List, Optional
+
+from agent.core.presets import AGENT_PRESETS
+from agent.core.prompts import (
+    DEFAULT_SYSTEM_PREFIX,
+    ROLE_CONTRACTS,
+    TASK_NAME_FALLBACK,
+    TASK_NAME_GENERATION_SYSTEM_PROMPT,
+)
+from agent.orchestration.models import CompletionPolicy
+from agent.skill.models import Skill
+from agent.skill.skill_loader import load_skills_from_dir
+
+
+class RunnerPromptRuntime:
+    """Build prompts while leaving Agent loop state in ``AgentRunner``."""
+
+    def __init__(
+        self,
+        *,
+        logger: logging.Logger,
+    ) -> None:
+        self._log = logger
+
+    async def build_system_prompt(
+        self,
+        config: Any,
+        base_prompt: Optional[str],
+        *,
+        skills_dir: Optional[str],
+        resolve_policy: Callable[[Any], Any],
+        format_skills: Callable[[List[Skill]], str],
+    ) -> Optional[str]:
+        if base_prompt is not None:
+            system_prompt = base_prompt
+        elif config.system_prompt is not None:
+            system_prompt = config.system_prompt
+        else:
+            preset = AGENT_PRESETS.get(config.agent_type)
+            system_prompt = (
+                preset.system_prompt if preset and preset.system_prompt else None
+            )
+
+        skills_filter: Optional[List[str]] = config.skills
+        if skills_filter is None:
+            preset = AGENT_PRESETS.get(config.agent_type)
+            if preset is not None:
+                skills_filter = preset.skills
+
+        all_skills = load_skills_from_dir(skills_dir)
+        if skills_filter is not None:
+            skills = [skill for skill in all_skills if skill.name in skills_filter]
+        else:
+            skills = all_skills
+        skills_text = format_skills(skills) if skills else ""
+
+        if system_prompt:
+            if skills_text:
+                system_prompt += f"\n\n## Skills\n{skills_text}"
+        else:
+            system_prompt = DEFAULT_SYSTEM_PREFIX
+            if skills_text:
+                system_prompt += f"\n\n## Skills\n{skills_text}"
+
+        if config.max_iterations and config.max_iterations > 0:
+            system_prompt += (
+                "\n\n## Execution Constraint\n"
+                f"这是一项有严格步数限制的任务。你最多可以用 {config.max_iterations} "
+                "轮交互来解决问题。\n请务必【边查边写、随时存档】!每当你收集或得出一个"
+                "有价值的独立结果(如收集到一个独立 Case),请立刻调用工具写入或追加"
+                "到结果文件中,绝对不要等到所有任务都做完再最后一次性输出。这样"
+                "即使触达步数上限被强制打断,你已经收集的成果也能安全保留!"
+            )
+
+        if config.memory:
+            try:
+                from agent.core.memory import format_memory_injection, load_memory_files
+
+                files = load_memory_files(config.memory)
+                memory_text = format_memory_injection(files)
+                if memory_text:
+                    system_prompt += f"\n\n{memory_text}"
+            except Exception as exc:
+                self._log.warning("[Memory] 加载记忆失败,跳过注入: %s", exc)
+
+        policy = resolve_policy(config)
+        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
+            contract = ROLE_CONTRACTS.get(policy.role.value)
+            if contract:
+                system_prompt += f"\n\n{contract}"
+        return system_prompt
+
+    async def generate_task_name(
+        self,
+        messages: List[Dict[str, Any]],
+        *,
+        utility_llm_call: Optional[Callable[..., Awaitable[Dict[str, Any]]]],
+    ) -> str:
+        text_parts: List[str] = []
+        for message in messages:
+            content = message.get("content", "")
+            if isinstance(content, str):
+                text_parts.append(content)
+            elif isinstance(content, list):
+                for part in content:
+                    if isinstance(part, dict) and part.get("type") == "text":
+                        text_parts.append(part.get("text", ""))
+        raw_text = " ".join(text_parts).strip()
+        if not raw_text:
+            return TASK_NAME_FALLBACK
+
+        if utility_llm_call:
+            try:
+                result = await utility_llm_call(
+                    messages=[
+                        {
+                            "role": "system",
+                            "content": TASK_NAME_GENERATION_SYSTEM_PROMPT,
+                        },
+                        {"role": "user", "content": raw_text[:2000]},
+                    ],
+                    model="gpt-4o-mini",
+                )
+                title = result.get("content", "").strip()
+                if title and len(title) < 100:
+                    return title
+            except Exception:
+                self._log.debug(
+                    "Utility model failed to generate a task name",
+                    exc_info=True,
+                )
+        return raw_text[:50] + ("..." if len(raw_text) > 50 else "")
+
+
+__all__ = ["RunnerPromptRuntime"]

+ 187 - 0
agent/agent/core/runner_tools.py

@@ -0,0 +1,187 @@
+"""Role-aware tool execution support for :mod:`agent.core.runner`.
+
+This module keeps tool selection, authorization, and protected context
+construction together.  ``AgentRunner`` remains the public facade and delegates
+to this internal component so existing callers and subclass overrides keep the
+same entry points.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Callable, Dict, List, Optional
+
+from agent.core.presets import get_preset
+from agent.orchestration.models import AgentRole, CompletionPolicy
+from agent.orchestration.policy import (
+    PLANNER_TOOLS,
+    VALIDATOR_TOOLS,
+    WORKER_TOOLS,
+    ResolvedAgentPolicy,
+)
+from agent.tools import ToolRegistry
+from agent.trace.goal_models import GoalTree
+from agent.trace.models import Trace
+
+
+class RunnerToolRuntime:
+    """Resolve and execute tools without owning the Agent loop."""
+
+    def __init__(
+        self,
+        registry: ToolRegistry,
+        policy: Any,
+        logger: logging.Logger,
+    ) -> None:
+        self._registry = registry
+        self._policy = policy
+        self._log = logger
+
+    def resolve_policy(
+        self,
+        config: Any,
+        trace: Optional[Trace] = None,
+    ) -> ResolvedAgentPolicy:
+        """Resolve role and effective tools from the preset, never host context."""
+        preset = get_preset(config.agent_type)
+        resolved = self._policy.resolve(config, preset, self._registry)
+        if trace and trace.agent_role != resolved.role.value:
+            raise ValueError(
+                "Trace role mismatch: "
+                f"stored={trace.agent_role}, requested={resolved.role.value}"
+            )
+        if (
+            resolved.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION
+            and resolved.role == AgentRole.LEGACY
+        ):
+            raise ValueError(
+                f"Preset '{config.agent_type}' has legacy role and cannot run "
+                "explicit_validation"
+            )
+        return resolved
+
+    def get_run_tool_schemas(
+        self,
+        config: Any,
+        resolved: Optional[ResolvedAgentPolicy] = None,
+        *,
+        get_tool_schemas: Optional[Callable[..., List[Dict[str, Any]]]] = None,
+    ) -> List[Dict[str, Any]]:
+        resolved = resolved or self.resolve_policy(config)
+        if resolved.completion_policy == CompletionPolicy.LEGACY_AUTO:
+            schema_resolver = get_tool_schemas or self.get_tool_schemas
+            schemas = schema_resolver(
+                config.tools,
+                config.tool_groups,
+                config.exclude_tools,
+            )
+            explicit_only = PLANNER_TOOLS | WORKER_TOOLS | VALIDATOR_TOOLS
+            return [
+                schema
+                for schema in schemas
+                if schema.get("function", {}).get("name") not in explicit_only
+            ]
+        return self._registry.get_schemas(sorted(resolved.effective_tools))
+
+    def get_tool_schemas(
+        self,
+        tools: Optional[List[str]] = None,
+        tool_groups: Optional[List[str]] = None,
+        exclude_tools: Optional[List[str]] = None,
+    ) -> List[Dict[str, Any]]:
+        """Return the legacy union of tool groups and explicit tool names."""
+        if tool_groups is not None:
+            tool_names = set(self._registry.get_tool_names(groups=tool_groups))
+        else:
+            tool_names = set(self._registry.get_tool_names())
+        if tools is not None:
+            tool_names.update(tools)
+        if exclude_tools:
+            tool_names.difference_update(exclude_tools)
+        return self._registry.get_schemas(list(tool_names))
+
+    @staticmethod
+    def build_protected_context(
+        config: Any,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+        sequence: int,
+        *,
+        runner: Any,
+        trace_store: Any,
+        task_coordinator: Any,
+        side_branch: Optional[Dict[str, Any]] = None,
+    ) -> Dict[str, Any]:
+        """Merge host context first so framework identity always wins."""
+        context = dict(config.context or {})
+        trace_context = trace.context or {}
+        context.update(
+            {
+                "store": trace_store,
+                "trace_id": trace.trace_id,
+                "root_trace_id": trace_context.get("root_trace_id", trace.trace_id),
+                "goal_id": goal_tree.current_id if goal_tree else None,
+                "runner": runner,
+                "goal_tree": goal_tree,
+                "knowledge_config": config.knowledge,
+                "sequence": sequence,
+                "side_branch": side_branch,
+                "role": trace.agent_role,
+                "completion_policy": CompletionPolicy(config.completion_policy).value,
+                "task_id": trace_context.get("task_id"),
+                "spec_version": trace_context.get("spec_version"),
+                "attempt_id": trace_context.get("attempt_id"),
+                "snapshot_id": trace_context.get("snapshot_id"),
+                "validation_id": trace_context.get("validation_id"),
+                "coordinator": task_coordinator,
+            }
+        )
+        return context
+
+    async def execute_authorized_tool(
+        self,
+        tool_name: str,
+        tool_args: Dict[str, Any],
+        tool_call_id: str,
+        config: Any,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+        sequence: int,
+        *,
+        resolve_policy: Callable[[Any, Optional[Trace]], ResolvedAgentPolicy],
+        build_context: Callable[..., Dict[str, Any]],
+        side_branch: Optional[Dict[str, Any]] = None,
+    ) -> Any:
+        resolved = resolve_policy(config, trace)
+        if resolved.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
+            authorization = self._policy.authorize(
+                resolved.role,
+                tool_name,
+                resolved,
+            )
+            if not authorization.allowed:
+                self._log.warning(
+                    "Rejected unauthorized tool call: %s",
+                    authorization.reason,
+                )
+                return {
+                    "text": f"Error: {authorization.reason}",
+                    "error": "unauthorized_tool",
+                }
+        context = build_context(
+            config,
+            trace,
+            goal_tree,
+            sequence,
+            side_branch=side_branch,
+        )
+        context["tool_call_id"] = tool_call_id
+        return await self._registry.execute(
+            tool_name,
+            tool_args,
+            uid=config.uid or "",
+            context=context,
+        )
+
+
+__all__ = ["RunnerToolRuntime"]

+ 21 - 0
agent/agent/debug/__init__.py

@@ -0,0 +1,21 @@
+"""Development and diagnostics helpers kept outside the runtime core."""
+
+from .tree_dump import (
+    DEFAULT_DUMP_PATH,
+    DEFAULT_JSON_PATH,
+    DEFAULT_MD_PATH,
+    StepTreeDumper,
+    dump_json,
+    dump_markdown,
+    dump_tree,
+)
+
+__all__ = [
+    "DEFAULT_DUMP_PATH",
+    "DEFAULT_JSON_PATH",
+    "DEFAULT_MD_PATH",
+    "StepTreeDumper",
+    "dump_tree",
+    "dump_json",
+    "dump_markdown",
+]

+ 825 - 0
agent/agent/debug/tree_dump.py

@@ -0,0 +1,825 @@
+"""
+Step 树 Debug 输出
+
+将 Step 树以完整格式输出到文件,便于开发调试。
+
+使用方式:
+    1. 命令行实时查看:
+       watch -n 0.5 cat .trace/tree.txt
+
+    2. VS Code 打开文件自动刷新:
+       code .trace/tree.txt
+
+    3. 代码中使用:
+       from agent.trace import dump_tree
+       dump_tree(trace, steps)
+"""
+
+import json
+from datetime import datetime
+from pathlib import Path
+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:
+    """Step 树 Debug 输出器"""
+
+    def __init__(self, output_path: str = DEFAULT_DUMP_PATH):
+        self.output_path = Path(output_path)
+        self.output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    def dump(
+        self,
+        trace: Optional[Dict[str, Any]] = None,
+        steps: Optional[List[Dict[str, Any]]] = None,
+        title: str = "Step Tree Debug",
+    ) -> str:
+        """
+        输出完整的树形结构到文件
+
+        Args:
+            trace: Trace 字典(可选)
+            steps: Step 字典列表
+            title: 输出标题
+
+        Returns:
+            输出的文本内容
+        """
+        lines = []
+
+        # 标题和时间
+        lines.append("=" * 60)
+        lines.append(f" {title}")
+        lines.append(f" Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+        lines.append("=" * 60)
+        lines.append("")
+
+        # Trace 信息
+        if trace:
+            lines.append("## Trace")
+            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("")
+
+        # 统计摘要
+        if steps:
+            lines.append("## Statistics")
+            stats = self._calculate_statistics(steps)
+            lines.append(f"  Total steps: {stats['total']}")
+            lines.append("  By type:")
+            for step_type, count in sorted(stats['by_type'].items()):
+                lines.append(f"    {step_type}: {count}")
+            lines.append("  By status:")
+            for status, count in sorted(stats['by_status'].items()):
+                lines.append(f"    {status}: {count}")
+            if stats['total_duration_ms'] > 0:
+                lines.append(f"  Total duration: {stats['total_duration_ms']}ms")
+            if stats['total_tokens'] > 0:
+                lines.append(f"  Total tokens: {stats['total_tokens']}")
+            if stats['total_cost'] > 0:
+                lines.append(f"  Total cost: ${stats['total_cost']:.4f}")
+            lines.append("")
+
+        # Step 树
+        if steps:
+            lines.append("## Steps")
+            lines.append("")
+
+            # 构建树结构
+            tree = self._build_tree(steps)
+            tree_output = self._render_tree(tree, steps)
+            lines.append(tree_output)
+
+        content = "\n".join(lines)
+
+        # 写入文件
+        self.output_path.write_text(content, encoding="utf-8")
+
+        return content
+
+    def _calculate_statistics(self, steps: List[Dict[str, Any]]) -> Dict[str, Any]:
+        """计算统计信息"""
+        stats = {
+            'total': len(steps),
+            'by_type': {},
+            'by_status': {},
+            'total_duration_ms': 0,
+            'total_tokens': 0,
+            'total_cost': 0.0,
+        }
+
+        for step in steps:
+            # 按类型统计
+            step_type = step.get('step_type', 'unknown')
+            stats['by_type'][step_type] = stats['by_type'].get(step_type, 0) + 1
+
+            # 按状态统计
+            status = step.get('status', 'unknown')
+            stats['by_status'][status] = stats['by_status'].get(status, 0) + 1
+
+            # 累计指标
+            if step.get('duration_ms'):
+                stats['total_duration_ms'] += step.get('duration_ms', 0)
+            if step.get('tokens'):
+                stats['total_tokens'] += step.get('tokens', 0)
+            if step.get('cost'):
+                stats['total_cost'] += step.get('cost', 0.0)
+
+        return stats
+
+    def _build_tree(self, steps: List[Dict[str, Any]]) -> Dict[str, List[str]]:
+        """构建父子关系映射"""
+        # parent_id -> [child_ids]
+        children: Dict[str, List[str]] = {"__root__": []}
+
+        for step in steps:
+            step_id = step.get("step_id", "")
+            parent_id = step.get("parent_id")
+
+            if parent_id is None:
+                children["__root__"].append(step_id)
+            else:
+                if parent_id not in children:
+                    children[parent_id] = []
+                children[parent_id].append(step_id)
+
+        return children
+
+    def _render_tree(
+        self,
+        tree: Dict[str, List[str]],
+        steps: List[Dict[str, Any]],
+        parent_id: str = "__root__",
+        indent: int = 0,
+    ) -> str:
+        """递归渲染树结构"""
+        # step_id -> step 映射
+        step_map = {s.get("step_id"): s for s in steps}
+
+        lines = []
+        child_ids = tree.get(parent_id, [])
+
+        for i, step_id in enumerate(child_ids):
+            step = step_map.get(step_id, {})
+            is_last = i == len(child_ids) - 1
+
+            # 渲染当前节点
+            node_output = self._render_node(step, indent, is_last)
+            lines.append(node_output)
+
+            # 递归渲染子节点
+            if step_id in tree:
+                child_output = self._render_tree(tree, steps, step_id, indent + 1)
+                lines.append(child_output)
+
+        return "\n".join(lines)
+
+    def _render_node(self, step: Dict[str, Any], indent: int, is_last: bool) -> str:
+        """渲染单个节点的完整信息"""
+        lines = []
+
+        # 缩进和连接符
+        prefix = "  " * indent
+        connector = "└── " if is_last else "├── "
+        child_prefix = "  " * indent + ("    " if is_last else "│   ")
+
+        # 状态图标
+        status = step.get("status", "unknown")
+        status_icons = {
+            "completed": "✓",
+            "in_progress": "→",
+            "planned": "○",
+            "failed": "✗",
+            "skipped": "⊘",
+            "awaiting_approval": "⏸",
+        }
+        icon = status_icons.get(status, "?")
+
+        # 类型和描述
+        step_type = step.get("step_type", "unknown")
+        description = step.get("description", "")
+
+        # 第一行:类型和描述
+        lines.append(f"{prefix}{connector}[{icon}] {step_type}: {description}")
+
+        # 详细信息
+        step_id = step.get("step_id", "")[:8]  # 只显示前 8 位
+        lines.append(f"{child_prefix}id: {step_id}...")
+
+        # 关键字段:sequence, status, parent_id
+        sequence = step.get("sequence")
+        if sequence is not None:
+            lines.append(f"{child_prefix}sequence: {sequence}")
+        lines.append(f"{child_prefix}status: {status}")
+
+        parent_id = step.get("parent_id")
+        if parent_id:
+            lines.append(f"{child_prefix}parent_id: {parent_id[:8]}...")
+
+        # 执行指标
+        if step.get("duration_ms") is not None:
+            lines.append(f"{child_prefix}duration: {step.get('duration_ms')}ms")
+        if step.get("tokens") is not None:
+            lines.append(f"{child_prefix}tokens: {step.get('tokens')}")
+        if step.get("cost") is not None:
+            lines.append(f"{child_prefix}cost: ${step.get('cost'):.4f}")
+
+        # summary(如果有)
+        if step.get("summary"):
+            summary = step.get("summary", "")
+            # 截断长 summary
+            if len(summary) > 100:
+                summary = summary[:100] + "..."
+            lines.append(f"{child_prefix}summary: {summary}")
+
+        # 错误信息(结构化显示)
+        error = step.get("error")
+        if error:
+            lines.append(f"{child_prefix}error:")
+            lines.append(f"{child_prefix}  code: {error.get('code', 'UNKNOWN')}")
+            error_msg = error.get('message', '')
+            if len(error_msg) > 200:
+                error_msg = error_msg[:200] + "..."
+            lines.append(f"{child_prefix}  message: {error_msg}")
+            lines.append(f"{child_prefix}  retryable: {error.get('retryable', True)}")
+
+        # data 内容(格式化输出,更激进的截断)
+        data = step.get("data", {})
+        if data:
+            lines.append(f"{child_prefix}data:")
+            data_lines = self._format_data(data, child_prefix + "  ", max_value_len=150)
+            lines.append(data_lines)
+
+        # 时间
+        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"{child_prefix}time: {time_part}")
+
+        lines.append("")  # 空行分隔
+        return "\n".join(lines)
+
+    def _format_data(self, data: Dict[str, Any], prefix: str, max_value_len: int = 150) -> str:
+        """格式化 data 字典(更激进的截断策略)"""
+        lines = []
+
+        for key, value in data.items():
+            # 格式化值
+            if isinstance(value, str):
+                # 检测图片数据
+                if value.startswith("data:image") or (len(value) > 10000 and "\n" not in value[:100]):
+                    lines.append(f"{prefix}{key}: [IMAGE_DATA: {len(value)} chars, truncated]")
+                    continue
+
+                if len(value) > max_value_len:
+                    value_str = value[:max_value_len] + f"... ({len(value)} chars)"
+                else:
+                    value_str = value
+                # 处理多行字符串
+                if "\n" in value_str:
+                    first_line = value_str.split("\n")[0]
+                    line_count = value.count("\n") + 1
+                    value_str = first_line + f"... ({line_count} lines)"
+            elif isinstance(value, (dict, list)):
+                value_str = json.dumps(value, ensure_ascii=False, indent=2)
+                if len(value_str) > max_value_len:
+                    value_str = value_str[:max_value_len] + "..."
+                # 缩进多行
+                value_str = value_str.replace("\n", "\n" + prefix + "  ")
+            else:
+                value_str = str(value)
+
+            lines.append(f"{prefix}{key}: {value_str}")
+
+        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("")
+
+        # 统计摘要
+        if steps:
+            lines.append("## Statistics")
+            lines.append("")
+            stats = self._calculate_statistics(steps)
+            lines.append(f"- **Total steps**: {stats['total']}")
+            lines.append("")
+            lines.append("**By type:**")
+            lines.append("")
+            for step_type, count in sorted(stats['by_type'].items()):
+                lines.append(f"- `{step_type}`: {count}")
+            lines.append("")
+            lines.append("**By status:**")
+            lines.append("")
+            for status, count in sorted(stats['by_status'].items()):
+                lines.append(f"- `{status}`: {count}")
+            lines.append("")
+            if stats['total_duration_ms'] > 0:
+                lines.append(f"- **Total duration**: {stats['total_duration_ms']}ms")
+            if stats['total_tokens'] > 0:
+                lines.append(f"- **Total tokens**: {stats['total_tokens']}")
+            if stats['total_cost'] > 0:
+                lines.append(f"- **Total cost**: ${stats['total_cost']:.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": "⊘",
+            "awaiting_approval": "⏸",
+        }
+        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}...`")
+
+        # 关键字段
+        sequence = step.get("sequence")
+        if sequence is not None:
+            lines.append(f"- **sequence**: {sequence}")
+        lines.append(f"- **status**: {status}")
+
+        parent_id = step.get("parent_id")
+        if parent_id:
+            lines.append(f"- **parent_id**: `{parent_id[:16]}...`")
+
+        # 执行指标
+        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("")
+
+        # 错误信息
+        error = step.get("error")
+        if error:
+            lines.append("<details>")
+            lines.append("<summary><b>❌ Error</b></summary>")
+            lines.append("")
+            lines.append(f"- **code**: `{error.get('code', 'UNKNOWN')}`")
+            lines.append(f"- **retryable**: {error.get('retryable', True)}")
+            lines.append("- **message**:")
+            lines.append("```")
+            error_msg = error.get('message', '')
+            if len(error_msg) > 500:
+                error_msg = error_msg[:500] + "..."
+            lines.append(error_msg)
+            lines.append("```")
+            lines.append("")
+            lines.append("</details>")
+            lines.append("")
+
+        # Summary
+        if step.get("summary"):
+            lines.append("<details>")
+            lines.append("<summary><b>📝 Summary</b></summary>")
+            lines.append("")
+            summary = step.get('summary', '')
+            if len(summary) > 1000:
+                summary = summary[:1000] + "..."
+            lines.append(f"```\n{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": "💬",
+            "output": "📤",
+            "arguments": "⚙️",
+        }
+        icon = icon_map.get(key, "📄")
+
+        # 特殊处理:跳过 None 值
+        if value is None:
+            return ""
+
+        # 特殊处理 messages 中的图片引用
+        if key == 'messages' and isinstance(value, list):
+            # 统计图片数量
+            image_count = 0
+            for msg in value:
+                if isinstance(msg, dict):
+                    content = msg.get('content', [])
+                    if isinstance(content, list):
+                        for item in content:
+                            if isinstance(item, dict) and item.get('type') == 'image_url':
+                                url = item.get('image_url', {}).get('url', '')
+                                if url.startswith('blob://'):
+                                    image_count += 1
+
+            if image_count > 0:
+                # 显示图片摘要
+                lines = []
+                lines.append("<details>")
+                lines.append(f"<summary><b>📨 Messages (含 {image_count} 张图片)</b></summary>")
+                lines.append("")
+                lines.append("```json")
+
+                # 渲染消息,图片显示为简化格式
+                simplified_messages = []
+                for msg in value:
+                    if isinstance(msg, dict):
+                        simplified_msg = msg.copy()
+                        content = msg.get('content', [])
+                        if isinstance(content, list):
+                            new_content = []
+                            for item in content:
+                                if isinstance(item, dict) and item.get('type') == 'image_url':
+                                    url = item.get('image_url', {}).get('url', '')
+                                    if url.startswith('blob://'):
+                                        blob_ref = url.replace('blob://', '')
+                                        size = item.get('image_url', {}).get('size', 0)
+                                        size_kb = size / 1024 if size > 0 else 0
+                                        new_content.append({
+                                            'type': 'image_url',
+                                            'image_url': {
+                                                'url': f'[IMAGE: {blob_ref[:8]}... ({size_kb:.1f}KB)]'
+                                            }
+                                        })
+                                    else:
+                                        new_content.append(item)
+                                else:
+                                    new_content.append(item)
+                            simplified_msg['content'] = new_content
+                        simplified_messages.append(simplified_msg)
+                    else:
+                        simplified_messages.append(msg)
+
+                lines.append(json.dumps(simplified_messages, ensure_ascii=False, indent=2))
+                lines.append("```")
+                lines.append("")
+                lines.append("</details>")
+                return "\n".join(lines)
+
+        # 判断是否需要折叠(长内容或复杂结构)
+        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 and "\n" not in value[:100]):
+                    lines.append("```")
+                    lines.append(f"[IMAGE DATA: {len(value)} chars, truncated for display]")
+                    lines.append("```")
+                elif len(value) > 2000:
+                    # 超长文本,只显示前500字符
+                    lines.append("```")
+                    lines.append(value[:500])
+                    lines.append(f"... (truncated, total {len(value)} chars)")
+                    lines.append("```")
+                else:
+                    lines.append("```")
+                    lines.append(value)
+                    lines.append("```")
+            elif isinstance(value, (dict, list)):
+                # 递归截断图片 base64
+                truncated_value = self._truncate_image_data(value)
+                json_str = json.dumps(truncated_value, ensure_ascii=False, indent=2)
+
+                # 如果 JSON 太长,也截断
+                if len(json_str) > 3000:
+                    json_str = json_str[:3000] + "\n... (truncated)"
+
+                lines.append("```json")
+                lines.append(json_str)
+                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)"
+                # 检测 blob 引用
+                elif isinstance(value, str) and value.startswith("blob://"):
+                    blob_ref = value.replace("blob://", "")
+                    result[key] = f"<BLOB_REF: {blob_ref[:8]}...>"
+                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,
+    steps: Optional[List[Any]] = None,
+    output_path: str = DEFAULT_DUMP_PATH,
+    title: str = "Step Tree Debug",
+) -> str:
+    """
+    便捷函数:输出 Step 树到文件
+
+    Args:
+        trace: Trace 对象或字典
+        steps: Step 对象或字典列表
+        output_path: 输出文件路径
+        title: 输出标题
+
+    Returns:
+        输出的文本内容
+
+    示例:
+        from agent.debug import dump_tree
+
+        # 每次 step 变化后调用
+        dump_tree(trace, steps)
+
+        # 自定义路径
+        dump_tree(trace, steps, output_path=".debug/my_trace.txt")
+    """
+    # 转换为字典
+    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(trace_dict, steps_list, title)
+
+
+def dump_json(
+    trace: Optional[Any] = None,
+    steps: Optional[List[Any]] = None,
+    output_path: str = DEFAULT_JSON_PATH,
+) -> str:
+    """
+    输出完整的 JSON 格式(用于程序化分析)
+
+    Args:
+        trace: Trace 对象或字典
+        steps: Step 对象或字典列表
+        output_path: 输出文件路径
+
+    Returns:
+        JSON 字符串
+    """
+    path = Path(output_path)
+    path.parent.mkdir(parents=True, exist_ok=True)
+
+    # 转换为字典
+    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)
+
+    data = {
+        "generated_at": datetime.now().isoformat(),
+        "trace": trace_dict,
+        "steps": steps_list,
+    }
+
+    content = json.dumps(data, ensure_ascii=False, indent=2)
+    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)

+ 14 - 0
agent/agent/im_client/__init__.py

@@ -0,0 +1,14 @@
+"""Packaged IM client used by the builtin IM tools."""
+
+from .client import ChatWindow, IMClient
+from .notifier import AgentNotifier, ConsoleNotifier
+from .protocol import IMMessage, IMResponse
+
+__all__ = [
+    "AgentNotifier",
+    "ChatWindow",
+    "ConsoleNotifier",
+    "IMClient",
+    "IMMessage",
+    "IMResponse",
+]

+ 348 - 0
agent/agent/im_client/client.py

@@ -0,0 +1,348 @@
+import asyncio
+import json
+import logging
+import os
+import tempfile
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+import websockets
+from filelock import FileLock
+
+from .protocol import IMMessage, IMResponse
+from .notifier import AgentNotifier, ConsoleNotifier
+
+
+class ChatWindow:
+    """单个聊天窗口的数据管理。"""
+
+    def __init__(self, chat_id: str, data_dir: Path):
+        self.chat_id = chat_id
+        self.data_dir = data_dir
+        self.data_dir.mkdir(parents=True, exist_ok=True)
+
+        self.chatbox_path = data_dir / "chatbox.jsonl"
+        self.in_pending_path = data_dir / "in_pending.json"
+        self.out_pending_path = data_dir / "out_pending.jsonl"
+
+        # 文件锁
+        self._in_pending_lock = FileLock(str(data_dir / ".in_pending.lock"))
+        self._out_pending_lock = FileLock(str(data_dir / ".out_pending.lock"))
+        self._chatbox_lock = FileLock(str(data_dir / ".chatbox.lock"))
+
+        # 初始化文件
+        if not self.chatbox_path.exists():
+            self.chatbox_path.write_text("")
+        if not self.in_pending_path.exists():
+            self.in_pending_path.write_text("[]")
+        if not self.out_pending_path.exists():
+            self.out_pending_path.write_text("")
+
+    def append_to_in_pending(self, msg: dict):
+        with self._in_pending_lock:
+            pending = self._load_json_array(self.in_pending_path)
+            pending.append(msg)
+            self._atomic_write_json(self.in_pending_path, pending)
+
+    def read_in_pending(self) -> list[dict]:
+        with self._in_pending_lock:
+            return self._load_json_array(self.in_pending_path)
+
+    def clear_in_pending(self):
+        with self._in_pending_lock:
+            self._atomic_write_json(self.in_pending_path, [])
+
+    def append_to_chatbox(self, msg: dict):
+        with self._chatbox_lock:
+            with open(self.chatbox_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    def append_to_out_pending(self, msg: dict):
+        with self._out_pending_lock:
+            with open(self.out_pending_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    @staticmethod
+    def _load_json_array(path: Path) -> list:
+        if not path.exists():
+            return []
+        text = path.read_text(encoding="utf-8").strip()
+        if not text:
+            return []
+        try:
+            data = json.loads(text)
+            return data if isinstance(data, list) else []
+        except json.JSONDecodeError:
+            return []
+
+    @staticmethod
+    def _atomic_write_json(path: Path, data):
+        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
+        try:
+            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+            os.replace(tmp_path, str(path))
+        except Exception:
+            if os.path.exists(tmp_path):
+                os.unlink(tmp_path)
+            raise
+
+
+class IMClient:
+    """IM Client - 一个实例管理多个聊天窗口。
+
+    一个 Agent (contact_id) 对应一个 IMClient 实例。
+    该实例可以管理多个 chat_id(窗口),每个窗口有独立的消息存储。
+    """
+
+    def __init__(
+        self,
+        contact_id: str,
+        server_url: str = "ws://localhost:8000",
+        data_dir: str | None = None,
+        notify_interval: float = 30.0,
+    ):
+        self.contact_id = contact_id
+        self.server_url = server_url
+        self.notify_interval = notify_interval
+
+        self.base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
+        self.base_dir.mkdir(parents=True, exist_ok=True)
+
+        # 窗口管理
+        self._windows: dict[str, ChatWindow] = {}
+        self._notifiers: dict[str, AgentNotifier] = {}
+
+        self.ws = None
+        self.log = logging.getLogger(contact_id)
+        self._send_queue = asyncio.Queue()
+
+        # 消息回调:{chat_id: callback} 或 {"*": callback} 匹配所有窗口
+        self._on_message_callbacks: dict[str, callable] = {}
+
+    def open_window(
+        self, chat_id: str | None = None, notifier: AgentNotifier | None = None
+    ) -> str:
+        """打开一个新窗口。
+
+        Args:
+            chat_id: 窗口 ID(留空自动生成)
+            notifier: 该窗口的通知器
+
+        Returns:
+            窗口的 chat_id
+        """
+        if chat_id is None:
+            chat_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
+
+        if chat_id in self._windows:
+            return chat_id
+
+        window_dir = self.base_dir / "windows" / chat_id
+        self._windows[chat_id] = ChatWindow(chat_id, window_dir)
+        self._notifiers[chat_id] = notifier or ConsoleNotifier()
+
+        self.log.info(f"打开窗口: {chat_id}")
+        return chat_id
+
+    def close_window(self, chat_id: str):
+        """关闭一个窗口。"""
+        self._windows.pop(chat_id, None)
+        self._notifiers.pop(chat_id, None)
+
+    def on_message(self, callback: callable, chat_id: str = "*"):
+        """注册消息回调。收到消息时立即触发,无需轮询。
+
+        Args:
+            callback: 回调函数,签名 async def callback(msg: dict)
+            chat_id: 监听的窗口 ID,"*" 表示所有窗口
+        """
+        self._on_message_callbacks[chat_id] = callback
+        self.log.info("注册消息回调: %s", chat_id)
+
+    def list_windows(self) -> list[str]:
+        """列出所有打开的窗口。"""
+        return list(self._windows.keys())
+
+    async def run(self):
+        """启动 Client 服务,自动重连。"""
+        while True:
+            try:
+                # 连接时不带 chat_id,因为一个实例管理多个窗口
+                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id=__multi__"
+                self.log.info(f"连接 {ws_url} ...")
+                async with websockets.connect(ws_url) as ws:
+                    self.ws = ws
+                    self.log.info("已连接")
+                    await asyncio.gather(
+                        self._ws_listener(),
+                        self._send_worker(),
+                        self._pending_notifier(),
+                    )
+            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
+                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
+                self.ws = None
+                await asyncio.sleep(5)
+            except asyncio.CancelledError:
+                self.log.info("服务停止")
+                break
+
+    async def _ws_listener(self):
+        """监听 WebSocket,根据 receiver_chat_id 分发到对应窗口。"""
+        async for raw in self.ws:
+            try:
+                data = json.loads(raw)
+            except json.JSONDecodeError:
+                self.log.warning(f"收到无效 JSON: {raw}")
+                continue
+
+            if "sender" in data and "receiver" in data:
+                # 聊天消息
+                receiver_chat_id = data.get("receiver_chat_id")
+
+                if receiver_chat_id and receiver_chat_id in self._windows:
+                    # 定向发送到指定窗口
+                    window = self._windows[receiver_chat_id]
+                    window.append_to_in_pending(data)
+                    window.append_to_chatbox(data)
+                    self.log.info(
+                        f"收到消息 -> 窗口 {receiver_chat_id}: {data['sender']}"
+                    )
+                    await self._fire_on_message(receiver_chat_id, data)
+                elif not receiver_chat_id:
+                    # 广播到所有窗口
+                    for chat_id, window in self._windows.items():
+                        window.append_to_in_pending(data)
+                        window.append_to_chatbox(data)
+                        await self._fire_on_message(chat_id, data)
+                    self.log.info(
+                        f"收到消息 -> 广播到 {len(self._windows)} 个窗口: {data['sender']}"
+                    )
+                else:
+                    self.log.warning(f"收到消息但窗口 {receiver_chat_id} 不存在")
+
+            elif "status" in data:
+                # 发送回执
+                resp = IMResponse(**data)
+                if resp.status == "success":
+                    self.log.info(f"消息 {resp.msg_id} 发送成功")
+                else:
+                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
+
+    async def _fire_on_message(self, chat_id: str, data: dict):
+        """触发消息回调。"""
+        # 精确匹配
+        cb = self._on_message_callbacks.get(chat_id)
+        if cb is None:
+            # 通配符匹配
+            cb = self._on_message_callbacks.get("*")
+        if cb:
+            try:
+                await cb(data)
+            except Exception as e:
+                self.log.error(f"on_message 回调异常: {e}")
+
+    async def _send_worker(self):
+        """从队列取消息并发送。"""
+        while True:
+            msg_data = await self._send_queue.get()
+            msg = IMMessage(sender=self.contact_id, **msg_data)
+            try:
+                await self.ws.send(msg.model_dump_json())
+                self.log.info(
+                    f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}"
+                )
+                # 记录到发送方窗口的 chatbox
+                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
+                    self._windows[msg.sender_chat_id].append_to_chatbox(
+                        msg.model_dump()
+                    )
+            except Exception as e:
+                self.log.error(f"发送失败: {e}")
+                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
+                    self._windows[msg.sender_chat_id].append_to_out_pending(
+                        msg.model_dump()
+                    )
+
+    async def _pending_notifier(self):
+        """轮询各窗口的 in_pending,有新消息就调通知回调。"""
+        while True:
+            for chat_id, window in list(self._windows.items()):
+                pending = window.read_in_pending()
+                if pending:
+                    senders = list(set(m.get("sender", "unknown") for m in pending))
+                    count = len(pending)
+                    notifier = self._notifiers.get(chat_id)
+                    if notifier:
+                        try:
+                            await notifier.notify(count=count, from_contacts=senders)
+                        except Exception as e:
+                            self.log.error(f"窗口 {chat_id} 通知回调异常: {e}")
+            await asyncio.sleep(self.notify_interval)
+
+    # ── Agent 调用的工具方法 ──
+
+    def read_pending(self, chat_id: str) -> list[dict]:
+        """读取某个窗口的待处理消息,并清空。"""
+        window = self._windows.get(chat_id)
+        if window is None:
+            return []
+        pending = window.read_in_pending()
+        if pending:
+            window.clear_in_pending()
+        return pending
+
+    def send_message(
+        self,
+        chat_id: str,
+        receiver: str,
+        content: str,
+        msg_type: str = "chat",
+        receiver_chat_id: str | None = None,
+    ):
+        """从某个窗口发送消息。"""
+        msg_data = {
+            "sender_chat_id": chat_id,
+            "receiver": receiver,
+            "content": content,
+            "msg_type": msg_type,
+            "receiver_chat_id": receiver_chat_id,
+        }
+        self._send_queue.put_nowait(msg_data)
+
+    def get_chat_history(
+        self, chat_id: str, peer_id: str | None = None, limit: int = 20
+    ) -> list[dict]:
+        """查询某个窗口的聊天历史。"""
+        window = self._windows.get(chat_id)
+        if window is None or not window.chatbox_path.exists():
+            return []
+
+        lines = window.chatbox_path.read_text(encoding="utf-8").strip().splitlines()
+        messages = []
+        for line in reversed(lines):
+            if not line.strip():
+                continue
+            try:
+                m = json.loads(line)
+            except json.JSONDecodeError:
+                continue
+
+            if peer_id and m.get("sender") != peer_id and m.get("receiver") != peer_id:
+                continue
+
+            messages.append(
+                {
+                    "sender": m.get("sender", "unknown"),
+                    "receiver": m.get("receiver", "unknown"),
+                    "content": m.get("content", ""),
+                    "msg_type": m.get("msg_type", "chat"),
+                }
+            )
+
+            if len(messages) >= limit:
+                break
+
+        messages.reverse()
+        return messages

+ 22 - 0
agent/agent/im_client/notifier.py

@@ -0,0 +1,22 @@
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class AgentNotifier:
+    """Agent 通知接口基类。
+
+    当 pending.json 有新消息时,Client 会调用 notify()。
+    每个 Agent 可以继承此类实现自己的通知方式。
+    """
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        raise NotImplementedError
+
+
+class ConsoleNotifier(AgentNotifier):
+    """默认实现:打印到控制台。"""
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        sources = ", ".join(from_contacts)
+        log.info(f"[IM 通知] 你有 {count} 条新消息,来自: {sources}")

+ 23 - 0
agent/agent/im_client/protocol.py

@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+from typing import Optional
+import uuid
+
+
+class IMMessage(BaseModel):
+    msg_id: str = ""
+    sender: str
+    receiver: str
+    content: str
+    msg_type: str = "chat"  # chat | image | video | system
+    sender_chat_id: Optional[str] = None  # 发送方窗口 ID
+    receiver_chat_id: Optional[str] = None  # 接收方窗口 ID(指定则定向,否则广播)
+
+    def model_post_init(self, _context):
+        if not self.msg_id:
+            self.msg_id = uuid.uuid4().hex[:12]
+
+
+class IMResponse(BaseModel):
+    status: str  # "success" | "failed"
+    msg_id: str
+    error: Optional[str] = None

+ 416 - 0
agent/agent/llm/anthropic_protocol.py

@@ -0,0 +1,416 @@
+"""Provider-neutral helpers for Anthropic Messages API adapters."""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import mimetypes
+import struct
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+import httpx
+
+from .pricing import calculate_cost
+from .usage import TokenUsage
+
+
+ANTHROPIC_RETRYABLE_EXCEPTIONS = (
+    httpx.RemoteProtocolError,
+    httpx.ConnectError,
+    httpx.ReadTimeout,
+    httpx.WriteTimeout,
+    httpx.ConnectTimeout,
+    httpx.PoolTimeout,
+    ConnectionError,
+)
+
+ANTHROPIC_MODEL_EXACT = {
+    "claude-sonnet-4-6": "claude-sonnet-4-6",
+    "claude-sonnet-4.6": "claude-sonnet-4-6",
+    "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
+    "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
+    "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
+    "claude-opus-4-6": "claude-opus-4-6",
+    "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
+    "claude-opus-4-5": "claude-opus-4-5-20251101",
+    "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
+    "claude-opus-4-1": "claude-opus-4-1-20250805",
+    "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
+    "claude-haiku-4-5": "claude-haiku-4-5-20251001",
+}
+
+ANTHROPIC_MODEL_FUZZY: List[Tuple[str, str]] = [
+    ("sonnet-4-6", "claude-sonnet-4-6"),
+    ("sonnet-4.6", "claude-sonnet-4-6"),
+    ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
+    ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
+    ("opus-4-6", "claude-opus-4-6"),
+    ("opus-4.6", "claude-opus-4-6"),
+    ("opus-4-5", "claude-opus-4-5-20251101"),
+    ("opus-4.5", "claude-opus-4-5-20251101"),
+    ("opus-4-1", "claude-opus-4-1-20250805"),
+    ("opus-4.1", "claude-opus-4-1-20250805"),
+    ("haiku-4-5", "claude-haiku-4-5-20251001"),
+    ("haiku-4.5", "claude-haiku-4-5-20251001"),
+    ("sonnet", "claude-sonnet-4-6"),
+    ("opus", "claude-opus-4-6"),
+    ("haiku", "claude-haiku-4-5-20251001"),
+]
+
+
+def resolve_anthropic_model(
+    model: str,
+    *,
+    provider_prefix: str = "",
+    preserve_unknown_prefix: bool = False,
+    logger: Optional[logging.Logger] = None,
+) -> str:
+    """Resolve framework aliases while preserving each Provider's fallback."""
+
+    original = model
+    bare = model.split("/", 1)[1] if "/" in model else model
+    target = ANTHROPIC_MODEL_EXACT.get(bare)
+    if target is None:
+        bare_lower = bare.lower()
+        target = next(
+            (
+                candidate
+                for keyword, candidate in ANTHROPIC_MODEL_FUZZY
+                if keyword in bare_lower
+            ),
+            None,
+        )
+    if target is not None:
+        resolved = f"{provider_prefix}{target}"
+        if logger and bare != target:
+            logger.info("Anthropic model alias resolved: %s -> %s", model, resolved)
+        return resolved
+
+    fallback = original if preserve_unknown_prefix else bare
+    if logger:
+        logger.warning("Could not resolve Anthropic model alias: %s", model)
+    return fallback
+
+
+def normalize_tool_call_ids(
+    messages: List[Dict[str, Any]], target_prefix: str
+) -> List[Dict[str, Any]]:
+    """Rewrite cross-Provider tool call IDs without mutating input messages."""
+
+    id_map: Dict[str, str] = {}
+    for message in messages:
+        if message.get("role") != "assistant":
+            continue
+        for tool_call in message.get("tool_calls") or ():
+            old_id = tool_call.get("id", "")
+            if old_id and not old_id.startswith(f"{target_prefix}_"):
+                id_map.setdefault(old_id, f"{target_prefix}_{len(id_map):06x}")
+
+    if not id_map:
+        return messages
+
+    normalized = []
+    for message in messages:
+        if message.get("role") == "assistant" and message.get("tool_calls"):
+            tool_calls = [
+                {**tool_call, "id": id_map[tool_call["id"]]}
+                if tool_call.get("id") in id_map
+                else tool_call
+                for tool_call in message["tool_calls"]
+            ]
+            normalized.append({**message, "tool_calls": tool_calls})
+        elif message.get("role") == "tool" and message.get("tool_call_id") in id_map:
+            normalized.append(
+                {**message, "tool_call_id": id_map[message["tool_call_id"]]}
+            )
+        else:
+            normalized.append(message)
+    return normalized
+
+
+def _image_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
+    """Read PNG/JPEG dimensions from headers without a Pillow dependency."""
+
+    try:
+        if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) >= 24:
+            return struct.unpack(">II", data[16:24])
+        if data[:2] == b"\xff\xd8":
+            offset = 2
+            while offset < len(data) - 9:
+                if data[offset] != 0xFF:
+                    break
+                marker = data[offset + 1]
+                if marker in (0xC0, 0xC2):
+                    height, width = struct.unpack(">HH", data[offset + 5 : offset + 9])
+                    return width, height
+                length = struct.unpack(">H", data[offset + 2 : offset + 4])[0]
+                offset += 2 + length
+    except (IndexError, struct.error):
+        return None
+    return None
+
+
+def to_anthropic_content(
+    content: Any,
+    *,
+    resolve_local_files: bool = True,
+    include_image_metadata: bool = True,
+    logger: Optional[logging.Logger] = None,
+) -> Any:
+    """Convert OpenAI content blocks to Anthropic content blocks."""
+
+    if not isinstance(content, list):
+        return content
+
+    converted = []
+    for block in content:
+        if not isinstance(block, dict) or block.get("type") != "image_url":
+            converted.append(block)
+            continue
+
+        image_url = block.get("image_url", {})
+        url = image_url.get("url", "") if isinstance(image_url, dict) else str(image_url)
+        image_data: Optional[bytes] = None
+        if url.startswith("data:"):
+            header, _, encoded = url.partition(",")
+            media_type = (
+                header.split(":", 1)[1].split(";", 1)[0]
+                if ":" in header
+                else "image/png"
+            )
+            source = {"type": "base64", "media_type": media_type, "data": encoded}
+            if include_image_metadata:
+                image_data = base64.b64decode(encoded)
+        elif resolve_local_files and Path(url).is_file():
+            path = Path(url)
+            image_data = path.read_bytes()
+            media_type = mimetypes.guess_type(str(path))[0] or "image/png"
+            source = {
+                "type": "base64",
+                "media_type": media_type,
+                "data": base64.b64encode(image_data).decode("ascii"),
+            }
+            if logger:
+                logger.info("Converted local image to base64: %s (%d bytes)", url, len(image_data))
+        else:
+            source = {"type": "url", "url": url}
+
+        image_block: Dict[str, Any] = {"type": "image", "source": source}
+        if include_image_metadata and image_data:
+            dimensions = _image_dimensions(image_data)
+            if dimensions:
+                image_block["_image_meta"] = {
+                    "width": dimensions[0],
+                    "height": dimensions[1],
+                }
+        converted.append(image_block)
+    return converted
+
+
+def to_anthropic_messages(
+    messages: List[Dict[str, Any]],
+    *,
+    resolve_local_files: bool = True,
+    include_image_metadata: bool = True,
+    split_tool_result_images: bool = True,
+    logger: Optional[logging.Logger] = None,
+) -> Tuple[Any, List[Dict[str, Any]]]:
+    """Convert OpenAI message history to the Anthropic Messages format."""
+
+    def convert(content: Any) -> Any:
+        return to_anthropic_content(
+            content,
+            resolve_local_files=resolve_local_files,
+            include_image_metadata=include_image_metadata,
+            logger=logger,
+        )
+
+    system_prompt = None
+    anthropic_messages: List[Dict[str, Any]] = []
+    for message in messages:
+        role = message.get("role", "")
+        content = message.get("content", "")
+        if role == "system":
+            system_prompt = content
+        elif role == "user":
+            anthropic_messages.append({"role": "user", "content": convert(content)})
+        elif role == "assistant":
+            tool_calls = message.get("tool_calls")
+            if not tool_calls:
+                anthropic_messages.append({"role": "assistant", "content": content})
+                continue
+            content_blocks: List[Dict[str, Any]] = []
+            if content:
+                assistant_content = convert(content)
+                if isinstance(assistant_content, list):
+                    content_blocks.extend(assistant_content)
+                elif isinstance(assistant_content, str) and assistant_content.strip():
+                    content_blocks.append({"type": "text", "text": assistant_content})
+            for tool_call in tool_calls:
+                function = tool_call.get("function", {})
+                arguments = function.get("arguments", "{}")
+                try:
+                    parsed_arguments = (
+                        json.loads(arguments) if isinstance(arguments, str) else arguments
+                    )
+                except json.JSONDecodeError:
+                    parsed_arguments = {}
+                content_blocks.append(
+                    {
+                        "type": "tool_use",
+                        "id": tool_call.get("id", ""),
+                        "name": function.get("name", ""),
+                        "input": parsed_arguments,
+                    }
+                )
+            anthropic_messages.append(
+                {"role": "assistant", "content": content_blocks}
+            )
+        elif role == "tool":
+            tool_content = convert(content)
+            if not split_tool_result_images:
+                blocks = [
+                    {
+                        "type": "tool_result",
+                        "tool_use_id": message.get("tool_call_id", ""),
+                        "content": tool_content,
+                    }
+                ]
+            else:
+                text_parts: List[Dict[str, Any]] = []
+                image_parts: List[Dict[str, Any]] = []
+                if isinstance(tool_content, list):
+                    for block in tool_content:
+                        if isinstance(block, dict) and block.get("type") == "image":
+                            image_parts.append(block)
+                        else:
+                            text_parts.append(block)
+                elif isinstance(tool_content, str) and tool_content:
+                    text_parts.append({"type": "text", "text": tool_content})
+                tool_result: Dict[str, Any] = {
+                    "type": "tool_result",
+                    "tool_use_id": message.get("tool_call_id", ""),
+                }
+                if len(text_parts) == 1 and text_parts[0].get("type") == "text":
+                    tool_result["content"] = text_parts[0]["text"]
+                elif text_parts:
+                    tool_result["content"] = text_parts
+                blocks = [tool_result, *image_parts]
+
+            if (
+                anthropic_messages
+                and anthropic_messages[-1].get("role") == "user"
+                and isinstance(anthropic_messages[-1].get("content"), list)
+                and anthropic_messages[-1]["content"]
+                and anthropic_messages[-1]["content"][0].get("type") == "tool_result"
+            ):
+                anthropic_messages[-1]["content"].extend(blocks)
+            else:
+                anthropic_messages.append({"role": "user", "content": blocks})
+    return system_prompt, anthropic_messages
+
+
+def to_anthropic_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """Convert OpenAI function definitions to Anthropic tool definitions."""
+
+    return [
+        {
+            "name": function.get("name", ""),
+            "description": function.get("description", ""),
+            "input_schema": function.get(
+                "parameters", {"type": "object", "properties": {}}
+            ),
+        }
+        for tool in tools
+        if tool.get("type") == "function"
+        for function in (tool["function"],)
+    ]
+
+
+def parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
+    """Convert an Anthropic response to the framework's unified shape."""
+
+    text_parts = []
+    tool_calls = []
+    for block in result.get("content", []):
+        if block.get("type") == "text":
+            text_parts.append(block.get("text", ""))
+        elif block.get("type") == "tool_use":
+            tool_calls.append(
+                {
+                    "id": block.get("id", ""),
+                    "type": "function",
+                    "function": {
+                        "name": block.get("name", ""),
+                        "arguments": json.dumps(
+                            block.get("input", {}), ensure_ascii=False
+                        ),
+                    },
+                }
+            )
+    stop_reason = result.get("stop_reason", "end_turn")
+    finish_reason = {
+        "end_turn": "stop",
+        "tool_use": "tool_calls",
+        "max_tokens": "length",
+        "stop_sequence": "stop",
+    }.get(stop_reason, stop_reason)
+    raw_usage = result.get("usage", {})
+    return {
+        "content": "\n".join(text_parts),
+        "tool_calls": tool_calls or None,
+        "finish_reason": finish_reason,
+        "usage": TokenUsage(
+            input_tokens=raw_usage.get("input_tokens", 0),
+            output_tokens=raw_usage.get("output_tokens", 0),
+            cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
+            cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
+        ),
+    }
+
+
+def count_cache_controls(system_prompt: Any, messages: List[Dict[str, Any]]) -> int:
+    """Count cache-control blocks for optional Provider diagnostics."""
+
+    blocks = list(system_prompt) if isinstance(system_prompt, list) else []
+    for message in messages:
+        if isinstance(message.get("content"), list):
+            blocks.extend(message["content"])
+    return sum(
+        1 for block in blocks if isinstance(block, dict) and "cache_control" in block
+    )
+
+
+def build_anthropic_result(parsed: Dict[str, Any], model: str) -> Dict[str, Any]:
+    """Attach cost and token compatibility fields to a parsed response."""
+
+    usage = parsed["usage"]
+    return {
+        "content": parsed["content"],
+        "tool_calls": parsed["tool_calls"],
+        "prompt_tokens": usage.input_tokens,
+        "completion_tokens": usage.output_tokens,
+        "reasoning_tokens": usage.reasoning_tokens,
+        "cache_creation_tokens": usage.cache_creation_tokens,
+        "cache_read_tokens": usage.cache_read_tokens,
+        "finish_reason": parsed["finish_reason"],
+        "cost": calculate_cost(model, usage),
+        "usage": usage,
+    }
+
+
+__all__ = [
+    "ANTHROPIC_MODEL_EXACT",
+    "ANTHROPIC_MODEL_FUZZY",
+    "ANTHROPIC_RETRYABLE_EXCEPTIONS",
+    "build_anthropic_result",
+    "count_cache_controls",
+    "normalize_tool_call_ids",
+    "parse_anthropic_response",
+    "resolve_anthropic_model",
+    "to_anthropic_content",
+    "to_anthropic_messages",
+    "to_anthropic_tools",
+]

+ 12 - 42
agent/agent/llm/claude.py

@@ -11,15 +11,14 @@ import logging
 import httpx
 from typing import List, Dict, Any, Optional
 
-from .pricing import calculate_cost
-
-# 直接复用底层已经在 openrouter 内部写好的格式转换/拦截器
-from .openrouter import (
-    _normalize_tool_call_ids,
-    _to_anthropic_messages,
-    _to_anthropic_tools,
-    _parse_anthropic_response,
-    _RETRYABLE_EXCEPTIONS
+from .anthropic_protocol import (
+    ANTHROPIC_RETRYABLE_EXCEPTIONS as _RETRYABLE_EXCEPTIONS,
+    build_anthropic_result,
+    count_cache_controls,
+    parse_anthropic_response as _parse_anthropic_response,
+    normalize_tool_call_ids as _normalize_tool_call_ids,
+    to_anthropic_messages as _to_anthropic_messages,
+    to_anthropic_tools as _to_anthropic_tools,
 )
 
 logger = logging.getLogger(__name__)
@@ -65,10 +64,8 @@ async def anthropic_native_llm_call(
 
     # 记录使用的配置(只在第一次调用时输出)
     if not hasattr(anthropic_native_llm_call, '_logged_config'):
-        logger.info(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
-        logger.info(f"[Anthropic Native] Using {url_source}: {base_url}")
-        print(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
-        print(f"[Anthropic Native] Using {url_source}: {base_url}")
+        logger.info("[Anthropic Native] Using credentials from %s", key_source)
+        logger.info("[Anthropic Native] Using %s endpoint: %s", url_source, base_url)
         anthropic_native_llm_call._logged_config = True
 
     anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
@@ -114,17 +111,7 @@ async def anthropic_native_llm_call(
 
     # Debug: 检查 cache_control 是否存在
     if logger.isEnabledFor(logging.DEBUG):
-        cache_control_count = 0
-        if isinstance(system_prompt, list):
-            for block in system_prompt:
-                if isinstance(block, dict) and "cache_control" in block:
-                    cache_control_count += 1
-        for msg in anthropic_messages:
-            content = msg.get("content", "")
-            if isinstance(content, list):
-                for block in content:
-                    if isinstance(block, dict) and "cache_control" in block:
-                        cache_control_count += 1
+        cache_control_count = count_cache_controls(system_prompt, anthropic_messages)
         if cache_control_count > 0:
             logger.debug(f"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
 
@@ -160,7 +147,6 @@ async def anthropic_native_llm_call(
                     last_exception = e
                     continue
                 logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
-                print(f"[Anthropic Native] API Error {status}: {error_body[:500]}")
                 raise
                 
             except _RETRYABLE_EXCEPTIONS as e:
@@ -174,23 +160,7 @@ async def anthropic_native_llm_call(
     else:
         raise last_exception  # type: ignore[misc]
 
-    # 解析响应并抽离 Usage
-    parsed = _parse_anthropic_response(result)
-    usage = parsed["usage"]
-    cost = calculate_cost(model, usage)
-
-    return {
-        "content": parsed["content"],
-        "tool_calls": parsed["tool_calls"],
-        "prompt_tokens": usage.input_tokens,
-        "completion_tokens": usage.output_tokens,
-        "reasoning_tokens": usage.reasoning_tokens,
-        "cache_creation_tokens": usage.cache_creation_tokens,
-        "cache_read_tokens": usage.cache_read_tokens,
-        "finish_reason": parsed["finish_reason"],
-        "cost": cost,
-        "usage": usage,
-    }
+    return build_anthropic_result(_parse_anthropic_response(result), model)
 
 
 def create_claude_llm_call(model: str = "claude-3-5-sonnet-20241022"):

+ 29 - 16
agent/agent/llm/gemini.py

@@ -8,7 +8,7 @@ Gemini Provider (HTTP API)
 
 import os
 import json
-import sys
+import logging
 import httpx
 from typing import List, Dict, Any, Optional
 
@@ -16,6 +16,9 @@ from .usage import TokenUsage
 from .pricing import calculate_cost
 
 
+logger = logging.getLogger(__name__)
+
+
 def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
     """
     Dump完整的LLM请求用于调试(需要设置 AGENT_DEBUG=1)
@@ -57,12 +60,10 @@ def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
         "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)
+    logger.debug(
+        "[AGENT_DEBUG] Gemini request dump:\n%s",
+        json.dumps(debug_info, indent=2, ensure_ascii=False),
+    )
 
 
 def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optional[str]]:
@@ -99,7 +100,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
             content_text = msg.get("content", "")
 
             if not tool_name:
-                print(f"[WARNING] Tool message missing 'name' field, skipping")
+                logger.warning("Gemini tool message missing name; skipping")
                 continue
 
             # 尝试解析为 JSON
@@ -197,7 +198,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
                                 }
                             })
                         except Exception as e:
-                            print(f"[WARNING] Failed to parse image data URL: {e}")
+                            logger.warning("Failed to parse Gemini image data URL: %s", e)
 
             if parts:
                 gemini_role = "model" if role == "assistant" else "user"
@@ -345,7 +346,11 @@ def create_gemini_llm_call(
         # 转换消息
         contents, system_instruction = _convert_messages_to_gemini(messages)
 
-        print(f"\n[Gemini HTTP] Converted {len(contents)} messages: {[c['role'] for c in contents]}")
+        logger.debug(
+            "[Gemini HTTP] Converted %d messages: %s",
+            len(contents),
+            [content["role"] for content in contents],
+        )
 
         # 构建请求
         endpoint = f"{base_url}/models/{model}:generateContent"
@@ -372,17 +377,22 @@ def create_gemini_llm_call(
 
         except httpx.HTTPStatusError as e:
             error_body = e.response.text
-            print(f"[Gemini HTTP] Error {e.response.status_code}: {error_body}")
+            logger.error(
+                "[Gemini HTTP] Error %d: %s",
+                e.response.status_code,
+                error_body,
+            )
             raise
         except Exception as e:
-            print(f"[Gemini HTTP] Request failed: {e}")
+            logger.error("[Gemini HTTP] Request failed: %s", 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)
+            logger.debug(
+                "[AGENT_DEBUG] Gemini response:\n%s",
+                json.dumps(gemini_resp, ensure_ascii=False, indent=2)[:2000],
+            )
 
         # 解析响应
         content = ""
@@ -411,7 +421,10 @@ def create_gemini_llm_call(
                 # Gemini 返回了格式错误的函数调用
                 # 提取 finishMessage 中的内容作为 content
                 finish_message = candidate.get("finishMessage", "")
-                print(f"[Gemini HTTP] Warning: MALFORMED_FUNCTION_CALL\n{finish_message}")
+                logger.warning(
+                    "[Gemini HTTP] MALFORMED_FUNCTION_CALL: %s",
+                    finish_message,
+                )
                 content = f"[模型尝试调用工具但格式错误]\n\n{finish_message}"
             else:
                 # 正常解析

+ 29 - 391
agent/agent/llm/openrouter.py

@@ -15,64 +15,37 @@ OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 
 """
 
 import os
-import json
 import asyncio
 import logging
 import httpx
 from pathlib import Path
 from typing import List, Dict, Any, Optional
 
-from .usage import TokenUsage, create_usage_from_response
+from .anthropic_protocol import (
+    ANTHROPIC_MODEL_EXACT,
+    ANTHROPIC_MODEL_FUZZY,
+    ANTHROPIC_RETRYABLE_EXCEPTIONS,
+    build_anthropic_result,
+    count_cache_controls,
+    normalize_tool_call_ids as _normalize_tool_call_ids,
+    parse_anthropic_response as _parse_anthropic_response,
+    resolve_anthropic_model,
+    to_anthropic_messages,
+    to_anthropic_tools as _to_anthropic_tools,
+)
+from .usage import TokenUsage
 from .pricing import calculate_cost
 
 logger = logging.getLogger(__name__)
 
 # 可重试的异常类型
-_RETRYABLE_EXCEPTIONS = (
-    httpx.RemoteProtocolError,  # Server disconnected without sending a response
-    httpx.ConnectError,
-    httpx.ReadTimeout,
-    httpx.WriteTimeout,
-    httpx.ConnectTimeout,
-    httpx.PoolTimeout,
-    ConnectionError,
-)
+_RETRYABLE_EXCEPTIONS = ANTHROPIC_RETRYABLE_EXCEPTIONS
 
 
 # ── OpenRouter Anthropic endpoint: model name mapping ──────────────────────
 # Local copy of yescode's model tables so this module is self-contained.
-_OR_MODEL_EXACT = {
-    "claude-sonnet-4-6": "claude-sonnet-4-6",
-    "claude-sonnet-4.6": "claude-sonnet-4-6",
-    "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
-    "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
-    "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
-    "claude-opus-4-6": "claude-opus-4-6",
-    "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
-    "claude-opus-4-5": "claude-opus-4-5-20251101",
-    "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
-    "claude-opus-4-1": "claude-opus-4-1-20250805",
-    "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
-    "claude-haiku-4-5": "claude-haiku-4-5-20251001",
-}
-
-_OR_MODEL_FUZZY = [
-    ("sonnet-4-6", "claude-sonnet-4-6"),
-    ("sonnet-4.6", "claude-sonnet-4-6"),
-    ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
-    ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
-    ("opus-4-6", "claude-opus-4-6"),
-    ("opus-4.6", "claude-opus-4-6"),
-    ("opus-4-5", "claude-opus-4-5-20251101"),
-    ("opus-4.5", "claude-opus-4-5-20251101"),
-    ("opus-4-1", "claude-opus-4-1-20250805"),
-    ("opus-4.1", "claude-opus-4-1-20250805"),
-    ("haiku-4-5", "claude-haiku-4-5-20251001"),
-    ("haiku-4.5", "claude-haiku-4-5-20251001"),
-    ("sonnet", "claude-sonnet-4-6"),
-    ("opus", "claude-opus-4-6"),
-    ("haiku", "claude-haiku-4-5-20251001"),
-]
+_OR_MODEL_EXACT = ANTHROPIC_MODEL_EXACT
+_OR_MODEL_FUZZY = ANTHROPIC_MODEL_FUZZY
 
 
 def _resolve_openrouter_model(model: str) -> str:
@@ -81,51 +54,12 @@ def _resolve_openrouter_model(model: str) -> str:
     Strips ``anthropic/`` prefix, resolves aliases / dot-notation,
     and re-prepends ``anthropic/`` for OpenRouter routing.
     """
-    # 1. Strip provider prefix
-    bare = model.split("/", 1)[1] if "/" in model else model
-
-    # 2. Exact match
-    if bare in _OR_MODEL_EXACT:
-        return f"anthropic/{_OR_MODEL_EXACT[bare]}"
-
-    # 3. Fuzzy keyword match (case-insensitive)
-    bare_lower = bare.lower()
-    for keyword, target in _OR_MODEL_FUZZY:
-        if keyword in bare_lower:
-            logger.info("[OpenRouter] Model fuzzy match: %s → anthropic/%s", model, target)
-            return f"anthropic/{target}"
-
-    # 4. Fallback – return as-is (let API report the error)
-    logger.warning("[OpenRouter] Could not resolve model name: %s, passing as-is", model)
-    return model
-
-
-# ── OpenRouter Anthropic endpoint: format conversion helpers ───────────────
-
-def _get_image_dimensions(data: bytes) -> Optional[tuple]:
-    """从图片二进制数据的文件头解析宽高,支持 PNG/JPEG。不依赖 PIL。"""
-    try:
-        # PNG: 前 8 字节签名,IHDR chunk 在 16-24 字节存宽高 (big-endian uint32)
-        if data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) >= 24:
-            import struct
-            w, h = struct.unpack('>II', data[16:24])
-            return (w, h)
-        # JPEG: 扫描 SOF0/SOF2 marker (0xFFC0/0xFFC2)
-        if data[:2] == b'\xff\xd8':
-            import struct
-            i = 2
-            while i < len(data) - 9:
-                if data[i] != 0xFF:
-                    break
-                marker = data[i + 1]
-                if marker in (0xC0, 0xC2):
-                    h, w = struct.unpack('>HH', data[i + 5:i + 9])
-                    return (w, h)
-                length = struct.unpack('>H', data[i + 2:i + 4])[0]
-                i += 2 + length
-    except Exception:
-        pass
-    return None
+    return resolve_anthropic_model(
+        model,
+        provider_prefix="anthropic/",
+        preserve_unknown_prefix=True,
+        logger=logger,
+    )
 
 
 def _sanitize_schema_name(title: str) -> str:
@@ -212,237 +146,14 @@ def _ensure_strict_schema(schema: Dict) -> Dict:
     result.pop("$schema", None)
 
     return result
-    _process(result)
-
-    return result
-
-
-def _to_anthropic_content(content: Any) -> Any:
-    """Convert OpenAI-style *content* (string or block list) to Anthropic format.
-
-    Handles ``image_url`` blocks → Anthropic ``image`` blocks (base64 or url).
-    Passes through ``text`` blocks and ``cache_control`` unchanged.
-    """
-    if not isinstance(content, list):
-        return content
-
-    result = []
-    for block in content:
-        if not isinstance(block, dict):
-            result.append(block)
-            continue
-
-        if block.get("type") == "image_url":
-            image_url_obj = block.get("image_url", {})
-            url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
-            if url.startswith("data:"):
-                header, _, data = url.partition(",")
-                media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
-                import base64 as b64mod
-                raw = b64mod.b64decode(data)
-                dims = _get_image_dimensions(raw)
-                img_block = {
-                    "type": "image",
-                    "source": {
-                        "type": "base64",
-                        "media_type": media_type,
-                        "data": data,
-                    },
-                }
-                if dims:
-                    img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
-                result.append(img_block)
-            else:
-                # 检测本地文件路径,自动转 base64
-                local_path = Path(url)
-                if local_path.exists() and local_path.is_file():
-                    import base64 as b64mod
-                    import mimetypes
-                    mime_type, _ = mimetypes.guess_type(str(local_path))
-                    mime_type = mime_type or "image/png"
-                    raw = local_path.read_bytes()
-                    dims = _get_image_dimensions(raw)
-                    b64_data = b64mod.b64encode(raw).decode("ascii")
-                    logger.info(f"[OpenRouter] 本地图片自动转 base64: {url} ({len(raw)} bytes)")
-                    img_block = {
-                        "type": "image",
-                        "source": {
-                            "type": "base64",
-                            "media_type": mime_type,
-                            "data": b64_data,
-                        },
-                    }
-                    if dims:
-                        img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
-                    result.append(img_block)
-                else:
-                    result.append({
-                        "type": "image",
-                        "source": {"type": "url", "url": url},
-                    })
-        else:
-            result.append(block)
-    return result
 
 
-def _to_anthropic_messages(messages: List[Dict[str, Any]]) -> tuple:
-    """Convert an OpenAI-format message list to Anthropic Messages API format.
-
-    Returns ``(system_prompt, anthropic_messages)`` where *system_prompt* is
-    ``None`` or a string extracted from ``role=system`` messages, and
-    *anthropic_messages* is the converted list.
-    """
-    system_prompt = None
-    anthropic_messages: List[Dict[str, Any]] = []
-
-    for msg in messages:
-        role = msg.get("role", "")
-        content = msg.get("content", "")
-
-        if role == "system":
-            system_prompt = content
-
-        elif role == "user":
-            anthropic_messages.append({
-                "role": "user",
-                "content": _to_anthropic_content(content),
-            })
-
-        elif role == "assistant":
-            tool_calls = msg.get("tool_calls")
-            if tool_calls:
-                content_blocks: List[Dict[str, Any]] = []
-                if content:
-                    converted = _to_anthropic_content(content)
-                    if isinstance(converted, list):
-                        content_blocks.extend(converted)
-                    elif isinstance(converted, str) and converted.strip():
-                        content_blocks.append({"type": "text", "text": converted})
-                for tc in tool_calls:
-                    func = tc.get("function", {})
-                    args_str = func.get("arguments", "{}")
-                    try:
-                        args = json.loads(args_str) if isinstance(args_str, str) else args_str
-                    except json.JSONDecodeError:
-                        args = {}
-                    content_blocks.append({
-                        "type": "tool_use",
-                        "id": tc.get("id", ""),
-                        "name": func.get("name", ""),
-                        "input": args,
-                    })
-                anthropic_messages.append({"role": "assistant", "content": content_blocks})
-            else:
-                anthropic_messages.append({"role": "assistant", "content": content})
-
-        elif role == "tool":
-            # Split tool result into text-only tool_result + sibling image blocks.
-            # Images nested inside tool_result.content are not reliably passed
-            # through by all proxies (e.g. OpenRouter).  Placing them as sibling
-            # content blocks in the same user message is more compatible.
-            converted = _to_anthropic_content(content)
-            text_parts: List[Dict[str, Any]] = []
-            image_parts: List[Dict[str, Any]] = []
-            if isinstance(converted, list):
-                for block in converted:
-                    if isinstance(block, dict) and block.get("type") == "image":
-                        image_parts.append(block)
-                    else:
-                        text_parts.append(block)
-            elif isinstance(converted, str):
-                text_parts = [{"type": "text", "text": converted}] if converted else []
-
-            # tool_result keeps only text content
-            tool_result_block: Dict[str, Any] = {
-                "type": "tool_result",
-                "tool_use_id": msg.get("tool_call_id", ""),
-            }
-            if len(text_parts) == 1 and text_parts[0].get("type") == "text":
-                tool_result_block["content"] = text_parts[0]["text"]
-            elif text_parts:
-                tool_result_block["content"] = text_parts
-            # (omit content key entirely when empty – Anthropic accepts this)
-
-            # Build the blocks to append: tool_result first, then any images
-            new_blocks = [tool_result_block] + image_parts
-
-            # Merge consecutive tool results into one user message
-            if (anthropic_messages
-                    and anthropic_messages[-1].get("role") == "user"
-                    and isinstance(anthropic_messages[-1].get("content"), list)
-                    and anthropic_messages[-1]["content"]
-                    and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
-                anthropic_messages[-1]["content"].extend(new_blocks)
-            else:
-                anthropic_messages.append({
-                    "role": "user",
-                    "content": new_blocks,
-                })
-
-    return system_prompt, anthropic_messages
-
-
-def _to_anthropic_tools(tools: List[Dict]) -> List[Dict]:
-    """Convert OpenAI tool definitions to Anthropic format."""
-    anthropic_tools = []
-    for tool in tools:
-        if tool.get("type") == "function":
-            func = tool["function"]
-            anthropic_tools.append({
-                "name": func.get("name", ""),
-                "description": func.get("description", ""),
-                "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
-            })
-    return anthropic_tools
-
-
-def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
-    """Parse an Anthropic Messages API response into the unified format.
-
-    Returns a dict with keys: content, tool_calls, finish_reason, usage.
-    """
-    content_blocks = result.get("content", [])
-
-    text_parts = []
-    tool_calls = []
-    for block in content_blocks:
-        if block.get("type") == "text":
-            text_parts.append(block.get("text", ""))
-        elif block.get("type") == "tool_use":
-            tool_calls.append({
-                "id": block.get("id", ""),
-                "type": "function",
-                "function": {
-                    "name": block.get("name", ""),
-                    "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
-                },
-            })
-
-    content = "\n".join(text_parts)
-
-    stop_reason = result.get("stop_reason", "end_turn")
-    finish_reason_map = {
-        "end_turn": "stop",
-        "tool_use": "tool_calls",
-        "max_tokens": "length",
-        "stop_sequence": "stop",
-    }
-    finish_reason = finish_reason_map.get(stop_reason, stop_reason)
-
-    raw_usage = result.get("usage", {})
-    usage = TokenUsage(
-        input_tokens=raw_usage.get("input_tokens", 0),
-        output_tokens=raw_usage.get("output_tokens", 0),
-        cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
-        cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
-    )
+def _to_anthropic_messages(
+    messages: List[Dict[str, Any]],
+) -> tuple[Any, List[Dict[str, Any]]]:
+    """Use the shared protocol with OpenRouter's local-image behavior."""
 
-    return {
-        "content": content,
-        "tool_calls": tool_calls if tool_calls else None,
-        "finish_reason": finish_reason,
-        "usage": usage,
-    }
+    return to_anthropic_messages(messages, logger=logger)
 
 
 # ── Provider detection / usage parsing ─────────────────────────────────────
@@ -508,50 +219,6 @@ def _parse_openrouter_usage(usage: Dict[str, Any], model: str) -> TokenUsage:
         )
 
 
-def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
-    """
-    将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
-    跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
-    (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
-    仅在检测到异格式 ID 时才重写,同格式直接跳过。
-    """
-    # 第一遍:收集需要重写的 ID
-    id_map: Dict[str, str] = {}
-    counter = 0
-    for msg in messages:
-        if msg.get("role") == "assistant" and msg.get("tool_calls"):
-            for tc in msg["tool_calls"]:
-                old_id = tc.get("id", "")
-                if old_id and not old_id.startswith(target_prefix + "_"):
-                    if old_id not in id_map:
-                        id_map[old_id] = f"{target_prefix}_{counter:06x}"
-                        counter += 1
-
-    if not id_map:
-        return messages  # 无需重写
-
-    logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
-
-    # 第二遍:重写(浅拷贝避免修改原始数据)
-    result = []
-    for msg in messages:
-        if msg.get("role") == "assistant" and msg.get("tool_calls"):
-            new_tcs = []
-            for tc in msg["tool_calls"]:
-                old_id = tc.get("id", "")
-                if old_id in id_map:
-                    new_tcs.append({**tc, "id": id_map[old_id]})
-                else:
-                    new_tcs.append(tc)
-            result.append({**msg, "tool_calls": new_tcs})
-        elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
-            result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
-        else:
-            result.append(msg)
-
-    return result
-
-
 async def _openrouter_anthropic_call(
     messages: List[Dict[str, Any]],
     model: str,
@@ -586,7 +253,6 @@ async def _openrouter_anthropic_call(
                     _img_count += 1
     if _img_count:
         logger.info("[OpenRouter/Anthropic] payload contains %d image block(s)", _img_count)
-        print(f"[OpenRouter/Anthropic] payload contains {_img_count} image block(s)")
 
     payload: Dict[str, Any] = {
         "model": resolved_model,
@@ -616,17 +282,7 @@ async def _openrouter_anthropic_call(
 
     # Debug: 检查 cache_control 是否存在
     if logger.isEnabledFor(logging.DEBUG):
-        cache_control_count = 0
-        if isinstance(system_prompt, list):
-            for block in system_prompt:
-                if isinstance(block, dict) and "cache_control" in block:
-                    cache_control_count += 1
-        for msg in anthropic_messages:
-            content = msg.get("content", "")
-            if isinstance(content, list):
-                for block in content:
-                    if isinstance(block, dict) and "cache_control" in block:
-                        cache_control_count += 1
+        cache_control_count = count_cache_controls(system_prompt, anthropic_messages)
         if cache_control_count > 0:
             logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
 
@@ -660,9 +316,7 @@ async def _openrouter_anthropic_call(
                     await asyncio.sleep(wait)
                     last_exception = e
                     continue
-                # Log AND print error body so it is visible in console output
                 logger.error("[OpenRouter/Anthropic] HTTP %d error body: %s", status, error_body)
-                print(f"[OpenRouter/Anthropic] API Error {status}: {error_body[:500]}")
                 raise
 
             except _RETRYABLE_EXCEPTIONS as e:
@@ -679,23 +333,7 @@ async def _openrouter_anthropic_call(
     else:
         raise last_exception  # type: ignore[misc]
 
-    # 解析 Anthropic 响应 → 统一格式
-    parsed = _parse_anthropic_response(result)
-    usage = parsed["usage"]
-    cost = calculate_cost(model, usage)
-
-    return {
-        "content": parsed["content"],
-        "tool_calls": parsed["tool_calls"],
-        "prompt_tokens": usage.input_tokens,
-        "completion_tokens": usage.output_tokens,
-        "reasoning_tokens": usage.reasoning_tokens,
-        "cache_creation_tokens": usage.cache_creation_tokens,
-        "cache_read_tokens": usage.cache_read_tokens,
-        "finish_reason": parsed["finish_reason"],
-        "cost": cost,
-        "usage": usage,
-    }
+    return build_anthropic_result(_parse_anthropic_response(result), model)
 
 
 def _resolve_local_images_in_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

+ 8 - 3
agent/agent/llm/pricing.py

@@ -12,7 +12,8 @@ LLM 定价计算器
 
 import os
 import re
-from dataclasses import dataclass, field
+import logging
+from dataclasses import dataclass
 from pathlib import Path
 from typing import Dict, Any, Optional, List
 import yaml
@@ -20,6 +21,9 @@ import yaml
 from .usage import TokenUsage
 
 
+logger = logging.getLogger(__name__)
+
+
 @dataclass
 class ModelPricing:
     """
@@ -170,7 +174,7 @@ class PricingCalculator:
 
         for path in search_paths:
             if path.exists():
-                print(f"[Pricing] Loaded config from: {path}")
+                logger.info("Pricing config loaded from %s", path)
                 return str(path)
 
         return None
@@ -201,7 +205,8 @@ class PricingCalculator:
         """
         内置默认定价表
 
-        价格来源:各提供商官网(2024-12 更新)
+        这些值仅是无外部配置时的兼容兜底;生产环境应通过
+        ``AGENT_PRICING_CONFIG`` 提供经过校验的实时价格。
         单位:美元 / 1M tokens
         """
         return [

+ 1 - 1
agent/agent/llm/prompts/wrapper.py

@@ -6,7 +6,7 @@ Prompt Wrapper - 为 .prompt 文件提供 Prompt 实现
 
 import base64
 from pathlib import Path
-from typing import List, Dict, Any, Union, Optional
+from typing import List, Dict, Any, Union
 from agent.llm.prompts.loader import load_prompt, get_message
 
 

+ 2 - 5
agent/agent/llm/usage.py

@@ -13,9 +13,8 @@ Token Usage 数据模型和费用计算
 - PricingCalculator: 策略模式,根据定价表计算费用
 """
 
-from dataclasses import dataclass, field
-from typing import Dict, Any, Optional
-import copy
+from dataclasses import dataclass
+from typing import Dict, Any
 
 
 @dataclass(frozen=True)
@@ -82,8 +81,6 @@ class TokenUsage:
 
         这里返回等效的全价 tokens 数
         """
-        # 普通输入 = 总输入 - 缓存读取
-        regular_input = self.input_tokens - self.cache_read_tokens
         # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
         # 简化:返回原始值,让 PricingCalculator 处理
         return self.input_tokens

+ 73 - 393
agent/agent/llm/yescode.py

@@ -1,392 +1,91 @@
-"""
-Yescode Provider
+"""Yescode Provider using the Anthropic Messages API."""
 
-使用 Yescode 代理 API 调用 Claude 等模型
-使用 Anthropic Messages API 格式(/v1/messages)
-
-环境变量:
-- YESCODE_BASE_URL: API 基础地址(如 https://co.yes.vg)
-- YESCODE_API_KEY: API 密钥
-
-注意:
-- Yescode 代理要求 User-Agent 包含 "claude-code"
-- 使用 Anthropic 原生 Messages API 格式
-- 响应格式转换为框架统一的 OpenAI 兼容格式
-"""
-
-import os
-import json
 import asyncio
 import logging
-import httpx
-from typing import List, Dict, Any, Optional
-
-from .usage import TokenUsage
-from .pricing import calculate_cost
+import os
+from typing import Any, Dict, List, Optional
 
-logger = logging.getLogger(__name__)
+import httpx
 
-# 可重试的异常类型
-_RETRYABLE_EXCEPTIONS = (
-    httpx.RemoteProtocolError,
-    httpx.ConnectError,
-    httpx.ReadTimeout,
-    httpx.WriteTimeout,
-    httpx.ConnectTimeout,
-    httpx.PoolTimeout,
-    ConnectionError,
+from .anthropic_protocol import (
+    ANTHROPIC_MODEL_EXACT,
+    ANTHROPIC_MODEL_FUZZY,
+    ANTHROPIC_RETRYABLE_EXCEPTIONS,
+    build_anthropic_result,
+    normalize_tool_call_ids as _normalize_tool_call_ids,
+    parse_anthropic_response as _parse_anthropic_response,
+    resolve_anthropic_model,
+    to_anthropic_content,
+    to_anthropic_messages,
+    to_anthropic_tools as _convert_tools_to_anthropic,
 )
 
-# 模糊匹配规则:(关键词, 目标模型名),从精确到宽泛排序
-# 精确匹配走 MODEL_EXACT,不命中则按顺序尝试关键词匹配
-MODEL_EXACT = {
-    "claude-sonnet-4-6": "claude-sonnet-4-6",
-    "claude-sonnet-4.6": "claude-sonnet-4-6",
-    "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
-    "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
-    "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
-    "claude-opus-4-6": "claude-opus-4-6",
-    "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
-    "claude-opus-4-5": "claude-opus-4-5-20251101",
-    "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
-    "claude-opus-4-1": "claude-opus-4-1-20250805",
-    "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
-    "claude-haiku-4-5": "claude-haiku-4-5-20251001",
-}
+logger = logging.getLogger(__name__)
 
-MODEL_FUZZY = [
-    # 版本+家族(精确)
-    ("sonnet-4-6", "claude-sonnet-4-6"),
-    ("sonnet-4.6", "claude-sonnet-4-6"),
-    ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
-    ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
-    ("opus-4-6", "claude-opus-4-6"),
-    ("opus-4.6", "claude-opus-4-6"),
-    ("opus-4-5", "claude-opus-4-5-20251101"),
-    ("opus-4.5", "claude-opus-4-5-20251101"),
-    ("opus-4-1", "claude-opus-4-1-20250805"),
-    ("opus-4.1", "claude-opus-4-1-20250805"),
-    ("haiku-4-5", "claude-haiku-4-5-20251001"),
-    ("haiku-4.5", "claude-haiku-4-5-20251001"),
-    # 仅家族名 → 最新版本
-    ("sonnet", "claude-sonnet-4-6"),
-    ("opus", "claude-opus-4-6"),
-    ("haiku", "claude-haiku-4-5-20251001"),
-]
+# Keep existing module-level names available to callers that imported them.
+_RETRYABLE_EXCEPTIONS = ANTHROPIC_RETRYABLE_EXCEPTIONS
+MODEL_EXACT = ANTHROPIC_MODEL_EXACT
+MODEL_FUZZY = ANTHROPIC_MODEL_FUZZY
 
 
 def _resolve_model(model: str) -> str:
-    """将任意格式的模型名映射为 Yescode API 接受的模型名。
-    支持:OpenRouter 前缀(anthropic/xxx)、带点号(4.5)、纯家族名(sonnet)等。
-    """
-    # 1. 剥离 provider 前缀
-    if "/" in model:
-        model = model.split("/", 1)[1]
-
-    # 2. 精确匹配
-    if model in MODEL_EXACT:
-        return MODEL_EXACT[model]
-
-    # 3. 模糊匹配(大小写不敏感)
-    model_lower = model.lower()
-    for keyword, target in MODEL_FUZZY:
-        if keyword in model_lower:
-            logger.info("模型名模糊匹配: %s → %s", model, target)
-            return target
-
-    # 4. 兜底:原样返回,让 API 报错
-    logger.warning("未能匹配模型名: %s, 原样传递", model)
-    return model
-
+    """Resolve a framework model alias for Yescode."""
 
-def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
-    """
-    将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
-    跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
-    (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
-    仅在检测到异格式 ID 时才重写,同格式直接跳过。
-    """
-    # 第一遍:收集需要重写的 ID
-    id_map: Dict[str, str] = {}
-    counter = 0
-    for msg in messages:
-        if msg.get("role") == "assistant" and msg.get("tool_calls"):
-            for tc in msg["tool_calls"]:
-                old_id = tc.get("id", "")
-                if old_id and not old_id.startswith(target_prefix + "_"):
-                    if old_id not in id_map:
-                        id_map[old_id] = f"{target_prefix}_{counter:06x}"
-                        counter += 1
-
-    if not id_map:
-        return messages  # 无需重写
-
-    logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
-
-    # 第二遍:重写(浅拷贝避免修改原始数据)
-    result = []
-    for msg in messages:
-        if msg.get("role") == "assistant" and msg.get("tool_calls"):
-            new_tcs = []
-            for tc in msg["tool_calls"]:
-                old_id = tc.get("id", "")
-                if old_id in id_map:
-                    new_tcs.append({**tc, "id": id_map[old_id]})
-                else:
-                    new_tcs.append(tc)
-            result.append({**msg, "tool_calls": new_tcs})
-        elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
-            result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
-        else:
-            result.append(msg)
-
-    return result
+    return resolve_anthropic_model(model, logger=logger)
 
 
 def _convert_content_to_anthropic(content: Any) -> Any:
-    """
-    将 OpenAI 格式的 content(字符串或列表)转换为 Anthropic 格式。
-    主要处理 image_url 类型块 → Anthropic image 块。
-    """
-    if not isinstance(content, list):
-        return content
-
-    result = []
-    for block in content:
-        if not isinstance(block, dict):
-            result.append(block)
-            continue
-
-        block_type = block.get("type", "")
-        if block_type == "image_url":
-            image_url_obj = block.get("image_url", {})
-            url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
-            if url.startswith("data:"):
-                # base64 编码图片:data:<media_type>;base64,<data>
-                header, _, data = url.partition(",")
-                media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
-                result.append({
-                    "type": "image",
-                    "source": {
-                        "type": "base64",
-                        "media_type": media_type,
-                        "data": data,
-                    },
-                })
-            else:
-                result.append({
-                    "type": "image",
-                    "source": {
-                        "type": "url",
-                        "url": url,
-                    },
-                })
-        else:
-            result.append(block)
-    return result
-
-
-def _convert_messages_to_anthropic(messages: List[Dict[str, Any]]) -> tuple:
-    """
-    将 OpenAI 格式消息转换为 Anthropic Messages API 格式
-
-    Returns:
-        (system_prompt, anthropic_messages)
-    """
-    system_prompt = None
-    anthropic_messages = []
-
-    for msg in messages:
-        role = msg.get("role", "")
-        content = msg.get("content", "")
-
-        if role == "system":
-            # Anthropic 把 system 消息放在顶层参数中
-            system_prompt = content
-        elif role == "user":
-            anthropic_messages.append({"role": "user", "content": _convert_content_to_anthropic(content)})
-        elif role == "assistant":
-            assistant_msg = {"role": "assistant"}
-            # 处理 tool_calls(assistant 发起工具调用)
-            tool_calls = msg.get("tool_calls")
-            if tool_calls:
-                content_blocks = []
-                if content:
-                    # content 可能已被 _add_cache_control 转成 list(含 cache_control),
-                    # 也可能是普通字符串。两者都需要正确处理,避免产生 {"type":"text","text":[...]}
-                    converted = _convert_content_to_anthropic(content)
-                    if isinstance(converted, list):
-                        content_blocks.extend(converted)
-                    elif isinstance(converted, str) and converted.strip():
-                        content_blocks.append({"type": "text", "text": converted})
-                for tc in tool_calls:
-                    func = tc.get("function", {})
-                    args_str = func.get("arguments", "{}")
-                    try:
-                        args = json.loads(args_str) if isinstance(args_str, str) else args_str
-                    except json.JSONDecodeError:
-                        args = {}
-                    content_blocks.append({
-                        "type": "tool_use",
-                        "id": tc.get("id", ""),
-                        "name": func.get("name", ""),
-                        "input": args,
-                    })
-                assistant_msg["content"] = content_blocks
-            else:
-                assistant_msg["content"] = content
-            anthropic_messages.append(assistant_msg)
-        elif role == "tool":
-            # OpenAI tool 结果 -> Anthropic tool_result
-            # Anthropic 要求同一个 assistant 的所有 tool_results 合并到一个 user message 中
-            tool_result_block = {
-                "type": "tool_result",
-                "tool_use_id": msg.get("tool_call_id", ""),
-                "content": _convert_content_to_anthropic(content),
-            }
-            # 如果上一条已经是 tool_result user message,合并进去
-            if (anthropic_messages
-                    and anthropic_messages[-1].get("role") == "user"
-                    and isinstance(anthropic_messages[-1].get("content"), list)
-                    and anthropic_messages[-1]["content"]
-                    and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
-                anthropic_messages[-1]["content"].append(tool_result_block)
-            else:
-                anthropic_messages.append({
-                    "role": "user",
-                    "content": [tool_result_block],
-                })
-
-    return system_prompt, anthropic_messages
-
+    """Preserve Yescode's original image conversion behavior."""
 
-def _convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
-    """将 OpenAI 工具定义转换为 Anthropic 格式"""
-    anthropic_tools = []
-    for tool in tools:
-        if tool.get("type") == "function":
-            func = tool["function"]
-            anthropic_tools.append({
-                "name": func.get("name", ""),
-                "description": func.get("description", ""),
-                "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
-            })
-    return anthropic_tools
-
-
-def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
-    """
-    将 Anthropic Messages API 响应转换为框架统一格式
-
-    Anthropic 响应格式:
-    {
-        "id": "msg_...",
-        "type": "message",
-        "role": "assistant",
-        "content": [{"type": "text", "text": "..."}, {"type": "tool_use", ...}],
-        "usage": {"input_tokens": ..., "output_tokens": ...},
-        "stop_reason": "end_turn" | "tool_use" | "max_tokens"
-    }
-    """
-    content_blocks = result.get("content", [])
-
-    # 提取文本内容
-    text_parts = []
-    tool_calls = []
-    for block in content_blocks:
-        if block.get("type") == "text":
-            text_parts.append(block.get("text", ""))
-        elif block.get("type") == "tool_use":
-            # 转换为 OpenAI tool_calls 格式
-            tool_calls.append({
-                "id": block.get("id", ""),
-                "type": "function",
-                "function": {
-                    "name": block.get("name", ""),
-                    "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
-                },
-            })
+    return to_anthropic_content(
+        content,
+        resolve_local_files=False,
+        include_image_metadata=False,
+    )
 
-    content = "\n".join(text_parts)
 
-    # 映射 stop_reason
-    stop_reason = result.get("stop_reason", "end_turn")
-    finish_reason_map = {
-        "end_turn": "stop",
-        "tool_use": "tool_calls",
-        "max_tokens": "length",
-        "stop_sequence": "stop",
-    }
-    finish_reason = finish_reason_map.get(stop_reason, stop_reason)
-
-    # 提取 usage(Anthropic 原生格式)
-    raw_usage = result.get("usage", {})
-    usage = TokenUsage(
-        input_tokens=raw_usage.get("input_tokens", 0),
-        output_tokens=raw_usage.get("output_tokens", 0),
-        cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
-        cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
+def _convert_messages_to_anthropic(
+    messages: List[Dict[str, Any]],
+) -> tuple[Any, List[Dict[str, Any]]]:
+    """Preserve Yescode's nested tool-result image representation."""
+
+    return to_anthropic_messages(
+        messages,
+        resolve_local_files=False,
+        include_image_metadata=False,
+        split_tool_result_images=False,
     )
 
-    return {
-        "content": content,
-        "tool_calls": tool_calls if tool_calls else None,
-        "finish_reason": finish_reason,
-        "usage": usage,
-    }
-
 
 async def yescode_llm_call(
     messages: List[Dict[str, Any]],
     model: str = "claude-sonnet-4.5",
     tools: Optional[List[Dict]] = None,
-    **kwargs
+    **kwargs,
 ) -> Dict[str, Any]:
-    """
-    Yescode LLM 调用函数
-
-    Args:
-        messages: OpenAI 格式消息列表
-        model: 模型名称(如 "claude-sonnet-4.5")
-        tools: OpenAI 格式工具定义
-        **kwargs: 其他参数(temperature, max_tokens 等)
+    """Call a Yescode Anthropic-compatible endpoint."""
 
-    Returns:
-        统一格式的响应字典
-    """
     base_url = os.getenv("YESCODE_BASE_URL")
     api_key = os.getenv("YESCODE_API_KEY")
-
     if not base_url:
         raise ValueError("YESCODE_BASE_URL environment variable not set")
     if not api_key:
         raise ValueError("YESCODE_API_KEY environment variable not set")
 
-    base_url = base_url.rstrip("/")
-    endpoint = f"{base_url}/v1/messages"
-
-    # 解析模型名
+    endpoint = f"{base_url.rstrip('/')}/v1/messages"
     api_model = _resolve_model(model)
-
-    # 跨 Provider 续跑时,重写不兼容的 tool_call_id
     messages = _normalize_tool_call_ids(messages, "toolu")
-
-    # 转换消息格式
     system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
 
-    # 构建 Anthropic 格式请求
     payload = {
         "model": api_model,
         "messages": anthropic_messages,
         "max_tokens": kwargs.get("max_tokens", 16384),
     }
-
     if system_prompt:
         payload["system"] = system_prompt
-
     if tools:
         payload["tools"] = _convert_tools_to_anthropic(tools)
-
     if "temperature" in kwargs:
         payload["temperature"] = kwargs["temperature"]
 
@@ -397,7 +96,6 @@ async def yescode_llm_call(
         "user-agent": "claude-code/1.0.0",
     }
 
-    # 调用 API(带重试)
     max_retries = 5
     last_exception = None
     for attempt in range(max_retries):
@@ -407,81 +105,63 @@ async def yescode_llm_call(
                 response.raise_for_status()
                 result = response.json()
                 break
-
-            except httpx.HTTPStatusError as e:
-                error_body = e.response.text
-                status = e.response.status_code
-                if status in (429, 500, 502, 503, 504, 524, 529) and attempt < max_retries - 1:
-                    wait = 2 ** attempt * 2
+            except httpx.HTTPStatusError as exc:
+                error_body = exc.response.text
+                status = exc.response.status_code
+                if (
+                    status in (429, 500, 502, 503, 504, 524, 529)
+                    and attempt < max_retries - 1
+                ):
+                    wait = 2**attempt * 2
                     logger.warning(
                         "[Yescode] HTTP %d (attempt %d/%d), retrying in %ds: %s",
-                        status, attempt + 1, max_retries, wait, error_body[:200],
+                        status,
+                        attempt + 1,
+                        max_retries,
+                        wait,
+                        error_body[:200],
                     )
                     await asyncio.sleep(wait)
-                    last_exception = e
+                    last_exception = exc
                     continue
                 logger.error("[Yescode] Error %d: %s", status, error_body)
-                print(f"[Yescode] API Error {status}: {error_body[:500]}")
                 raise
-
-            except _RETRYABLE_EXCEPTIONS as e:
-                last_exception = e
+            except _RETRYABLE_EXCEPTIONS as exc:
+                last_exception = exc
                 if attempt < max_retries - 1:
-                    wait = 2 ** attempt * 2
+                    wait = 2**attempt * 2
                     logger.warning(
                         "[Yescode] %s (attempt %d/%d), retrying in %ds",
-                        type(e).__name__, attempt + 1, max_retries, wait,
+                        type(exc).__name__,
+                        attempt + 1,
+                        max_retries,
+                        wait,
                     )
                     await asyncio.sleep(wait)
                     continue
-                logger.error("[Yescode] Request failed after %d attempts: %s", max_retries, e)
+                logger.error(
+                    "[Yescode] Request failed after %d attempts: %s",
+                    max_retries,
+                    exc,
+                )
                 raise
-
-            except Exception as e:
-                logger.error("[Yescode] Request failed: %s", e)
+            except Exception as exc:
+                logger.error("[Yescode] Request failed: %s", exc)
                 raise
     else:
         raise last_exception  # type: ignore[misc]
 
-    # 解析 Anthropic 响应并转换为统一格式
-    parsed = _parse_anthropic_response(result)
-    usage = parsed["usage"]
-
-    # 计算费用
-    cost = calculate_cost(model, usage)
-
-    return {
-        "content": parsed["content"],
-        "tool_calls": parsed["tool_calls"],
-        "prompt_tokens": usage.input_tokens,
-        "completion_tokens": usage.output_tokens,
-        "reasoning_tokens": usage.reasoning_tokens,
-        "cache_creation_tokens": usage.cache_creation_tokens,
-        "cache_read_tokens": usage.cache_read_tokens,
-        "finish_reason": parsed["finish_reason"],
-        "cost": cost,
-        "usage": usage,
-    }
-
+    return build_anthropic_result(_parse_anthropic_response(result), model)
 
-def create_yescode_llm_call(
-    model: str = "claude-sonnet-4.5"
-):
-    """
-    创建 Yescode LLM 调用函数
 
-    Args:
-        model: 模型名称
-            - "claude-sonnet-4.5"
+def create_yescode_llm_call(model: str = "claude-sonnet-4.5"):
+    """Create a Yescode LLM callable bound to a default model."""
 
-    Returns:
-        异步 LLM 调用函数
-    """
     async def llm_call(
         messages: List[Dict[str, Any]],
         model: str = model,
         tools: Optional[List[Dict]] = None,
-        **kwargs
+        **kwargs,
     ) -> Dict[str, Any]:
         return await yescode_llm_call(messages, model, tools, **kwargs)
 

+ 245 - 0
agent/agent/orchestration/_decision_engine.py

@@ -0,0 +1,245 @@
+"""Pure Planner-decision rules for the explicit orchestration aggregate."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any, Callable, Dict, Optional
+
+from ._task_graph import ACTIVE_TASK_STATUSES, TERMINAL_TASK_STATUSES, TaskGraph
+from .errors import TaskConflict
+from .models import (
+    DecisionAction,
+    PlannerDecision,
+    TaskLedger,
+    TaskRecord,
+    TaskStatus,
+    ValidationReport,
+    ValidationRunStatus,
+    ValidationVerdict,
+    json_values,
+    new_id,
+    utc_now,
+)
+from .state_machine import transition
+
+
+class DecisionEngine:
+    """Apply a Planner decision to a caller-owned in-memory ledger."""
+
+    def __init__(
+        self,
+        task_graph: TaskGraph,
+        max_repair_continuations: Callable[[], int],
+    ) -> None:
+        self._task_graph = task_graph
+        self._max_repair_continuations = max_repair_continuations
+
+    def apply(
+        self,
+        ledger: TaskLedger,
+        *,
+        task_id: str,
+        validation_id: Optional[str],
+        action: DecisionAction,
+        payload: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        task = self._task(ledger, task_id)
+        before = task.status
+        is_root = task.task_id == ledger.root_task_id
+        validation = ledger.validations.get(validation_id) if validation_id else None
+        attempt_id = (
+            validation.attempt_id
+            if validation
+            else task.attempt_ids[-1]
+            if task.attempt_ids
+            else None
+        )
+        reason = str(payload.get("reason", "")).strip()
+        if not reason and action != DecisionAction.UNBLOCK:
+            raise ValueError("Planner decision reason is required")
+
+        if action == DecisionAction.ACCEPT:
+            self.guard_accept(task, validation, ledger)
+            target = TaskStatus.COMPLETED
+        elif action == DecisionAction.REPAIR:
+            self.guard_replan(task, validation, ledger)
+            count_key = str(task.current_spec_version)
+            used = task.repair_count_by_version.get(count_key, 0)
+            if used >= self._max_repair_continuations():
+                raise TaskConflict(
+                    "Repair continuation limit reached; retry, revise, split, or block"
+                )
+            task.repair_count_by_version[count_key] = used + 1
+            target = TaskStatus.PENDING
+        elif action == DecisionAction.RETRY:
+            self._require_replan_state(task, "Retry")
+            target = TaskStatus.PENDING
+        elif action == DecisionAction.REVISE:
+            self._require_replan_state(task, "Revise")
+            objective = payload.get("objective", task.current_spec.objective)
+            version = task.current_spec_version + 1
+            draft = {
+                "objective": objective,
+                "acceptance_criteria": payload.get(
+                    "acceptance_criteria",
+                    [
+                        json_values(asdict(item))
+                        for item in task.current_spec.acceptance_criteria
+                    ],
+                ),
+                "context_refs": payload.get(
+                    "context_refs",
+                    task.current_spec.context_refs,
+                ),
+            }
+            task.specs.append(TaskGraph.task_spec_from_draft(draft, version=version))
+            task.current_spec_version = version
+            target = TaskStatus.PENDING
+        elif action == DecisionAction.SPLIT:
+            self._require_replan_state(task, "Split")
+            drafts = payload.get("tasks") or []
+            if not drafts:
+                raise ValueError("Split requires payload.tasks")
+            child_ids = self._task_graph.create_child_records(ledger, task, drafts)
+            payload["child_task_ids"] = child_ids
+            target = TaskStatus.WAITING_CHILDREN
+        elif action == DecisionAction.BLOCK:
+            if task.status in TERMINAL_TASK_STATUSES:
+                raise TaskConflict("Terminal task cannot be blocked")
+            if any(
+                child.status in ACTIVE_TASK_STATUSES
+                for child in TaskGraph.nonterminal_descendants(ledger, task)
+            ):
+                raise TaskConflict("Task with active descendants cannot be blocked")
+            task.blocked_reason = reason
+            target = TaskStatus.BLOCKED
+        elif action == DecisionAction.UNBLOCK:
+            if task.status != TaskStatus.BLOCKED:
+                raise TaskConflict("Only a blocked task can be unblocked")
+            task.blocked_reason = None
+            target = TaskStatus.NEEDS_REPLAN
+        elif action == DecisionAction.CANCEL:
+            self._guard_replace_or_cancel(ledger, task, is_root, "cancelled")
+            target = TaskStatus.CANCELLED
+        elif action == DecisionAction.SUPERSEDE:
+            self._guard_replace_or_cancel(ledger, task, is_root, "superseded")
+            replacement = payload.get("replacement") or {}
+            replacement_ids = self._task_graph.create_sibling_records(
+                ledger,
+                task,
+                [replacement],
+            )
+            task.superseded_by = replacement_ids[0]
+            payload["replacement_task_id"] = replacement_ids[0]
+            target = TaskStatus.SUPERSEDED
+        elif action == DecisionAction.REVALIDATE:
+            raise ValueError("Use revalidate_attempt() / validate_attempt tool")
+        else:
+            raise ValueError(f"Unsupported decision action: {action.value}")
+
+        transition(before, target)
+        task.status = target
+        task.updated_at = utc_now()
+        decision = PlannerDecision(
+            decision_id=new_id(),
+            task_id=task_id,
+            action=action,
+            reason=reason,
+            from_status=before,
+            to_status=target,
+            attempt_id=attempt_id,
+            validation_id=validation_id,
+            payload=payload,
+        )
+        ledger.decisions[decision.decision_id] = decision
+        task.decision_ids.append(decision.decision_id)
+        TaskGraph.update_parent_after_child(ledger, task)
+        return {
+            "task_id": task_id,
+            "decision_id": decision.decision_id,
+            "action": action.value,
+            "status": target.value,
+            "payload": payload,
+        }
+
+    @staticmethod
+    def guard_accept(
+        task: TaskRecord,
+        validation: Optional[ValidationReport],
+        ledger: TaskLedger,
+    ) -> None:
+        if task.status != TaskStatus.AWAITING_DECISION:
+            raise TaskConflict("Accept requires awaiting_decision")
+        if not validation or validation.status != ValidationRunStatus.COMPLETED:
+            raise TaskConflict("Accept requires a completed validation")
+        if validation.spec_version != task.current_spec_version:
+            raise TaskConflict("Validation belongs to an obsolete TaskSpec version")
+        if validation.verdict != ValidationVerdict.PASSED:
+            raise TaskConflict("Only a passed validation can be accepted")
+        if not task.attempt_ids or validation.attempt_id != task.attempt_ids[-1]:
+            raise TaskConflict("Validation does not belong to the current attempt")
+        attempt = ledger.attempts[validation.attempt_id]
+        if validation.snapshot_id != attempt.snapshot_id:
+            raise TaskConflict("Validation does not belong to the current snapshot")
+        if TaskGraph.nonterminal_descendants(ledger, task):
+            raise TaskConflict(
+                "Task cannot be accepted while descendants are non-terminal"
+            )
+
+    @staticmethod
+    def guard_replan(
+        task: TaskRecord,
+        validation: Optional[ValidationReport],
+        ledger: TaskLedger,
+    ) -> None:
+        if task.status != TaskStatus.AWAITING_DECISION:
+            raise TaskConflict("Repair requires awaiting_decision")
+        if not validation or validation.status != ValidationRunStatus.COMPLETED:
+            raise TaskConflict("Repair requires a completed validation")
+        if validation.spec_version != task.current_spec_version:
+            raise TaskConflict(
+                "Repair validation belongs to an obsolete TaskSpec version"
+            )
+        if not task.attempt_ids or validation.attempt_id != task.attempt_ids[-1]:
+            raise TaskConflict(
+                "Repair validation does not belong to the current attempt"
+            )
+        attempt = ledger.attempts[validation.attempt_id]
+        if validation.snapshot_id != attempt.snapshot_id:
+            raise TaskConflict(
+                "Repair validation does not belong to the current snapshot"
+            )
+        if validation.verdict == ValidationVerdict.PASSED:
+            raise TaskConflict("Passed work should be accepted, not repaired")
+
+    @staticmethod
+    def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
+        try:
+            return ledger.tasks[task_id]
+        except KeyError as exc:
+            raise ValueError(f"Task not found: {task_id}") from exc
+
+    @staticmethod
+    def _require_replan_state(task: TaskRecord, action: str) -> None:
+        if task.status not in (
+            TaskStatus.AWAITING_DECISION,
+            TaskStatus.NEEDS_REPLAN,
+        ):
+            raise TaskConflict(f"{action} requires awaiting_decision or needs_replan")
+
+    @staticmethod
+    def _guard_replace_or_cancel(
+        ledger: TaskLedger,
+        task: TaskRecord,
+        is_root: bool,
+        action: str,
+    ) -> None:
+        if is_root:
+            raise TaskConflict(f"The root task cannot be {action}")
+        if task.status in TERMINAL_TASK_STATUSES:
+            raise TaskConflict("Task is already terminal")
+        if TaskGraph.nonterminal_descendants(ledger, task):
+            raise TaskConflict(f"Task with non-terminal descendants cannot be {action}")
+
+
+__all__ = ["DecisionEngine"]

+ 175 - 0
agent/agent/orchestration/_goal_projection.py

@@ -0,0 +1,175 @@
+"""Best-effort TaskLedger-to-GoalTree compatibility projection."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Awaitable, Callable, Dict, Optional
+
+from agent.trace.goal_models import GoalTree
+
+from .models import TaskLedger, TaskRecord, TaskStatus
+from .protocols import TaskStore
+
+
+logger = logging.getLogger(__name__)
+
+
+GOAL_STATUS_BY_TASK_STATUS = {
+    TaskStatus.PENDING: "pending",
+    TaskStatus.RUNNING: "in_progress",
+    TaskStatus.AWAITING_VALIDATION: "in_progress",
+    TaskStatus.VALIDATING: "in_progress",
+    TaskStatus.AWAITING_DECISION: "in_progress",
+    TaskStatus.NEEDS_REPLAN: "in_progress",
+    TaskStatus.WAITING_CHILDREN: "in_progress",
+    TaskStatus.BLOCKED: "in_progress",
+    TaskStatus.COMPLETED: "completed",
+    TaskStatus.CANCELLED: "abandoned",
+    TaskStatus.SUPERSEDED: "abandoned",
+}
+
+
+class GoalProjection:
+    """Project authoritative task state into the legacy GoalTree view.
+
+    The projection never commits a ledger directly. Linking a projected Goal
+    back to its Task is delegated to the coordinator's mutation callback so the
+    coordinator remains the single persistence boundary.
+    """
+
+    def __init__(
+        self,
+        *,
+        task_store: TaskStore,
+        get_trace_store: Callable[[], Any],
+        mutate: Callable[..., Awaitable[Dict[str, Any]]],
+    ) -> None:
+        self._task_store = task_store
+        self._get_trace_store = get_trace_store
+        self._mutate = mutate
+
+    async def project_state(self, root_trace_id: str, task_id: str) -> None:
+        trace_store = self._get_trace_store()
+        if not trace_store:
+            return
+        ledger = await self._task_store.load(root_trace_id)
+        task = _task(ledger, task_id)
+        if not task.goal_id:
+            return
+        summary = None
+        if task.decision_ids:
+            summary = ledger.decisions[task.decision_ids[-1]].reason
+        await trace_store.update_goal(
+            root_trace_id,
+            task.goal_id,
+            cascade_completion=False,
+            status=GOAL_STATUS_BY_TASK_STATUS[task.status],
+            summary=summary,
+        )
+
+    async def project_compatibility(
+        self,
+        root_trace_id: str,
+        task_id: str,
+        *,
+        ensure: bool = False,
+        after_task_id: Optional[str] = None,
+    ) -> None:
+        """Run projection without invalidating an authoritative Ledger commit."""
+
+        if not self._get_trace_store():
+            return
+        try:
+            if ensure:
+                await self.ensure(
+                    root_trace_id,
+                    task_id,
+                    after_task_id=after_task_id,
+                )
+            await self.project_state(root_trace_id, task_id)
+        except Exception as exc:
+            self.log_failure(root_trace_id, task_id, exc)
+
+    @staticmethod
+    def log_failure(
+        root_trace_id: str,
+        task_id: str,
+        error: Exception,
+    ) -> None:
+        logger.warning(
+            "Goal compatibility projection failed after Ledger commit "
+            "(%s, %s): %s",
+            root_trace_id,
+            task_id,
+            error,
+        )
+
+    async def ensure(
+        self,
+        root_trace_id: str,
+        task_id: str,
+        after_task_id: Optional[str] = None,
+    ) -> None:
+        trace_store = self._get_trace_store()
+        if not trace_store:
+            return
+        ledger = await self._task_store.load(root_trace_id)
+        task = _task(ledger, task_id)
+        tree = await trace_store.get_goal_tree(root_trace_id)
+        if tree is None:
+            tree = GoalTree(mission=ledger.root_objective)
+        if task.goal_id and tree.find(task.goal_id):
+            return
+        parent_goal_id = None
+        if task.parent_task_id:
+            parent = ledger.tasks[task.parent_task_id]
+            if not parent.goal_id:
+                await self.ensure(root_trace_id, parent.task_id)
+                ledger = await self._task_store.load(root_trace_id)
+                parent = ledger.tasks[task.parent_task_id]
+                tree = await trace_store.get_goal_tree(root_trace_id) or tree
+            parent_goal_id = parent.goal_id
+        after_goal_id = None
+        if after_task_id and after_task_id in ledger.tasks:
+            after_goal_id = ledger.tasks[after_task_id].goal_id
+        if after_goal_id and tree.find(after_goal_id):
+            goal = tree.add_goals_after(
+                after_goal_id,
+                descriptions=[task.current_spec.objective],
+                reasons=["TaskLedger compatibility projection"],
+            )[0]
+        else:
+            goal = tree.add_goals(
+                descriptions=[task.current_spec.objective],
+                reasons=["TaskLedger compatibility projection"],
+                parent_id=parent_goal_id,
+            )[0]
+        await trace_store.update_goal_tree(root_trace_id, tree)
+
+        def link(current: TaskLedger) -> Dict[str, Any]:
+            current_task = _task(current, task_id)
+            if current_task.goal_id is None:
+                current_task.goal_id = goal.id
+            return {"task_id": task_id, "goal_id": current_task.goal_id}
+
+        await self._mutate(root_trace_id, "goal_projection_linked", link)
+
+    async def reconcile(self, root_trace_id: str) -> Dict[str, Any]:
+        ledger = await self._task_store.load(root_trace_id)
+        for task_id in ledger.tasks:
+            await self.ensure(root_trace_id, task_id)
+            await self.project_state(root_trace_id, task_id)
+        return {
+            "root_trace_id": root_trace_id,
+            "reconciled_tasks": len(ledger.tasks),
+        }
+
+
+def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
+    try:
+        return ledger.tasks[task_id]
+    except KeyError as exc:
+        raise ValueError(f"Task not found: {task_id}") from exc
+
+
+__all__ = ["GOAL_STATUS_BY_TASK_STATUS", "GoalProjection"]

+ 353 - 0
agent/agent/orchestration/_task_graph.py

@@ -0,0 +1,353 @@
+"""Pure task-tree operations used by :mod:`agent.orchestration.coordinator`.
+
+The task ledger remains owned and committed by ``TaskCoordinator``.  This
+module only mutates the in-memory aggregate handed to it; it never performs
+I/O, acquires locks, or commits state on its own.
+"""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from .errors import OrchestrationError, TaskConflict
+from .models import (
+    DecisionAction,
+    PlannerDecision,
+    TaskAttempt,
+    TaskLedger,
+    TaskRecord,
+    TaskSpec,
+    TaskStatus,
+    ValidationReport,
+    ValidationRunStatus,
+    ValidationVerdict,
+    json_values,
+    new_id,
+    utc_now,
+)
+from .state_machine import transition
+
+
+TERMINAL_TASK_STATUSES = {
+    TaskStatus.COMPLETED,
+    TaskStatus.CANCELLED,
+    TaskStatus.SUPERSEDED,
+}
+
+ACTIVE_TASK_STATUSES = {
+    TaskStatus.RUNNING,
+    TaskStatus.AWAITING_VALIDATION,
+    TaskStatus.VALIDATING,
+}
+
+
+def path_key(display_path: str) -> Tuple[int, ...]:
+    """Return a stable numeric sort key for a dotted task display path."""
+
+    try:
+        return tuple(int(part) for part in display_path.split("."))
+    except ValueError:
+        return (10**9,)
+
+
+class TaskGraph:
+    """Maintain task hierarchy and accepted child-result bindings.
+
+    All methods operate on a caller-owned ``TaskLedger``.  Keeping persistence
+    out of this component preserves ``TaskCoordinator`` as the sole writer.
+    """
+
+    @staticmethod
+    def accepted_child_results(
+        ledger: TaskLedger,
+        task: TaskRecord,
+        attempt: Optional[TaskAttempt] = None,
+    ) -> List[Dict[str, Any]]:
+        """Build immutable, accepted direct-child inputs for a parent Worker."""
+
+        results: List[Dict[str, Any]] = []
+        if attempt is None:
+            decision_ids = TaskGraph.accepted_child_decision_ids(ledger, task)
+        elif attempt.accepted_child_decision_ids is None:
+            raise OrchestrationError(
+                "Attempt has no frozen accepted child decision binding"
+            )
+        else:
+            decision_ids = attempt.accepted_child_decision_ids
+        if len(set(decision_ids)) != len(decision_ids):
+            raise OrchestrationError("Attempt child decision bindings are not unique")
+        expected_decision_ids = TaskGraph.accepted_child_decision_ids(ledger, task)
+        if tuple(decision_ids) != expected_decision_ids:
+            raise OrchestrationError(
+                "Attempt child decision bindings do not match direct children in "
+                "display_path order"
+            )
+        direct_children = set(task.child_task_ids)
+        prior_path: Optional[Tuple[int, ...]] = None
+        for decision_id in decision_ids:
+            decision = ledger.decisions.get(decision_id)
+            if (
+                decision is None
+                or decision.task_id not in direct_children
+                or decision.action != DecisionAction.ACCEPT
+            ):
+                raise OrchestrationError(
+                    "Attempt child decision binding is not a direct-child ACCEPT"
+                )
+            child = ledger.tasks[decision.task_id]
+            child_path = path_key(child.display_path)
+            if prior_path is not None and child_path < prior_path:
+                raise OrchestrationError(
+                    "Attempt child decision bindings are not in display_path order"
+                )
+            prior_path = child_path
+            bound_decision, child_attempt, validation = TaskGraph.accepted_result_binding(
+                ledger, child
+            )
+            if bound_decision.decision_id != decision_id:
+                raise OrchestrationError(
+                    "Attempt child decision binding is not the child's accepted result"
+                )
+            results.append(
+                {
+                    "task_id": child.task_id,
+                    "task_spec": json_values(asdict(child.current_spec)),
+                    "attempt_id": child_attempt.attempt_id,
+                    "snapshot_id": child_attempt.snapshot_id,
+                    "submission": json_values(asdict(child_attempt.submission)),
+                    "validation": {
+                        "validation_id": validation.validation_id,
+                        "summary": validation.summary,
+                        "evidence_refs": json_values(
+                            asdict(validation)["evidence_refs"]
+                        ),
+                    },
+                }
+            )
+        return results
+
+    @staticmethod
+    def accepted_child_decision_ids(
+        ledger: TaskLedger,
+        task: TaskRecord,
+    ) -> Tuple[str, ...]:
+        decision_ids: List[str] = []
+        children = sorted(
+            (ledger.tasks[child_id] for child_id in task.child_task_ids),
+            key=lambda child: path_key(child.display_path),
+        )
+        for child in children:
+            if child.status == TaskStatus.COMPLETED:
+                decision, _attempt, _validation = TaskGraph.accepted_result_binding(
+                    ledger, child
+                )
+                decision_ids.append(decision.decision_id)
+        return tuple(decision_ids)
+
+    @staticmethod
+    def root_task(ledger: TaskLedger) -> TaskRecord:
+        if not ledger.root_task_id:
+            raise TaskConflict(
+                "Rootless task ledger is incompatible with mission execution; "
+                "rebuild the development trace"
+            )
+        root = ledger.tasks.get(ledger.root_task_id)
+        top_level = [
+            task.task_id
+            for task in ledger.tasks.values()
+            if task.parent_task_id is None
+        ]
+        if not root or root.parent_task_id is not None or top_level != [root.task_id]:
+            raise OrchestrationError("Task ledger Root Task structure is invalid")
+        return root
+
+    @staticmethod
+    def accepted_result_binding(
+        ledger: TaskLedger,
+        task: TaskRecord,
+    ) -> Tuple[PlannerDecision, TaskAttempt, ValidationReport]:
+        if task.status != TaskStatus.COMPLETED or not task.decision_ids:
+            raise OrchestrationError(f"Task {task.task_id} has no accepted result")
+        decision = ledger.decisions.get(task.decision_ids[-1])
+        if (
+            not decision
+            or decision.task_id != task.task_id
+            or decision.action != DecisionAction.ACCEPT
+            or not decision.attempt_id
+            or not decision.validation_id
+        ):
+            raise OrchestrationError(
+                f"Task {task.task_id} has an invalid accept decision"
+            )
+        attempt = ledger.attempts.get(decision.attempt_id)
+        validation = ledger.validations.get(decision.validation_id)
+        if (
+            not attempt
+            or attempt.task_id != task.task_id
+            or not attempt.submission
+            or not attempt.snapshot_id
+            or attempt.spec_version != task.current_spec_version
+            or not validation
+            or validation.task_id != task.task_id
+            or validation.attempt_id != attempt.attempt_id
+            or validation.snapshot_id != attempt.snapshot_id
+            or validation.spec_version != task.current_spec_version
+            or validation.status != ValidationRunStatus.COMPLETED
+            or validation.verdict != ValidationVerdict.PASSED
+        ):
+            raise OrchestrationError(
+                f"Task {task.task_id} has an invalid accepted result binding"
+            )
+        return decision, attempt, validation
+
+    @staticmethod
+    def nonterminal_descendants(
+        ledger: TaskLedger,
+        task: TaskRecord,
+    ) -> List[TaskRecord]:
+        descendants: List[TaskRecord] = []
+        pending_ids = list(task.child_task_ids)
+        while pending_ids:
+            child = ledger.tasks[pending_ids.pop()]
+            pending_ids.extend(child.child_task_ids)
+            if child.status not in TERMINAL_TASK_STATUSES:
+                descendants.append(child)
+        return descendants
+
+    def create_child_records(
+        self,
+        ledger: TaskLedger,
+        parent: TaskRecord,
+        drafts: Sequence[Dict[str, Any]],
+    ) -> List[str]:
+        return self.create_records(ledger, drafts, parent.task_id, parent.display_path)
+
+    def create_sibling_records(
+        self,
+        ledger: TaskLedger,
+        task: TaskRecord,
+        drafts: Sequence[Dict[str, Any]],
+    ) -> List[str]:
+        parent_path = (
+            ledger.tasks[task.parent_task_id].display_path
+            if task.parent_task_id
+            else ""
+        )
+        return self.create_records(ledger, drafts, task.parent_task_id, parent_path)
+
+    @staticmethod
+    def add_task_record(
+        ledger: TaskLedger,
+        draft: Dict[str, Any],
+        parent_task_id: Optional[str],
+        display_path: str,
+    ) -> str:
+        task_id = new_id()
+        spec = TaskGraph.task_spec_from_draft(draft, version=1)
+        ledger.tasks[task_id] = TaskRecord(
+            task_id=task_id,
+            goal_id=None,
+            parent_task_id=parent_task_id,
+            display_path=display_path,
+            specs=[spec],
+        )
+        return task_id
+
+    @staticmethod
+    def task_spec_from_draft(draft: Dict[str, Any], *, version: int) -> TaskSpec:
+        if not isinstance(draft, dict):
+            raise ValueError("Task draft must be an object")
+        return TaskSpec.from_dict(
+            {
+                "version": version,
+                "objective": draft.get("objective", ""),
+                "acceptance_criteria": draft.get("acceptance_criteria", []),
+                "context_refs": draft.get("context_refs", ()),
+            }
+        )
+
+    def create_records(
+        self,
+        ledger: TaskLedger,
+        drafts: Sequence[Dict[str, Any]],
+        parent_task_id: Optional[str],
+        parent_path: str,
+    ) -> List[str]:
+        siblings = sorted(
+            (
+                task
+                for task in ledger.tasks.values()
+                if task.parent_task_id == parent_task_id
+            ),
+            key=lambda item: path_key(item.display_path),
+        )
+        created: List[str] = []
+        for offset, draft in enumerate(drafts, start=1):
+            index = len(siblings) + offset
+            display_path = f"{parent_path}.{index}" if parent_path else str(index)
+            task_id = self.add_task_record(
+                ledger,
+                draft,
+                parent_task_id,
+                display_path,
+            )
+            if parent_task_id:
+                ledger.tasks[parent_task_id].child_task_ids.append(task_id)
+            created.append(task_id)
+        return created
+
+    @staticmethod
+    def mark_parent_waiting(parent: TaskRecord) -> None:
+        if parent.status == TaskStatus.WAITING_CHILDREN:
+            return
+        transition(parent.status, TaskStatus.WAITING_CHILDREN)
+        parent.status = TaskStatus.WAITING_CHILDREN
+        parent.updated_at = utc_now()
+
+    @staticmethod
+    def update_parent_after_child(ledger: TaskLedger, child: TaskRecord) -> None:
+        if not child.parent_task_id or child.status not in TERMINAL_TASK_STATUSES:
+            return
+        parent = ledger.tasks[child.parent_task_id]
+        if parent.status != TaskStatus.WAITING_CHILDREN:
+            return
+        children = [ledger.tasks[child_id] for child_id in parent.child_task_ids]
+        if children and all(
+            candidate.status in TERMINAL_TASK_STATUSES for candidate in children
+        ):
+            transition(parent.status, TaskStatus.NEEDS_REPLAN)
+            parent.status = TaskStatus.NEEDS_REPLAN
+        parent.updated_at = utc_now()
+
+    @staticmethod
+    def display_path(
+        ledger: TaskLedger,
+        parent_task_id: Optional[str],
+        index: int,
+    ) -> str:
+        if not parent_task_id:
+            return str(index)
+        return f"{ledger.tasks[parent_task_id].display_path}.{index}"
+
+    @staticmethod
+    def rebase_task_path(
+        ledger: TaskLedger,
+        task: TaskRecord,
+        display_path: str,
+    ) -> None:
+        task.display_path = display_path
+        for index, child_id in enumerate(task.child_task_ids, start=1):
+            TaskGraph.rebase_task_path(
+                ledger,
+                ledger.tasks[child_id],
+                f"{display_path}.{index}",
+            )
+
+
+__all__ = [
+    "ACTIVE_TASK_STATUSES",
+    "TERMINAL_TASK_STATUSES",
+    "TaskGraph",
+    "path_key",
+]

Разница между файлами не показана из-за своего большого размера
+ 286 - 285
agent/agent/orchestration/coordinator.py


+ 6 - 1
agent/agent/orchestration/executor.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import json
+import logging
 from collections.abc import Mapping
 from copy import deepcopy
 from dataclasses import replace
@@ -11,7 +12,7 @@ from types import MappingProxyType
 from typing import Any, Dict, Iterable, Optional, Tuple
 
 from agent.core.runner import RunConfig
-from agent.tools.builtin.knowledge import KnowledgeConfig
+from agent.core.knowledge_config import KnowledgeConfig
 
 from .models import AgentRole, CompletionPolicy, ExecutionStats, FailureCode
 from .protocols import ValidatorRunResult, WorkerRunResult
@@ -23,6 +24,9 @@ from .run_config import (
 )
 
 
+logger = logging.getLogger(__name__)
+
+
 class LocalAgentExecutor:
     """Run workers and validators with the same Runner in isolated traces."""
 
@@ -298,6 +302,7 @@ class LocalAgentExecutor:
         try:
             return await trace_store.get_trace(trace_id)
         except Exception:
+            logger.debug("Unable to reload local sub-trace %s", trace_id, exc_info=True)
             return None
 
     @staticmethod

+ 3 - 1
agent/agent/orchestration/operations.py

@@ -785,10 +785,12 @@ def stage_timeout(
 def _failure_stats(
     stats: Optional[ExecutionStats],
     fallback: FailureCode,
+    *,
+    override: bool = False,
 ) -> ExecutionStats:
     if stats is None:
         return ExecutionStats(failure_code=fallback)
-    if stats.failure_code is not None:
+    if stats.failure_code is not None and not override:
         return stats
     return replace(stats, failure_code=fallback)
 

+ 0 - 2
agent/agent/skill/skill_loader.py

@@ -33,8 +33,6 @@ parent: parent-id
 ...
 """
 
-import os
-import re
 from pathlib import Path
 from typing import List, Dict, Optional
 import logging

+ 18 - 28
agent/agent/tools/builtin/__init__.py

@@ -1,23 +1,25 @@
-"""
-内置基础工具 - 参考 opencode 实现
+"""Builtin tool registration and compatibility exports."""
 
-这些工具参考 vendor/opencode/packages/opencode/src/tool/ 的设计,
-在 Python 中重新实现核心功能。
-
-参考版本:opencode main branch (2025-01)
-"""
+import importlib.util
 
 from agent.tools.builtin.file.read import read_file
 from agent.tools.builtin.file.read_images import read_images
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.write import write_file
-from agent.tools.builtin.glob_tool import glob_files
+from agent.tools.builtin.file.glob import glob_files
 from agent.tools.builtin.file.grep import grep_content
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.subagent import agent, evaluate
-# sandbox 工具已废弃(2026-04);search.py / crawler.py 已重构为 content/ 工具族(2026-04)
-from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_save_pending,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
+from agent.tools.builtin.knowledge import (
+    knowledge_batch_update as knowledge_batch_update,
+    knowledge_list as knowledge_list,
+    knowledge_save as knowledge_save,
+    knowledge_save_pending,
+    knowledge_search as knowledge_search,
+    knowledge_slim as knowledge_slim,
+    knowledge_update as knowledge_update,
+)
 # Memory / Dream(见 agent/docs/memory.md)
 from agent.tools.builtin.memory import dream
 # 知识上传/查询已统一到 agent 工具:
@@ -40,18 +42,14 @@ from agent.tools.builtin.orchestration import (
     submit_attempt,
     submit_validation,
 )
-# 导入浏览器工具以触发注册 (因 P1 流水线不需要,且加载缓慢,暂时全局屏蔽)
+# Browser tools are opt-in because importing browser-use is comparatively heavy.
 # import agent.tools.builtin.browser  # noqa: F401
 
-try:
+if importlib.util.find_spec("lark_oapi") is not None:
     import agent.tools.builtin.feishu
-except ImportError:
-    pass  # optional feishu extra
 
-try:
+if importlib.util.find_spec("websockets") is not None:
     import agent.tools.builtin.im
-except ImportError:
-    pass  # optional IM/server dependencies
 
 __all__ = [
     # 文件操作
@@ -64,18 +62,10 @@ __all__ = [
     # 系统工具
     "bash_command",
     "skill",
-    # 知识管理:统一通过 agent(agent_type="remote_librarian" / "remote_librarian_ingest" / "remote_research")
-    # 知识管理(旧架构 - 直接 HTTP API,仅供 Knowledge Manager 内部使用)
-    # "knowledge_search",
-    # "knowledge_save",
-    # "knowledge_list",
-    # "knowledge_update",
-    # "knowledge_batch_update",
-    # "knowledge_slim",
     "list_skills",
     "agent",
     "evaluate",
-    # 内容工具族(重构自 search.py + crawler.py)
+    # 内容工具族
     "content_platforms",
     "content_search",
     "content_detail",
@@ -103,6 +93,6 @@ __all__ = [
     "submit_attempt",
     "submit_validation",
     # Memory & Knowledge 提取审核
-    "knowledge_save_pending",  # 反思侧分支暂存(core 组默认可见)
-    "dream",                    # memory-bearing Agent 整理长期记忆(memory 组)
+    "knowledge_save_pending",
+    "dream",
 ]

+ 11 - 1
agent/agent/tools/builtin/browser/__init__.py

@@ -2,7 +2,7 @@
 浏览器工具 - Browser-Use 原生工具适配器
 
 基于 browser-use 实现的浏览器自动化工具集。
-28 个原始工具已合并为 14 个语义化入口(2026-04 重构)
+对外保留稳定的语义化工具入口
 """
 
 from agent.tools.builtin.browser.baseClass import (
@@ -29,6 +29,12 @@ from agent.tools.builtin.browser.baseClass import (
     browser_js,
     browser_download,
 )
+from agent.tools.builtin.browser.providers import (
+    BrowserContainerProvider,
+    BrowserCookieProvider,
+    configure_browser_providers,
+    reset_browser_providers,
+)
 
 __all__ = [
     # 会话管理
@@ -37,6 +43,10 @@ __all__ = [
     'get_browser_live_url',
     'cleanup_browser_session',
     'kill_browser_session',
+    'BrowserContainerProvider',
+    'BrowserCookieProvider',
+    'configure_browser_providers',
+    'reset_browser_providers',
 
     # @tool 入口
     'browser_navigate',

+ 43 - 248
agent/agent/tools/builtin/browser/baseClass.py

@@ -43,35 +43,29 @@ Native Browser-Use Tools Adapter
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
 """
 import logging
-import sys
-import os
 import json
-import httpx
 import asyncio
-import aiohttp
 import re
 import base64
 from urllib.parse import urlparse, parse_qs, unquote
 from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from pathlib import Path
 from langchain_core.runnables import RunnableLambda
-from argparse import Namespace # 使用 Namespace 快速构造带属性的对象
-from langchain_core.messages import AIMessage
+from argparse import Namespace
 from ....llm.qwen import qwen_llm_call
-
-# 将项目根目录添加到 Python 路径
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-# 配置日志
-logger = logging.getLogger(__name__)
-
-# 导入框架的工具装饰器和结果类
 from agent.tools import tool, ToolResult
-from agent.tools.builtin.browser.sync_mysql_help import mysql
+from agent.tools.builtin.browser.downloads import download_direct_url
+from agent.tools.builtin.browser.providers import (
+    get_browser_container_provider,
+    get_browser_cookie_provider,
+)
 
 # 导入 browser-use 的核心类
 from browser_use import BrowserSession, BrowserProfile
+from browser_use.agent.views import ActionResult
+from browser_use.filesystem.file_system import FileSystem
 from browser_use.tools.service import Tools
+
 try:
     from browser_use.tools.views import ReadContentAction  # type: ignore
 except Exception:
@@ -81,8 +75,9 @@ except Exception:
         goal: str
         source: str = "page"
         context: str = ""
-from browser_use.agent.views import ActionResult
-from browser_use.filesystem.file_system import FileSystem
+
+
+logger = logging.getLogger(__name__)
 
 
 # ============================================================
@@ -103,129 +98,9 @@ _last_headless: bool = True
 _live_url: Optional[str] = None
 
 async def create_container(url: str, account_name: str = "liuwenwu") -> Dict[str, Any]:
-    """
-    创建浏览器容器并导航到指定URL
-
-    按照 test.md 的要求:
-    1.1 调用接口创建容器
-    1.2 调用接口创建窗口并导航到URL
+    """Create a container through the Host-configurable provider."""
 
-    Args:
-        url: 要导航的URL地址
-        account_name: 账户名称
-
-    Returns:
-        包含容器信息的字典:
-        - success: 是否成功
-        - container_id: 容器ID
-        - vnc: VNC访问URL
-        - cdp: CDP协议URL(用于浏览器连接)
-        - connection_id: 窗口连接ID
-        - error: 错误信息(如果失败)
-    """
-    result = {
-        "success": False,
-        "container_id": None,
-        "vnc": None,
-        "cdp": None,
-        "connection_id": None,
-        "error": None
-    }
-
-    try:
-        async with aiohttp.ClientSession() as session:
-            # 步骤1.1: 创建容器
-            print("📦 步骤1.1: 创建容器...")
-            create_url = "http://47.84.182.56:8200/api/v1/container/create"
-            create_payload = {
-                "auto_remove": True,
-                "need_port_binding": True,
-                "max_lifetime_seconds": 900
-            }
-
-            async with session.post(create_url, json=create_payload) as resp:
-                if resp.status != 200:
-                    raise RuntimeError(f"创建容器失败: HTTP {resp.status}")
-
-                create_result = await resp.json()
-                if create_result.get("code") != 0:
-                    raise RuntimeError(f"创建容器失败: {create_result.get('msg')}")
-
-                data = create_result.get("data", {})
-                result["container_id"] = data.get("container_id")
-                result["vnc"] = data.get("vnc")
-                result["cdp"] = data.get("cdp")
-
-                print(f"✅ 容器创建成功")
-                print(f"   Container ID: {result['container_id']}")
-                print(f"   VNC: {result['vnc']}")
-                print(f"   CDP: {result['cdp']}")
-
-            # 等待容器内的浏览器启动
-            print(f"\n⏳ 等待容器内浏览器启动...")
-            await asyncio.sleep(5)
-
-            # 步骤1.2: 创建页面并导航
-            print(f"\n📱 步骤1.2: 创建页面并导航到 {url}...")
-
-            page_create_url = "http://47.84.182.56:8200/api/v1/browser/page/create"
-            page_payload = {
-                "container_id": result["container_id"],
-                "url": url,
-                "account_name": account_name,
-                "need_wait": True,
-                "timeout": 30
-            }
-
-            # 重试机制:最多尝试3次
-            max_retries = 3
-            page_created = False
-            last_error = None
-
-            for attempt in range(max_retries):
-                try:
-                    if attempt > 0:
-                        print(f"   重试 {attempt + 1}/{max_retries}...")
-                        await asyncio.sleep(3)  # 重试前等待
-
-                    async with session.post(page_create_url, json=page_payload, timeout=aiohttp.ClientTimeout(total=60)) as resp:
-                        if resp.status != 200:
-                            response_text = await resp.text()
-                            last_error = f"HTTP {resp.status}: {response_text[:200]}"
-                            continue
-
-                        page_result = await resp.json()
-                        if page_result.get("code") != 0:
-                            last_error = f"{page_result.get('msg')}"
-                            continue
-
-                        page_data = page_result.get("data", {})
-                        result["connection_id"] = page_data.get("connection_id")
-                        result["success"] = True
-                        page_created = True
-
-                        print(f"✅ 页面创建成功")
-                        print(f"   Connection ID: {result['connection_id']}")
-                        break
-
-                except asyncio.TimeoutError:
-                    last_error = "请求超时"
-                    continue
-                except aiohttp.ClientError as e:
-                    last_error = f"网络错误: {str(e)}"
-                    continue
-                except Exception as e:
-                    last_error = f"未知错误: {str(e)}"
-                    continue
-
-            if not page_created:
-                raise RuntimeError(f"创建页面失败(尝试{max_retries}次后): {last_error}")
-
-    except Exception as e:
-        result["error"] = str(e)
-        print(f"❌ 错误: {str(e)}")
-
-    return result
+    return await get_browser_container_provider().create(url, account_name)
 
 async def init_browser_session(
     browser_type: str = "local",
@@ -260,8 +135,9 @@ async def init_browser_session(
     }
 
     if browser_type == "container":
-        print("🐳 使用容器浏览器模式")
-        if not url: url = "about:blank"
+        logger.info("Initializing container browser session")
+        if not url:
+            url = "about:blank"
         container_info = await create_container(url=url, account_name=profile_name)
         if not container_info["success"]:
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
@@ -269,13 +145,13 @@ async def init_browser_session(
         await asyncio.sleep(3)
 
     elif browser_type == "cloud":
-        print("🌐 使用云浏览器模式")
+        logger.info("Initializing cloud browser session")
         session_params["use_cloud"] = True
         if profile_name and profile_name != "default":
             session_params["cloud_profile_id"] = profile_name
 
     else:  # local
-        print("💻 使用本地浏览器模式")
+        logger.info("Initializing local browser session")
         session_params["is_local"] = True
         if user_data_dir is None and profile_name:
             user_data_dir = str(Path.home() / ".browser_use" / "profiles" / profile_name)
@@ -303,7 +179,7 @@ async def init_browser_session(
     _browser_tools = Tools()
     _file_system = FileSystem(base_dir=str(save_dir))
 
-    print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
+    logger.info("Browser session initialized: downloads=%s", save_dir)
 
     # 云浏览器:捕获 live URL
     if browser_type == "cloud":
@@ -314,7 +190,7 @@ async def init_browser_session(
             parsed = urllib.parse.urlparse(cdp_url)
             host_url = f"https://{parsed.hostname}"
             _live_url = f"https://live.browser-use.com?wss={urllib.parse.quote(host_url)}"
-            print(f"📡 实时画面链接: {_live_url}")
+            logger.info("Browser live view available: %s", _live_url)
 
     if browser_type in ["local", "cloud"] and url:
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
@@ -355,10 +231,10 @@ async def get_browser_session() -> tuple[BrowserSession, Tools]:
                 )
                 alive = True
         except Exception:
-            pass
+            logger.debug("Browser CDP health check failed", exc_info=True)
 
         if not alive:
-            print("⚠️ 浏览器会话连接已断开,正在重新初始化...")
+            logger.warning("Browser session disconnected; reinitializing")
             try:
                 await cleanup_browser_session()
             except Exception:
@@ -432,10 +308,8 @@ def action_result_to_tool_result(result: ActionResult, title: str = None) -> Too
 
 
 def _cookie_domain_for_type(cookie_type: str, url: str) -> Tuple[str, str]:
-    if cookie_type:
-        key = cookie_type.lower()
-        if key in {"xiaohongshu", "xhs"}:
-            return ".xiaohongshu.com", "https://www.xiaohongshu.com"
+    # ``cookie_type`` remains in the compatibility signature; scope is derived
+    # from the requested URL so the framework does not encode platform names.
     parsed = urlparse(url or "")
     domain = parsed.netloc or ""
     domain = domain.replace("www.", "")
@@ -516,27 +390,9 @@ def _fetch_cookie_row(cookie_type: str) -> Optional[Dict[str, Any]]:
     if not cookie_type:
         return None
     try:
-        return mysql.fetchone(
-            "select * from agent_channel_cookies where type=%s limit 1",
-            (cookie_type,)
-        )
-    except Exception:
-        return None
-
-
-def _fetch_profile_id(cookie_type: str) -> Optional[str]:
-    """从数据库获取 cloud_profile_id"""
-    if not cookie_type:
-        return None
-    try:
-        row = mysql.fetchone(
-            "select profileId from agent_channel_cookies where type=%s limit 1",
-            (cookie_type,)
-        )
-        if row and "profileId" in row:
-            return row["profileId"]
-        return None
-    except Exception:
+        return get_browser_cookie_provider().get_cookie_row(cookie_type)
+    except Exception as exc:
+        logger.warning("Browser cookie lookup failed for %s: %s", cookie_type, exc)
         return None
 
 
@@ -725,71 +581,11 @@ class DownloadLinkCaptureHandler(logging.Handler):
                 # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
                 if "..." not in url:
                     self.captured_url = url
-                    # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
 
 async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
-    save_dir = Path.cwd() / ".cache/.browser_use_files"
-    save_dir.mkdir(parents=True, exist_ok=True)
-    
-    # 提取域名作为 Referer,这能骗过 90% 的防盗链校验
-    from urllib.parse import urlparse
-    parsed_url = urlparse(url)
-    base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
-    
-    # 如果没传 save_name,自动从 URL 获取
-    if not save_name:
-        import unquote
-        # 尝试从 URL 路径获取文件名并解码(处理中文)
-        save_name = Path(urlparse(url).path).name or f"download_{int(time.time())}"
-        save_name = unquote(save_name) 
-
-    target_path = save_dir / save_name
-
-    headers = {
-        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
-        "Accept": "*/*",
-        "Referer": base_url,  # 动态设置 Referer
-        "Range": "bytes=0-",  # 有时对大文件下载有奇效
-    }
+    """Compatibility wrapper for the isolated download implementation."""
 
-    try:
-        print(f"🚀 开始下载: {url[:60]}...")
-        
-        # 使用 follow_redirects=True 处理链接中的 redirection
-        async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=60.0) as client:
-            async with client.stream("GET", url) as response:
-                if response.status_code != 200:
-                    print(f"❌ 下载失败,HTTP 状态码: {response.status_code}")
-                    return
-                
-                # 获取实际文件名(如果服务器提供了)
-                # 这里会优先使用你指定的 save_name
-                
-                with open(target_path, "wb") as f:
-                    downloaded_bytes = 0
-                    async for chunk in response.aiter_bytes():
-                        f.write(chunk)
-                        downloaded_bytes += len(chunk)
-                        if downloaded_bytes % (1024 * 1024) == 0: # 每下载 1MB 打印一次
-                            print(f"📥 已下载: {downloaded_bytes // (1024 * 1024)} MB")
-
-        print(f"✅ 下载完成!文件已存至: {target_path}")
-        success_msg = f"✅ 下载完成!文件已存至: {target_path}"
-        return ToolResult(
-            title="直链下载成功",
-            output=success_msg,
-            long_term_memory=success_msg,
-            metadata={"path": str(target_path)}
-        )
-
-    except Exception as e:
-        # 异常捕获返回
-        return ToolResult(
-            title="下载异常",
-            output="",
-            error=f"💥 发生错误: {str(e)}",
-            long_term_memory=f"下载任务由于异常中断: {str(e)}"
-        )
+    return await download_direct_url(url, save_name)
     
 async def browser_click_element(index: int) -> ToolResult:
     """
@@ -873,7 +669,7 @@ async def browser_input_text(index: int, text: str, clear: bool = True) -> ToolR
             title="输入失败",
             output="",
             error=f"Failed to input text into element {index}: {str(e)}",
-            long_term_memory=f"输入文本失败"
+            long_term_memory="输入文本失败"
         )
 
 
@@ -1362,7 +1158,7 @@ def scrub_search_redirect_url(url: str) -> str:
                 return unquote(found)
                 
     except Exception:
-        pass # 解析失败则返回原链接
+        logger.debug("Search redirect URL parsing failed", exc_info=True)
     
     return url
 
@@ -1387,7 +1183,6 @@ async def extraction_adapter(input_data):
         if clean_url != original_url:
             content = content.replace(original_url, clean_url)
     
-    from argparse import Namespace
     return Namespace(completion=content)
 
 async def browser_extract_content(query: str, extract_links: bool = False,
@@ -1461,7 +1256,7 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
                 content_type = ct_result.get('result', {}).get('value', '')
                 is_pdf = 'pdf' in content_type.lower()
             except Exception:
-                pass
+                logger.debug("Browser PDF content-type probe failed", exc_info=True)
 
         if not is_pdf:
             return None
@@ -1497,12 +1292,12 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
 
         value = result.get('result', {}).get('value', '')
         if not value:
-            print("⚠️ CDP fetch PDF: 无返回值")
+            logger.warning("CDP PDF fetch returned no value")
             return None
 
         data = json.loads(value)
         if 'error' in data:
-            print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
+            logger.warning("CDP PDF fetch failed: %s", data["error"])
             return None
 
         # 从 data URL 中提取 base64 并解码
@@ -1523,11 +1318,11 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
         with open(save_path, 'wb') as f:
             f.write(pdf_bytes)
 
-        print(f"📄 PDF 已通过 CDP 下载到: {save_path} ({len(pdf_bytes)} bytes)")
+        logger.info("CDP PDF saved: path=%s bytes=%d", save_path, len(pdf_bytes))
         return save_path
 
     except Exception as e:
-        print(f"⚠️ PDF 检测/下载异常: {e}")
+        logger.warning("CDP PDF detection/download failed: %s", e)
         return None
 
 
@@ -1885,12 +1680,12 @@ async def browser_wait_for_user_action(message: str = "Please complete the actio
         import asyncio
 
         print(f"\n{'='*60}")
-        print(f"⏸️  WAITING FOR USER ACTION")
+        print("⏸️  WAITING FOR USER ACTION")
         print(f"{'='*60}")
         print(f"📝 {message}")
         print(f"⏱️  Timeout: {timeout} seconds")
-        print(f"\n👉 Please complete the action in the browser window")
-        print(f"👉 Press ENTER when done, or wait for timeout")
+        print("\n👉 Please complete the action in the browser window")
+        print("👉 Press ENTER when done, or wait for timeout")
         print(f"{'='*60}\n")
 
         # Wait for user input or timeout
@@ -2150,7 +1945,7 @@ async def browser_load_cookies(url: str, name: str = "", auto_navigate: bool = T
 
 
 # ============================================================
-# 新版统一入口(13 个 @tool,替代原来 28 个)
+# 对外统一的浏览器工具入口
 # ============================================================
 
 
@@ -2461,7 +2256,7 @@ async def browser_download(url: str, save_name: str = "") -> ToolResult:
         url: 文件 URL
         save_name: 保存文件名(可选,默认自动推断)
     """
-    return await browser_download_direct_url(url=url, save_name=save_name or "download")
+    return await browser_download_direct_url(url=url, save_name=save_name)
 
 
 # ============================================================
@@ -2476,7 +2271,7 @@ __all__ = [
     'cleanup_browser_session',
     'kill_browser_session',
 
-    # 13 个 @tool 入口
+    # 14 个 @tool 入口
     'browser_navigate',
     'browser_search',
     'browser_back',

+ 110 - 0
agent/agent/tools/builtin/browser/downloads.py

@@ -0,0 +1,110 @@
+"""Reliable direct-download implementation for browser tools."""
+
+from __future__ import annotations
+
+import logging
+import os
+import time
+from pathlib import Path
+from typing import Optional
+from urllib.parse import unquote, urlparse
+
+import httpx
+
+from agent.tools import ToolResult
+
+
+logger = logging.getLogger(__name__)
+
+
+def _safe_download_name(url: str, requested_name: str) -> str:
+    candidate = requested_name.strip()
+    if not candidate:
+        candidate = unquote(Path(urlparse(url).path).name)
+    candidate = Path(candidate).name
+    if candidate in {"", ".", ".."}:
+        candidate = f"download_{int(time.time())}"
+    return candidate
+
+
+async def download_direct_url(
+    url: str,
+    save_name: str = "book.epub",
+    *,
+    download_dir: Optional[Path] = None,
+) -> ToolResult:
+    """Stream a URL to an atomic local file and always return ``ToolResult``."""
+
+    target_dir = download_dir or Path.cwd() / ".cache/.browser_use_files"
+    target_dir.mkdir(parents=True, exist_ok=True)
+    target_path = target_dir / _safe_download_name(url, save_name)
+    partial_path = target_path.with_name(f".{target_path.name}.part")
+    partial_path.unlink(missing_ok=True)
+    parsed_url = urlparse(url)
+    referer = f"{parsed_url.scheme}://{parsed_url.netloc}/"
+    headers = {
+        "User-Agent": (
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+            "AppleWebKit/537.36 (KHTML, like Gecko) "
+            "Chrome/120.0.0.0 Safari/537.36"
+        ),
+        "Accept": "*/*",
+        "Referer": referer,
+        "Range": "bytes=0-",
+    }
+
+    try:
+        downloaded_bytes = 0
+        status_code = 0
+        async with httpx.AsyncClient(
+            headers=headers,
+            follow_redirects=True,
+            timeout=60.0,
+        ) as client:
+            async with client.stream("GET", url) as response:
+                status_code = response.status_code
+                if status_code not in {200, 206}:
+                    error = f"HTTP {status_code}"
+                    return ToolResult(
+                        title="直链下载失败",
+                        output="",
+                        error=error,
+                        long_term_memory=f"下载失败: {error}",
+                        metadata={"url": url, "status_code": status_code},
+                    )
+                with partial_path.open("wb") as output:
+                    async for chunk in response.aiter_bytes():
+                        output.write(chunk)
+                        downloaded_bytes += len(chunk)
+        os.replace(partial_path, target_path)
+        message = f"下载完成,文件已保存至: {target_path}"
+        logger.info(
+            "Browser download completed: path=%s bytes=%d status=%d",
+            target_path,
+            downloaded_bytes,
+            status_code,
+        )
+        return ToolResult(
+            title="直链下载成功",
+            output=message,
+            long_term_memory=message,
+            metadata={
+                "path": str(target_path),
+                "bytes": downloaded_bytes,
+                "status_code": status_code,
+            },
+        )
+    except (httpx.HTTPError, OSError) as exc:
+        partial_path.unlink(missing_ok=True)
+        error = str(exc) or type(exc).__name__
+        logger.warning("Browser download failed: url=%s error=%s", url, error)
+        return ToolResult(
+            title="下载异常",
+            output="",
+            error=error,
+            long_term_memory=f"下载任务由于异常中断: {error}",
+            metadata={"url": url},
+        )
+
+
+__all__ = ["download_direct_url"]

+ 192 - 0
agent/agent/tools/builtin/browser/providers.py

@@ -0,0 +1,192 @@
+"""Host-configurable providers used by the browser adapter.
+
+The browser tools remain usable with their historical defaults, while Host
+applications can replace container provisioning and cookie lookup without the
+framework knowing their service endpoints or database schema.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from dataclasses import dataclass
+from typing import Any, Dict, Optional, Protocol, runtime_checkable
+
+import httpx
+
+
+logger = logging.getLogger(__name__)
+
+LEGACY_CONTAINER_BASE_URL = "http://47.84.182.56:8200"
+
+
+@runtime_checkable
+class BrowserContainerProvider(Protocol):
+    """Provision a browser container and navigate its first page."""
+
+    async def create(self, url: str, account_name: str) -> Dict[str, Any]: ...
+
+
+@runtime_checkable
+class BrowserCookieProvider(Protocol):
+    """Look up a cookie record by a Host-defined logical name."""
+
+    def get_cookie_row(self, cookie_type: str) -> Optional[Dict[str, Any]]: ...
+
+
+@dataclass
+class HttpContainerProvider:
+    """HTTP adapter for the legacy container service contract."""
+
+    base_url: str
+    startup_delay: float = 5.0
+    retry_delay: float = 3.0
+    max_retries: int = 3
+
+    @classmethod
+    def from_environment(cls) -> "HttpContainerProvider":
+        return cls(
+            base_url=os.getenv(
+                "AGENT_BROWSER_CONTAINER_BASE_URL",
+                LEGACY_CONTAINER_BASE_URL,
+            ).rstrip("/"),
+        )
+
+    async def create(self, url: str, account_name: str) -> Dict[str, Any]:
+        result: Dict[str, Any] = {
+            "success": False,
+            "container_id": None,
+            "vnc": None,
+            "cdp": None,
+            "connection_id": None,
+            "error": None,
+        }
+        try:
+            async with httpx.AsyncClient(timeout=60.0) as client:
+                response = await client.post(
+                    f"{self.base_url}/api/v1/container/create",
+                    json={
+                        "auto_remove": True,
+                        "need_port_binding": True,
+                        "max_lifetime_seconds": 900,
+                    },
+                )
+                response.raise_for_status()
+                payload = response.json()
+                if payload.get("code") != 0:
+                    raise RuntimeError(
+                        f"创建容器失败: {payload.get('msg') or 'unknown error'}"
+                    )
+                data = payload.get("data") or {}
+                result.update(
+                    container_id=data.get("container_id"),
+                    vnc=data.get("vnc"),
+                    cdp=data.get("cdp"),
+                )
+
+                if self.startup_delay > 0:
+                    await asyncio.sleep(self.startup_delay)
+
+                last_error = "unknown error"
+                for attempt in range(self.max_retries):
+                    if attempt and self.retry_delay > 0:
+                        await asyncio.sleep(self.retry_delay)
+                    try:
+                        page_response = await client.post(
+                            f"{self.base_url}/api/v1/browser/page/create",
+                            json={
+                                "container_id": result["container_id"],
+                                "url": url,
+                                "account_name": account_name,
+                                "need_wait": True,
+                                "timeout": 30,
+                            },
+                        )
+                        page_response.raise_for_status()
+                        page_payload = page_response.json()
+                        if page_payload.get("code") != 0:
+                            last_error = str(
+                                page_payload.get("msg") or "unknown service error"
+                            )
+                            continue
+                        page_data = page_payload.get("data") or {}
+                        result.update(
+                            success=True,
+                            connection_id=page_data.get("connection_id"),
+                        )
+                        return result
+                    except (httpx.HTTPError, ValueError) as exc:
+                        last_error = str(exc) or type(exc).__name__
+                raise RuntimeError(
+                    f"创建页面失败(尝试{self.max_retries}次后): {last_error}"
+                )
+        except Exception as exc:
+            result["error"] = str(exc) or type(exc).__name__
+            logger.exception(
+                "Browser container provisioning failed: %s",
+                result["error"],
+            )
+            return result
+
+
+class LegacyDatabaseCookieProvider:
+    """Compatibility adapter for the historical browser cookie table."""
+
+    def get_cookie_row(self, cookie_type: str) -> Optional[Dict[str, Any]]:
+        from agent.tools.builtin.browser.sync_mysql_help import mysql
+
+        return mysql.fetchone(
+            "select * from agent_channel_cookies where type=%s limit 1",
+            (cookie_type,),
+        )
+
+
+_container_provider: BrowserContainerProvider = HttpContainerProvider.from_environment()
+_cookie_provider: BrowserCookieProvider = LegacyDatabaseCookieProvider()
+
+
+def configure_browser_providers(
+    *,
+    container_provider: Optional[BrowserContainerProvider] = None,
+    cookie_provider: Optional[BrowserCookieProvider] = None,
+) -> None:
+    """Replace browser infrastructure adapters for the current process."""
+
+    global _container_provider, _cookie_provider
+    if container_provider is not None:
+        if not isinstance(container_provider, BrowserContainerProvider):
+            raise TypeError(
+                "container_provider must implement BrowserContainerProvider"
+            )
+        _container_provider = container_provider
+    if cookie_provider is not None:
+        if not isinstance(cookie_provider, BrowserCookieProvider):
+            raise TypeError("cookie_provider must implement BrowserCookieProvider")
+        _cookie_provider = cookie_provider
+
+
+def reset_browser_providers() -> None:
+    """Restore compatibility providers; intended for Host teardown and tests."""
+
+    global _container_provider, _cookie_provider
+    _container_provider = HttpContainerProvider.from_environment()
+    _cookie_provider = LegacyDatabaseCookieProvider()
+
+
+def get_browser_container_provider() -> BrowserContainerProvider:
+    return _container_provider
+
+
+def get_browser_cookie_provider() -> BrowserCookieProvider:
+    return _cookie_provider
+
+
+__all__ = [
+    "BrowserContainerProvider",
+    "BrowserCookieProvider",
+    "HttpContainerProvider",
+    "LegacyDatabaseCookieProvider",
+    "configure_browser_providers",
+    "reset_browser_providers",
+]

+ 7 - 3
agent/agent/tools/builtin/content/cache.py

@@ -7,11 +7,14 @@
 """
 
 import json
+import logging
 import os
 import time
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 
+logger = logging.getLogger(__name__)
+
 # CWD 锚定 —— 每个调用项目有独立缓存目录,避免 /tmp 跨会话污染
 _CACHE_DIR = Path(os.getcwd()) / ".cache" / "content_search"
 _CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -34,7 +37,8 @@ def _load_raw(trace_id: str) -> dict:
             p.unlink(missing_ok=True)
             return {}
         return data
-    except Exception:
+    except (OSError, json.JSONDecodeError, TypeError) as exc:
+        logger.debug("Failed to read content cache %s: %s", p, exc)
         return {}
 
 
@@ -44,8 +48,8 @@ def _save_raw(trace_id: str, data: dict) -> None:
         _cache_path(trace_id).write_text(
             json.dumps(data, ensure_ascii=False), encoding="utf-8"
         )
-    except Exception:
-        pass
+    except OSError as exc:
+        logger.warning("Failed to write content cache for trace %s: %s", trace_id, exc)
 
 
 def save_search_results(

+ 69 - 0
agent/agent/tools/builtin/content/collage.py

@@ -0,0 +1,69 @@
+"""Shared image-collage rendering for content platform adapters."""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import logging
+from typing import Any, Dict, List, Optional, Sequence
+
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+
+
+async def _upload_bytes(image_bytes: bytes, filename: str) -> str:
+    from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
+
+    return await _upload_bytes_to_oss(image_bytes, filename)
+
+
+async def render_image_collage(
+    urls: Sequence[str],
+    *,
+    labels: Optional[Sequence[str]] = None,
+    filename_prefix: str = "collage",
+    logger: Optional[logging.Logger] = None,
+) -> Optional[Dict[str, Any]]:
+    """Load, filter, render and publish a platform-selected image set."""
+
+    if not urls:
+        return None
+    if labels is not None and len(labels) != len(urls):
+        raise ValueError("labels must match urls length")
+
+    loaded = await load_images(list(urls))
+    valid_images: List[Any] = []
+    valid_labels: List[str] = []
+    for index, (_, image) in enumerate(loaded):
+        if image is None:
+            continue
+        valid_images.append(image)
+        if labels is not None:
+            valid_labels.append(labels[index])
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(
+        images=valid_images,
+        labels=valid_labels if labels is not None else None,
+    )
+    buffer = io.BytesIO()
+    grid.save(buffer, format="PNG")
+    image_bytes = buffer.getvalue()
+
+    try:
+        digest = hashlib.md5(image_bytes).hexdigest()[:12]
+        url = await _upload_bytes(
+            image_bytes,
+            f"{filename_prefix}_{digest}.png",
+        )
+        return {"type": "url", "url": url}
+    except Exception as exc:
+        (logger or logging.getLogger(__name__)).warning(
+            "Failed to upload collage to CDN: %s", exc
+        )
+        encoded, _ = encode_base64(grid, format="PNG")
+        return {"type": "base64", "media_type": "image/png", "data": encoded}
+
+
+__all__ = ["render_image_collage"]

+ 5 - 1
agent/agent/tools/builtin/content/media.py

@@ -7,6 +7,7 @@
 
 import asyncio
 import json
+import logging
 import subprocess
 import tempfile
 from pathlib import Path
@@ -14,6 +15,8 @@ from typing import Dict, List, Optional
 
 from agent.tools import tool, ToolResult
 
+logger = logging.getLogger(__name__)
+
 VIDEO_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "youtube_videos"
 VIDEO_DOWNLOAD_DIR.mkdir(exist_ok=True)
 
@@ -37,7 +40,8 @@ def download_youtube_video(video_id: str) -> Optional[str]:
         if result.returncode == 0 and output_path.exists():
             return str(output_path)
         return None
-    except Exception:
+    except (OSError, subprocess.SubprocessError) as exc:
+        logger.warning("YouTube video download failed for %s: %s", video_id, exc)
         return None
 
 

+ 26 - 75
agent/agent/tools/builtin/content/platforms/aigc_channel.py

@@ -6,6 +6,7 @@ AIGC-Channel 平台实现(9 个中文平台)
 """
 
 import json
+import logging
 import re
 from typing import Any, Dict, List, Optional
 
@@ -13,11 +14,13 @@ import httpx
 
 from agent.tools.models import ToolResult
 from agent.tools.builtin.content.quality import get_post_evaluator
-from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.collage import render_image_collage
 from agent.tools.builtin.content.registry import (
     PlatformDef, ParamSpec, register_platform,
 )
 
+logger = logging.getLogger(__name__)
+
 BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
 DEFAULT_TIMEOUT = 60.0
 
@@ -188,8 +191,8 @@ async def search(
                 }
                 post["_quality_score"] = eval_res["total_score"]
                 post["_quality_grade"] = eval_res["grade"]
-            except Exception:
-                pass
+            except Exception as exc:
+                logger.debug("AIGC post quality evaluation failed: %s", exc)
                 
         summary_item = {
             "index": idx,
@@ -230,34 +233,13 @@ KEEP_INDIVIDUAL = 8     # 单张图片保留数量;剩余图片合并为 1 张
 
 
 async def _build_images_collage(urls: List[str]) -> Optional[Dict[str, Any]]:
-    """将一组图片 URL 拼成单张网格图"""
-    if not urls:
-        return None
-
-    loaded = await load_images(urls)
-    valid_images = [img for (_, img) in loaded if img is not None]
-    if not valid_images:
-        return None
-
-    grid = build_image_grid(images=valid_images, labels=None)
-    import io
-    buf = io.BytesIO()
-    grid.save(buf, format="PNG")
-    img_bytes = buf.getvalue()
-
-    try:
-        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
-        import hashlib
+    """Render detail overflow images as one collage."""
 
-        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
-        filename = f"collage_detail_{md5_hash}.png"
-        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
-        return {"type": "url", "url": cdn_url}
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning("Failed to upload detail collage to CDN: %s", e)
-        b64, _ = encode_base64(grid, format="PNG")
-        return {"type": "base64", "media_type": "image/png", "data": b64}
+    return await render_image_collage(
+        urls,
+        filename_prefix="collage_detail",
+        logger=logger,
+    )
 
 
 async def detail(
@@ -375,54 +357,23 @@ async def suggest(channel: str, keyword: str) -> ToolResult:
 # ── 拼图辅助 ──
 
 async def _build_collage(posts: List[Dict[str, Any]]) -> Optional[str]:
-    """封面图网格拼图"""
+    """Select post covers and render the search-result collage."""
+
     urls, titles = [], []
     for post in posts:
-        imgs = post.get("images", [])
-        if imgs and imgs[0]:
-            urls.append(imgs[0])
-            base_title = _strip_html(post.get("title", ""))
+        images = post.get("images", [])
+        if images and images[0]:
+            urls.append(images[0])
+            title = _strip_html(post.get("title", ""))
             score = post.get("_quality_score")
-            if score is not None:
-                title_with_score = f"[{score}分] {base_title}"
-            else:
-                title_with_score = base_title
-            titles.append(title_with_score)
-
-    if not urls:
-        return None
-
-    loaded = await load_images(urls)
-    valid_images, valid_labels = [], []
-    for (_, img), title in zip(loaded, titles):
-        if img is not None:
-            valid_images.append(img)
-            valid_labels.append(title)
-
-    if not valid_images:
-        return None
-
-    grid = build_image_grid(images=valid_images, labels=valid_labels)
-    import io
-    buf = io.BytesIO()
-    grid.save(buf, format="PNG")
-    img_bytes = buf.getvalue()
-    
-    # 尝试上传到 CDN,替换冗长的 base64
-    try:
-        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
-        import hashlib
-        
-        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
-        filename = f"collage_search_{md5_hash}.png"
-        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
-        return {"type": "url", "url": cdn_url}
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning("Failed to upload collage to CDN: %s", e)
-        # 降级:还是用 base64 但可能会超长
-        b64, _ = encode_base64(grid, format="PNG")
-        return {"type": "base64", "media_type": "image/png", "data": b64}
+            titles.append(f"[{score}分] {title}" if score is not None else title)
+
+    return await render_image_collage(
+        urls,
+        labels=titles,
+        filename_prefix="collage_search",
+        logger=logger,
+    )
 
 
 # ── 注册所有 AIGC 平台 ──

+ 31 - 79
agent/agent/tools/builtin/content/platforms/x.py

@@ -5,15 +5,18 @@ X (Twitter) 平台实现
 """
 
 import json
+import logging
 from typing import Any, Dict, List, Optional
 
 import httpx
 
 from agent.tools.models import ToolResult
-from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.collage import render_image_collage
 from agent.tools.builtin.content.registry import PlatformDef, register_platform
 from agent.tools.builtin.content.quality import get_post_evaluator
 
+logger = logging.getLogger(__name__)
+
 CRAWLER_URL = "http://crawler.aiddit.com/crawler/x/keyword"
 COMMENT_URL = "http://crawler.aiddit.com/crawler/x/comment"
 DEFAULT_TIMEOUT = 60.0
@@ -64,8 +67,8 @@ async def search(
                         "quality_grade": eval_res["grade"]
                     }
                     tweet["_quality_score"] = eval_res["total_score"]
-                except Exception:
-                    pass
+                except Exception as exc:
+                    logger.debug("X post quality evaluation failed: %s", exc)
 
             summary_item = {
                 "index": idx,
@@ -101,34 +104,13 @@ KEEP_INDIVIDUAL = 8
 
 
 async def _build_images_collage(urls: List[str]) -> Optional[Dict[str, Any]]:
-    """将一组图片 URL 拼成单张网格图"""
-    if not urls:
-        return None
-
-    loaded = await load_images(urls)
-    valid_images = [img for (_, img) in loaded if img is not None]
-    if not valid_images:
-        return None
+    """Render detail overflow images as one collage."""
 
-    grid = build_image_grid(images=valid_images, labels=None)
-    import io
-    buf = io.BytesIO()
-    grid.save(buf, format="PNG")
-    img_bytes = buf.getvalue()
-
-    try:
-        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
-        import hashlib
-
-        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
-        filename = f"x_detail_collage_{md5_hash}.png"
-        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
-        return {"type": "url", "url": cdn_url}
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning("Failed to upload x detail collage to CDN: %s", e)
-        b64, _ = encode_base64(grid, format="PNG")
-        return {"type": "base64", "media_type": "image/png", "data": b64}
+    return await render_image_collage(
+        urls,
+        filename_prefix="x_detail_collage",
+        logger=logger,
+    )
 
 
 async def _fetch_author_comments(content_id: str, author_id: str) -> List[Dict[str, Any]]:
@@ -247,56 +229,26 @@ async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None)
 async def _build_tweet_collage(tweets: List[Dict[str, Any]]) -> Optional[str]:
     urls, titles = [], []
     for tweet in tweets:
-        thumb = None
-        for img_item in tweet.get("image_url_list", []):
-            url = img_item.get("image_url") if isinstance(img_item, dict) else img_item
-            if url:
-                thumb = url
-                break
-        if not thumb:
-            thumb = tweet.get("cover_url")
-        if thumb:
-            urls.append(thumb)
-            base_title = f"@{tweet.get('channel_account_name', '')}"
+        thumbnail = next(
+            (
+                item.get("image_url") if isinstance(item, dict) else item
+                for item in tweet.get("image_url_list", [])
+                if (item.get("image_url") if isinstance(item, dict) else item)
+            ),
+            None,
+        ) or tweet.get("cover_url")
+        if thumbnail:
+            urls.append(thumbnail)
+            title = f"@{tweet.get('channel_account_name', '')}"
             score = tweet.get("_quality_score")
-            if score is not None:
-                title_with_score = f"[{score}分] {base_title}"
-            else:
-                title_with_score = base_title
-            titles.append(title_with_score)
-
-    if not urls:
-        return None
-
-    loaded = await load_images(urls)
-    valid_images, valid_labels = [], []
-    for (_, img), title in zip(loaded, titles):
-        if img is not None:
-            valid_images.append(img)
-            valid_labels.append(title)
-
-    if not valid_images:
-        return None
-
-    grid = build_image_grid(images=valid_images, labels=valid_labels)
-    import io
-    buf = io.BytesIO()
-    grid.save(buf, format="PNG")
-    img_bytes = buf.getvalue()
-    
-    try:
-        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
-        import hashlib
-        
-        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
-        filename = f"x_collage_{md5_hash}.png"
-        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
-        return {"type": "url", "url": cdn_url}
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning("Failed to upload x collage to CDN: %s", e)
-        b64, _ = encode_base64(grid, format="PNG")
-        return {"type": "base64", "media_type": "image/png", "data": b64}
+            titles.append(f"[{score}分] {title}" if score is not None else title)
+
+    return await render_image_collage(
+        urls,
+        labels=titles,
+        filename_prefix="x_collage",
+        logger=logger,
+    )
 
 
 # ── 注册 ──

+ 23 - 54
agent/agent/tools/builtin/content/platforms/youtube.py

@@ -5,6 +5,7 @@ YouTube 平台实现
 """
 
 import json
+import logging
 import re
 import time
 from typing import Any, Dict, List, Optional
@@ -13,11 +14,13 @@ import httpx
 
 from agent.tools.models import ToolResult
 from agent.tools.builtin.content.quality import get_post_evaluator
-from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.collage import render_image_collage
 from agent.tools.builtin.content.registry import (
     PlatformDef, ParamSpec, register_platform,
 )
 
+logger = logging.getLogger(__name__)
+
 CRAWLER_BASE_URL = "http://crawler.aiddit.com/crawler"
 DEFAULT_TIMEOUT = 60.0
 
@@ -198,8 +201,8 @@ async def search(
                     }
                     video["_quality_score"] = eval_res["total_score"]
                     video["_quality_grade"] = eval_res["grade"]
-                except Exception:
-                    pass
+                except Exception as exc:
+                    logger.debug("YouTube quality evaluation failed: %s", exc)
             
             summary_item = {
                 "index": idx,
@@ -282,8 +285,8 @@ async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None)
                         inner2 = inner.get("data", {})
                         if isinstance(inner2, dict):
                             captions_text = inner2.get("content")
-        except Exception:
-            pass
+        except Exception as exc:
+            logger.debug("YouTube caption retrieval failed: %s", exc)
 
     # ── 3) 视频文件下载(用户显式 extras.download_video=True 时才跑) ──
     video_path = None
@@ -375,7 +378,7 @@ async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None)
     if transcript_text and transcript_text != captions_text:
         memory_parts.append("transcript")
     if detail_error:
-        memory_parts.append(f"degraded(detail backend down)")
+        memory_parts.append("degraded(detail backend down)")
     memory_extra = f" with {'+'.join(memory_parts)}" if memory_parts else ""
 
     title = video_info.get("title") or post.get("title") or content_id
@@ -391,56 +394,22 @@ async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None)
 async def _build_video_collage(videos: List[Dict[str, Any]]) -> Optional[str]:
     urls, titles = [], []
     for video in videos:
-        thumb = None
-        if "thumbnails" in video and isinstance(video["thumbnails"], list) and video["thumbnails"]:
-            thumb = video["thumbnails"][0].get("url")
-        elif "thumbnail" in video:
-            thumb = video.get("thumbnail")
-        elif "cover_url" in video:
-            thumb = video.get("cover_url")
-
-        if thumb:
-            urls.append(thumb)
-            base_title = video.get("title", "")
+        thumbnail = None
+        if isinstance(video.get("thumbnails"), list) and video["thumbnails"]:
+            thumbnail = video["thumbnails"][0].get("url")
+        thumbnail = thumbnail or video.get("thumbnail") or video.get("cover_url")
+        if thumbnail:
+            urls.append(thumbnail)
+            title = video.get("title", "")
             score = video.get("_quality_score")
-            if score is not None:
-                title_with_score = f"[{score}分] {base_title}"
-            else:
-                title_with_score = base_title
-            titles.append(title_with_score)
-
-    if not urls:
-        return None
+            titles.append(f"[{score}分] {title}" if score is not None else title)
 
-    loaded = await load_images(urls)
-    valid_images, valid_labels = [], []
-    for (_, img), title in zip(loaded, titles):
-        if img is not None:
-            valid_images.append(img)
-            valid_labels.append(title)
-
-    if not valid_images:
-        return None
-
-    grid = build_image_grid(images=valid_images, labels=valid_labels)
-    import io
-    buf = io.BytesIO()
-    grid.save(buf, format="PNG")
-    img_bytes = buf.getvalue()
-    
-    try:
-        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
-        import hashlib
-        
-        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
-        filename = f"youtube_collage_{md5_hash}.png"
-        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
-        return {"type": "url", "url": cdn_url}
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning("Failed to upload youtube collage to CDN: %s", e)
-        b64, _ = encode_base64(grid, format="PNG")
-        return {"type": "base64", "media_type": "image/png", "data": b64}
+    return await render_image_collage(
+        urls,
+        labels=titles,
+        filename_prefix="youtube_collage",
+        logger=logger,
+    )
 
 
 # ── 注册 ──

+ 6 - 3
agent/agent/tools/builtin/content/tools.py

@@ -11,6 +11,7 @@
 """
 
 import json
+import logging
 import os
 import uuid
 from typing import Any, Dict, Optional
@@ -26,6 +27,8 @@ import agent.tools.builtin.content.platforms.aigc_channel  # noqa: F401
 import agent.tools.builtin.content.platforms.youtube       # noqa: F401
 import agent.tools.builtin.content.platforms.x             # noqa: F401
 
+logger = logging.getLogger(__name__)
+
 
 def _get_trace_id(context: Optional[Dict[str, Any]]) -> str:
     """从 context 取 trace_id,回退到环境变量或自动生成"""
@@ -50,8 +53,8 @@ def _convert_post_timestamps(post: dict) -> None:
             if ts > 1000000000000:
                 ts = ts / 1000
             post[field] = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
-        except Exception:
-            pass
+        except (TypeError, ValueError, OSError, OverflowError) as exc:
+            logger.debug("Ignored invalid post timestamp %r: %s", ts, exc)
 
 
 # ── content_platforms ──
@@ -91,7 +94,7 @@ async def content_platforms(
         result = [p.summary() for p in hits]
 
     return ToolResult(
-        title=f"内容平台" + (f" ({platform})" if platform else ""),
+        title="内容平台" + (f" ({platform})" if platform else ""),
         output=json.dumps(result, ensure_ascii=False, indent=2),
     )
 

+ 50 - 26
agent/agent/tools/builtin/feishu/chat.py

@@ -3,21 +3,33 @@ import os
 import base64
 import httpx
 import asyncio
+import logging
 from typing import Optional, List, Dict, Any
 from .feishu_client import FeishuClient
 from agent.tools import tool, ToolResult, ToolContext
 from agent.trace.models import MessageContent
 
+
+logger = logging.getLogger(__name__)
+
 # 凭据只能由运行环境注入;缺失时 FeishuClient 会 fail-closed。
 FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "")
 FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "")
 
-CONTACTS_FILE = os.path.join(
-    os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")),
-    "config",
-    "feishu_contacts.json",
+CONTACTS_FILE = os.getenv(
+    "FEISHU_CONTACTS_FILE",
+    os.path.join(
+        os.path.abspath(
+            os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
+        ),
+        "config",
+        "feishu_contacts.json",
+    ),
+)
+CHAT_HISTORY_DIR = os.getenv(
+    "FEISHU_CHAT_HISTORY_DIR",
+    os.path.join(os.path.dirname(__file__), "chat_history"),
 )
-CHAT_HISTORY_DIR = os.path.join(os.path.dirname(__file__), "chat_history")
 UNREAD_SUMMARY_FILE = os.path.join(CHAT_HISTORY_DIR, "chat_summary.json")
 
 # ==================== 一、文件内使用的功能函数 ====================
@@ -30,7 +42,8 @@ def load_contacts() -> List[Dict[str, Any]]:
     try:
         with open(CONTACTS_FILE, "r", encoding="utf-8") as f:
             return json.load(f)
-    except Exception:
+    except (OSError, json.JSONDecodeError, TypeError) as exc:
+        logger.warning("Failed to load Feishu contacts from %s: %s", CONTACTS_FILE, exc)
         return []
 
 
@@ -39,8 +52,8 @@ def save_contacts(contacts: List[Dict[str, Any]]):
     try:
         with open(CONTACTS_FILE, "w", encoding="utf-8") as f:
             json.dump(contacts, f, ensure_ascii=False, indent=2)
-    except Exception as e:
-        print(f"保存联系人失败: {e}")
+    except OSError as exc:
+        logger.warning("Failed to save Feishu contacts to %s: %s", CONTACTS_FILE, exc)
 
 
 def list_contacts_info() -> List[Dict[str, str]]:
@@ -102,7 +115,8 @@ def _ensure_chat_history_dir():
 
 def get_chat_file_path(contact_name: str) -> str:
     _ensure_chat_history_dir()
-    return os.path.join(CHAT_HISTORY_DIR, f"chat_{contact_name}.json")
+    safe_name = contact_name.replace("/", "_").replace("\\", "_")
+    return os.path.join(CHAT_HISTORY_DIR, f"chat_{safe_name}.json")
 
 
 def load_chat_history(contact_name: str) -> List[Dict[str, Any]]:
@@ -111,7 +125,8 @@ def load_chat_history(contact_name: str) -> List[Dict[str, Any]]:
         try:
             with open(path, "r", encoding="utf-8") as f:
                 return json.load(f)
-        except Exception:
+        except (OSError, json.JSONDecodeError, TypeError) as exc:
+            logger.warning("Failed to load Feishu chat history %s: %s", path, exc)
             return []
     return []
 
@@ -121,8 +136,8 @@ def save_chat_history(contact_name: str, history: List[Dict[str, Any]]):
     try:
         with open(path, "w", encoding="utf-8") as f:
             json.dump(history, f, ensure_ascii=False, indent=2)
-    except Exception as e:
-        print(f"保存聊天记录失败: {e}")
+    except OSError as exc:
+        logger.warning("Failed to save Feishu chat history %s: %s", path, exc)
 
 
 def update_unread_count(contact_name: str, increment: int = 1, reset: bool = False):
@@ -133,7 +148,12 @@ def update_unread_count(contact_name: str, increment: int = 1, reset: bool = Fal
         try:
             with open(UNREAD_SUMMARY_FILE, "r", encoding="utf-8") as f:
                 summary = json.load(f)
-        except Exception:
+        except (OSError, json.JSONDecodeError, TypeError) as exc:
+            logger.warning(
+                "Failed to load Feishu unread summary %s: %s",
+                UNREAD_SUMMARY_FILE,
+                exc,
+            )
             summary = {}
 
     if reset:
@@ -144,8 +164,12 @@ def update_unread_count(contact_name: str, increment: int = 1, reset: bool = Fal
     try:
         with open(UNREAD_SUMMARY_FILE, "w", encoding="utf-8") as f:
             json.dump(summary, f, ensure_ascii=False, indent=2)
-    except Exception as e:
-        print(f"更新未读摘要失败: {e}")
+    except OSError as exc:
+        logger.warning(
+            "Failed to update Feishu unread summary %s: %s",
+            UNREAD_SUMMARY_FILE,
+            exc,
+        )
 
 
 # ==================== 三、@tool 工具 ====================
@@ -259,8 +283,8 @@ async def feishu_send_message_to_contact(
                             last_res = client.send_image(
                                 to=receive_id, image=image_bytes
                             )
-                        except Exception as e:
-                            print(f"解析 base64 图片失败: {e}")
+                        except Exception as exc:
+                            logger.warning("Failed to decode Feishu base64 image: %s", exc)
                     elif url.startswith("http://") or url.startswith("https://"):
                         # 处理网络 URL
                         try:
@@ -270,8 +294,8 @@ async def feishu_send_message_to_contact(
                                 last_res = client.send_image(
                                     to=receive_id, image=img_resp.content
                                 )
-                        except Exception as e:
-                            print(f"下载图片失败: {e}")
+                        except Exception as exc:
+                            logger.warning("Failed to download Feishu image: %s", exc)
                     else:
                         # 处理本地文件路径
                         try:
@@ -281,9 +305,9 @@ async def feishu_send_message_to_contact(
                                     to=receive_id, image=local_path
                                 )
                             else:
-                                print(f"本地图片文件不存在: {local_path}")
-                        except Exception as e:
-                            print(f"读取本地图片失败: {e}")
+                                logger.warning("Local Feishu image does not exist: %s", local_path)
+                        except Exception as exc:
+                            logger.warning("Failed to read local Feishu image: %s", exc)
         elif isinstance(content, dict):
             # 如果是单块格式也支持一下
             item_type = content.get("type")
@@ -324,8 +348,8 @@ async def feishu_send_message_to_contact(
                 save_chat_history(contact_name, history)
                 # 机器人回复了,将该联系人的未读计数重置为 0
                 update_unread_count(contact_name, reset=True)
-            except Exception as e:
-                print(f"记录发送的消息失败: {e}")
+            except Exception as exc:
+                logger.warning("Failed to record sent Feishu message: %s", exc)
 
             return ToolResult(
                 title=f"消息已成功发送至 {contact_name}",
@@ -461,8 +485,8 @@ def _convert_feishu_msg_to_openai_content(
                         "image_url": {"url": f"data:image/png;base64,{b64_str}"},
                     }
                 )
-        except Exception as e:
-            print(f"转换图片消息失败: {e}")
+        except Exception as exc:
+            logger.warning("Failed to convert Feishu image message: %s", exc)
             blocks.append({"type": "text", "text": "[图片内容获取失败]"})
     elif msg_type == "post":
         blocks.append({"type": "text", "text": raw_content})

+ 0 - 79
agent/agent/tools/builtin/feishu/chat_test.py

@@ -1,79 +0,0 @@
-import asyncio
-import os
-
-from agent.tools.builtin.feishu.chat import feishu_send_message_to_contact
-
-
-async def feishu_tools():
-    print("开始测试飞书工具...\n")
-
-    # # 1. 测试获取联系人列表
-    # print("--- 测试: feishu_get_contact_list ---")
-    # result_list = await feishu_get_contact_list()
-    # print(f"标题: {result_list.title}")
-    # print(f"输出: {result_list.output}")
-    # print("-" * 30 + "\n")
-    #
-    # # 2. 测试发送消息 (以 '谭景玉' 为例,请确保 contacts.json 中有此人且信息正确)
-    contact_name = "谭景玉"
-    # print(f"--- 测试: feishu_send_message_to_contact (对象: {contact_name}) ---")
-    #
-    # 测试发送纯文本
-    text_content = "干活"
-    print(f"正在发送文本: {text_content}")
-    result_send_text = await feishu_send_message_to_contact(contact_name, text_content)
-    print(f"标题: {result_send_text.title}")
-    print(f"输出: {result_send_text.output}")
-    if result_send_text.error:
-        print(f"错误: {result_send_text.error}")
-
-    # 测试发送多模态消息 (文本 + 图片)
-    # 注意:这里的图片 URL 需要是一个可访问的地址,或者你可以使用 base64 格式
-    # multimodal_content = [
-    #     {"type": "text", "text": "这是一条多模态测试消息:"},
-    #     {"type": "image_url", "image_url": {"url": "https://www.baidu.com/img/flexible/logo/pc/result.png"}}
-    # ]
-    # print(f"\n正在发送多模态消息...")
-    # result_send_multi = await feishu_send_message_to_contact(contact_name, multimodal_content)
-    # print(f"标题: {result_send_multi.title}")
-    # # print(f"输出: {result_send_multi.output}")
-    # if result_send_multi.error:
-    #     print(f"错误: {result_send_multi.error}")
-    # print("-" * 30 + "\n")
-
-    # # 3. 测试获取回复
-    # print(f"--- 测试: feishu_get_contact_replies (对象: {contact_name}) ---")
-    # result_replies = await feishu_get_contact_replies(contact_name)
-    # print(f"标题: {result_replies.title}")
-    # print(f"消息详情: {result_replies.output}")
-    # print("-" * 30 + "\n")
-
-    # # 4. 测试获取历史记录
-    # print(f"--- 测试: feishu_get_chat_history (对象: {contact_name}) ---")
-    # result_history = await feishu_get_chat_history(
-    #     contact_name,
-    #     page_size=5,
-    #     page_token=os.environ.get("FEISHU_TEST_PAGE_TOKEN"),
-    # )
-    # print(f"标题: {result_history.title}")
-    # print(f"历史记录输出: {result_history.output}")
-    # print("-" * 30 + "\n")
-
-
-if __name__ == "__main__":
-    missing = [
-        name
-        for name in ("FEISHU_APP_ID", "FEISHU_APP_SECRET")
-        if not os.environ.get(name)
-    ]
-    if missing:
-        raise RuntimeError(
-            f"missing required environment variables: {', '.join(missing)}"
-        )
-
-    try:
-        asyncio.run(feishu_tools())
-    except KeyboardInterrupt:
-        pass
-    except Exception as e:
-        print(f"测试过程中出现异常: {e}")

+ 24 - 14
agent/agent/tools/builtin/feishu/feishu_agent.py

@@ -27,11 +27,12 @@ import zipfile
 from typing import Dict, List, Any, Optional
 from collections import defaultdict
 
-PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
-if PROJECT_ROOT not in sys.path:
-    sys.path.insert(0, PROJECT_ROOT)
-
-from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain, ReceiveIdType
+from agent.tools.builtin.feishu.feishu_client import (
+    FeishuClient,
+    FeishuMessageEvent,
+    FeishuDomain,
+    ReceiveIdType,
+)
 from agent.tools.builtin.feishu.chat import (
     FEISHU_APP_ID,
     FEISHU_APP_SECRET,
@@ -41,15 +42,22 @@ from agent.tools.builtin.feishu.chat import (
 )
 from agent.llm.qwen import qwen_llm_call
 
-logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s %(message)s')
 logger = logging.getLogger("FeishuAgent")
 
 # ===== 配置 =====
 
+PROJECT_ROOT = os.getenv(
+    "FEISHU_AGENT_WORKSPACE",
+    os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")),
+)
 MODEL = os.getenv("FEISHU_AGENT_MODEL", "qwen3.5-397b-a17b")
 MAX_HISTORY = 50
 MAX_TOOL_ROUNDS = 10  # 工具调用最大循环次数
-ALLOWED_CONTACTS = {"关涛"}
+ALLOWED_CONTACTS = {
+    item.strip()
+    for item in os.getenv("FEISHU_AGENT_ALLOWED_CONTACTS", "关涛").split(",")
+    if item.strip()
+}
 
 DEFAULT_SYSTEM_PROMPT = """你是一个友好、有帮助的 AI 助手,正在通过飞书和用户对话。
 你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
@@ -337,7 +345,7 @@ def _tool_run_bash(command: str) -> str:
 def _tool_send_image(path: str) -> str:
     """发送图片到飞书"""
     from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
-    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain, ReceiveIdType
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
 
     if not _current_chat_id:
         return "错误:无法获取当前会话 ID"
@@ -422,7 +430,7 @@ def _output_reader(proc: subprocess.Popen, pid: int):
             if len(_background_processes[pid]["output_lines"]) > 500:
                 _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
     except Exception:
-        pass
+        logger.debug("Feishu background stdout reader stopped", exc_info=True)
 
     # stderr 也读
     try:
@@ -433,7 +441,7 @@ def _output_reader(proc: subprocess.Popen, pid: int):
             if len(_background_processes[pid]["output_lines"]) > 500:
                 _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
     except Exception:
-        pass
+        logger.debug("Feishu background stderr reader stopped", exc_info=True)
 
 
 def _tool_run_background(command: str, name: str = "") -> str:
@@ -446,7 +454,6 @@ def _tool_run_background(command: str, name: str = "") -> str:
     env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
     env["PYTHONIOENCODING"] = "utf-8"
     env["PYTHONUNBUFFERED"] = "1"  # 强制 Python 无缓冲输出
-    env["PYTHONIOENCODING"] = "utf-8"  # 强制 Python IO 编码为 utf-8
 
     try:
         proc = subprocess.Popen(
@@ -494,7 +501,7 @@ def _tool_stop_process(pid: int) -> str:
     try:
         proc.wait(timeout=5)
     except Exception:
-        pass
+        logger.debug("Feishu background process did not exit cleanly", exc_info=True)
 
     _background_processes.pop(pid, None)
     return f"已停止进程: PID={pid} ({name})"
@@ -539,7 +546,7 @@ def _tool_send_input(pid: int, text: str) -> str:
 
     proc = info["proc"]
     if proc.poll() is not None:
-        return f"进程已退出,无法发送输入"
+        return "进程已退出,无法发送输入"
 
     try:
         # 使用控制文件方式(更可靠)
@@ -549,7 +556,6 @@ def _tool_send_input(pid: int, text: str) -> str:
         return f"已向 PID={pid} 发送控制指令: {text.strip()}"
     except Exception as e:
         return f"发送输入失败: {e}"
-        return f"发送输入失败: {e}"
 
 
 class FeishuAgent:
@@ -888,5 +894,9 @@ class FeishuAgent:
 
 
 if __name__ == "__main__":
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
+    )
     agent = FeishuAgent()
     agent.start()

+ 1 - 3
agent/agent/tools/builtin/feishu/feishu_client.py

@@ -43,7 +43,6 @@ from lark_oapi.api.im.v1 import (
     ListMessageResponse,
 )
 
-logging.basicConfig(level=logging.INFO)
 logger = logging.getLogger(__name__)
 
 
@@ -929,6 +928,7 @@ class FeishuClient:
 # ==================== 使用示例 ====================
 
 if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
     # 从环境变量获取配置
     APP_ID = os.getenv("FEISHU_APP_ID")
     APP_SECRET = os.getenv("FEISHU_APP_SECRET")
@@ -987,7 +987,5 @@ if __name__ == "__main__":
             blocking=True,
         )
 
-        # res = client.get_message_list(chat_id='oc_56e85f0e2c97405d176729b62d8f56e5', start_time=0, end_time=1770623620)
-        # print(f"获取消息列表结果: {json.dumps(res, indent=4, ensure_ascii=False)}")
     except KeyboardInterrupt:
         print("\n退出")

+ 0 - 92
agent/agent/tools/builtin/feishu/websocket_event.py

@@ -1,92 +0,0 @@
-import os
-import json
-import logging
-import asyncio
-import sys
-from typing import Optional
-
-# 将项目根目录添加到 python 路径,确保可以作为独立脚本运行
-PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
-if PROJECT_ROOT not in sys.path:
-    sys.path.append(PROJECT_ROOT)
-
-from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain
-from agent.tools.builtin.feishu.chat import (
-    FEISHU_APP_ID, 
-    FEISHU_APP_SECRET, 
-    get_contact_by_id, 
-    load_chat_history, 
-    save_chat_history, 
-    update_unread_count,
-    _convert_feishu_msg_to_openai_content
-)
-
-# 配置日志
-logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-logger = logging.getLogger("FeishuWebsocket")
-
-class FeishuMessageListener:
-    def __init__(self):
-        self.client = FeishuClient(
-            app_id=FEISHU_APP_ID,
-            app_secret=FEISHU_APP_SECRET,
-            domain=FeishuDomain.FEISHU
-        )
-
-    def handle_incoming_message(self, event: FeishuMessageEvent):
-        """处理收到的飞书消息事件"""
-        # 1. 识别联系人
-        # 优先使用 sender_open_id 匹配联系人,如果没有则尝试 chat_id
-        contact = get_contact_by_id(event.sender_open_id) or get_contact_by_id(event.chat_id)
-        
-        if not contact:
-            logger.warning(f"收到未知发送者的消息: open_id={event.sender_open_id}, chat_id={event.chat_id}")
-            # 对于未知联系人,我们可以选择忽略,或者记录到 'unknown' 分类
-            contact = {"name": "未知联系人", "open_id": event.sender_open_id}
-
-        contact_name = contact.get("name")
-        logger.info(f"收到来自 [{contact_name}] 的消息: {event.content[:50]}...")
-
-        # 2. 转换为 OpenAI 多模态格式
-        # 构造一个类似 get_message_list 返回的字典对象,以便重用转换逻辑
-        msg_dict = {
-            "message_id": event.message_id,
-            "content_type": event.content_type,
-            "content": event.content, # 对于 text, websocket 传来的已经是解析后的字符串;对于 image 则是原始 JSON 字符串
-            "sender_id": event.sender_open_id,
-            "sender_type": "user" # WebSocket 收到的一般是用户消息,除非是机器人自己的回显(通常会过滤)
-        }
-        
-        openai_content = _convert_feishu_msg_to_openai_content(self.client, msg_dict)
-
-        # 3. 维护聊天记录
-        history = load_chat_history(contact_name)
-        new_message = {
-            "role": "user",
-            "message_id": event.message_id,
-            "timestamp": os.path.getmtime(os.path.join(os.path.dirname(__file__), "chat.py")), # 简单模拟一个时间戳,实际应使用事件时间
-            "content": openai_content
-        }
-        history.append(new_message)
-        save_chat_history(contact_name, history)
-
-        # 4. 更新未读计数
-        update_unread_count(contact_name, increment=1)
-        logger.info(f"已更新 [{contact_name}] 的聊天记录并增加未读计数")
-
-    def start(self):
-        """启动监听"""
-        logger.info("正在启动飞书消息实时监听...")
-        try:
-            self.client.start_websocket(
-                on_message=self.handle_incoming_message,
-                blocking=True
-            )
-        except KeyboardInterrupt:
-            logger.info("监听已停止")
-        except Exception as e:
-            logger.error(f"监听过程中出现错误: {e}")
-
-if __name__ == "__main__":
-    listener = FeishuMessageListener()
-    listener.start()

+ 22 - 0
agent/agent/tools/builtin/file/diff_utils.py

@@ -0,0 +1,22 @@
+"""Shared unified-diff rendering for file mutation tools."""
+
+from __future__ import annotations
+
+import difflib
+
+
+def create_unified_diff(filepath: str, old_content: str, new_content: str) -> str:
+    """Return a stable unified diff, or a localized no-change marker."""
+
+    diff_lines = difflib.unified_diff(
+        old_content.splitlines(keepends=True),
+        new_content.splitlines(keepends=True),
+        fromfile=f"a/{filepath}",
+        tofile=f"b/{filepath}",
+        lineterm="",
+    )
+    rendered = "".join(diff_lines)
+    return rendered or "(无变更)"
+
+
+__all__ = ["create_unified_diff"]

+ 1 - 28
agent/agent/tools/builtin/file/edit.py

@@ -11,10 +11,10 @@ Edit Tool - 文件编辑工具
 
 from pathlib import Path
 from typing import Optional, Generator
-import difflib
 import re
 
 from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.builtin.file.diff_utils import create_unified_diff as _create_diff
 
 
 @tool(description="编辑文件,使用精确字符串替换。支持多种智能匹配策略。", hidden_params=["context"], groups=["core"], capabilities=["write"])
@@ -225,8 +225,6 @@ def block_anchor_replacer(content: str, find: str) -> Generator[str, None, None]
 
     first_line_find = find_lines[0].strip()
     last_line_find = find_lines[-1].strip()
-    find_block_size = len(find_lines)
-
     # 收集所有候选位置(首尾行都匹配)
     candidates = []
     for i in range(len(content_lines)):
@@ -245,8 +243,6 @@ def block_anchor_replacer(content: str, find: str) -> Generator[str, None, None]
     # 单个候选:使用宽松阈值
     if len(candidates) == 1:
         start_line, end_line = candidates[0]
-        actual_block_size = end_line - start_line + 1
-
         similarity = _calculate_block_similarity(
             content_lines[start_line:end_line + 1],
             find_lines
@@ -506,26 +502,3 @@ def multi_occurrence_replacer(content: str, find: str) -> Generator[str, None, N
             break
         yield find
         start_index = index + len(find)
-
-
-# ============================================================================
-# 辅助函数
-# ============================================================================
-
-def _create_diff(filepath: str, old_content: str, new_content: str) -> str:
-    """生成 unified diff"""
-    old_lines = old_content.splitlines(keepends=True)
-    new_lines = new_content.splitlines(keepends=True)
-
-    diff_lines = list(difflib.unified_diff(
-        old_lines,
-        new_lines,
-        fromfile=f"a/{filepath}",
-        tofile=f"b/{filepath}",
-        lineterm=''
-    ))
-
-    if not diff_lines:
-        return "(无变更)"
-
-    return ''.join(diff_lines)

+ 7 - 2
agent/agent/tools/builtin/file/glob.py

@@ -19,7 +19,12 @@ from agent.tools import tool, ToolResult, ToolContext
 LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
 
 
-@tool(description="使用 glob 模式匹配文件", hidden_params=["context"], capabilities=["read"])
+@tool(
+    description="使用 glob 模式匹配文件",
+    hidden_params=["context"],
+    groups=["core"],
+    capabilities=["read"],
+)
 async def glob_files(
     pattern: str,
     path: Optional[str] = None,
@@ -87,7 +92,7 @@ async def glob_files(
             output = "\n".join(file_paths)
 
             if truncated:
-                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+                output += "\n\n(结果已截断。考虑使用更具体的路径或模式。)"
 
         return ToolResult(
             title=f"匹配: {pattern}",

+ 5 - 1
agent/agent/tools/builtin/file/grep.py

@@ -9,6 +9,7 @@ Grep Tool - 内容搜索工具
 - 按修改时间排序结果
 """
 
+import logging
 import re
 import subprocess
 from pathlib import Path
@@ -16,6 +17,8 @@ from typing import Optional, List, Tuple
 
 from agent.tools import tool, ToolResult, ToolContext
 
+logger = logging.getLogger(__name__)
+
 # 常量
 LIMIT = 100  # 最大返回匹配数(参考 opencode grep.ts:107)
 MAX_LINE_LENGTH = 2000  # 最大行长度(参考 opencode grep.ts:10)
@@ -210,7 +213,8 @@ async def _python_search(
                     # 限制数量避免过多搜索
                     if len(matches) >= LIMIT * 2:
                         return matches
-        except Exception:
+        except (OSError, UnicodeError) as exc:
+            logger.debug("Failed to search file %s: %s", file_path, exc)
             continue
 
     return matches

+ 1 - 1
agent/agent/tools/builtin/file/image_cdn.py

@@ -10,7 +10,7 @@ import hashlib
 import json
 import logging
 import re
-from typing import Any, Union
+from typing import Any
 import httpx
 
 logger = logging.getLogger(__name__)

+ 0 - 1
agent/agent/tools/builtin/file/read.py

@@ -10,7 +10,6 @@ Read Tool - 文件读取工具
 - 行长度和字节限制
 """
 
-import os
 import base64
 import mimetypes
 from pathlib import Path

+ 1 - 20
agent/agent/tools/builtin/file/write.py

@@ -11,9 +11,9 @@ Write Tool - 文件写入工具
 
 from pathlib import Path
 from typing import Optional
-import difflib
 
 from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.builtin.file.diff_utils import create_unified_diff as _create_diff
 
 
 @tool(description="写入文件内容(创建新文件、覆盖现有文件或追加内容)", hidden_params=["context"], groups=["core"], capabilities=["write"])
@@ -117,22 +117,3 @@ async def write_file(
         },
         long_term_memory=f"{operation}文件 {path.name}"
     )
-
-
-def _create_diff(filepath: str, old_content: str, new_content: str) -> str:
-    """生成 unified diff"""
-    old_lines = old_content.splitlines(keepends=True)
-    new_lines = new_content.splitlines(keepends=True)
-
-    diff_lines = list(difflib.unified_diff(
-        old_lines,
-        new_lines,
-        fromfile=f"a/{filepath}",
-        tofile=f"b/{filepath}",
-        lineterm=''
-    ))
-
-    if not diff_lines:
-        return "(无变更)"
-
-    return ''.join(diff_lines)

+ 1 - 1
agent/agent/tools/builtin/file/write_json.py

@@ -4,7 +4,7 @@ Write JSON Tool - 用于直接安全写入结构化 JSON 数据,避免大模
 
 import json
 from pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Optional
 
 from agent.tools import tool, ToolResult, ToolContext
 

+ 3 - 106
agent/agent/tools/builtin/glob_tool.py

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

+ 1 - 10
agent/agent/tools/builtin/im/chat.py

@@ -6,20 +6,11 @@
 import asyncio
 import json
 import logging
-import os
-import sys
 from typing import Optional
 
+from agent.im_client import AgentNotifier, IMClient
 from agent.tools import tool, ToolResult, ToolContext
 
-# 将 im-client 目录加入 sys.path
-_IM_CLIENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "im-client"))
-if _IM_CLIENT_DIR not in sys.path:
-    sys.path.insert(0, _IM_CLIENT_DIR)
-
-from client import IMClient  # noqa: E402
-from notifier import AgentNotifier  # noqa: E402
-
 logger = logging.getLogger(__name__)
 
 # ── 全局状态 ──

+ 8 - 78
agent/agent/tools/builtin/knowledge.py

@@ -7,13 +7,11 @@
 import os
 import json
 import logging
-import subprocess
 import uuid
 import httpx
-from dataclasses import dataclass
 from typing import List, Dict, Optional, Any
 from agent.tools import tool, ToolResult, ToolContext
-from agent.core.prompts import build_reflect_prompt, COMPLETION_REFLECT_PROMPT
+from agent.core.knowledge_config import KnowledgeConfig as KnowledgeConfig
 
 logger = logging.getLogger(__name__)
 
@@ -21,76 +19,6 @@ logger = logging.getLogger(__name__)
 KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000").rstrip("/")
 
 
-# ===== 知识管理配置 =====
-
-@dataclass
-class KnowledgeConfig:
-    """知识提取与注入的配置"""
-
-    # 压缩时提取(消息量超阈值触发压缩时,在 Level 1 过滤前用完整 history 反思)
-    enable_extraction: bool = True         # 是否在压缩触发时提取知识
-    reflect_prompt: str = ""               # 自定义反思 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:REFLECT_PROMPT
-
-    # agent运行完成后提取(不代表任务完成,agent 可能中途退出等待人工评估)
-    enable_completion_extraction: bool = True      # 是否在运行完成后提取知识
-    completion_reflect_prompt: str = ""            # 自定义复盘 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:COMPLETION_REFLECT_PROMPT
-
-    # 提取-审核-提交两阶段开关(见 agent/docs/memory.md 第三节)
-    reflect_auto_commit: bool = False
-    # False(默认): reflection 仅写 cognition_log: type="extraction_pending",
-    #               人工通过 CLI(agent/cli/extraction_review.py)review + commit 才进 KnowHub
-    # True         : reflection 直接 upload_knowledge(旧行为),适合无人值守的 example
-
-    # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
-    enable_injection: bool = True          # 是否在 focus goal 时自动注入相关知识
-
-    # 默认字段(保存/搜索时自动注入)
-    owner: str = ""                            # 所有者(空则尝试从 git config user.email 获取,再空则用 agent:{agent_id})
-    default_tags: Optional[Dict[str, str]] = None      # 默认 tags(会与工具调用参数合并)
-    default_scopes: Optional[List[str]] = None         # 默认 scopes(空则用 ["org:cybertogether"])
-    default_search_types: Optional[List[str]] = None   # 默认搜索类型过滤
-    default_search_owner: str = ""                     # 默认搜索 owner 过滤(空则不过滤,支持多个owner用逗号分隔,如 "user1@example.com,user2@example.com")
-
-    def get_reflect_prompt(self) -> str:
-        """压缩时反思 prompt"""
-        return self.reflect_prompt if self.reflect_prompt else build_reflect_prompt()
-
-    def get_completion_reflect_prompt(self) -> str:
-        """任务完成后复盘 prompt"""
-        return self.completion_reflect_prompt if self.completion_reflect_prompt else COMPLETION_REFLECT_PROMPT
-
-    @property
-    def resolved_owner(self) -> str:
-        """解析后的 owner(优先级:配置 > git email > 'agent')
-
-        供 inject_params key path 使用:knowledge_config.resolved_owner
-        """
-        if self.owner:
-            return self.owner
-
-        # 尝试从 git config 获取
-        try:
-            result = subprocess.run(
-                ["git", "config", "user.email"],
-                capture_output=True,
-                text=True,
-                timeout=2,
-            )
-            if result.returncode == 0 and result.stdout.strip():
-                return result.stdout.strip()
-        except Exception:
-            pass
-
-        return "agent"
-
-    def get_owner(self, agent_id: str = "agent") -> str:
-        """获取 owner(优先级:配置 > git email > agent:{agent_id})"""
-        owner = self.resolved_owner
-        if owner == "agent" and agent_id != "agent":
-            return f"agent:{agent_id}"
-        return owner
-
-
 @tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_search(
     query: str,
@@ -670,8 +598,7 @@ async def resource_save(
         async with httpx.AsyncClient(timeout=30.0) as client:
             response = await client.post(f"{KNOWHUB_API}/api/resource", json=payload)
             response.raise_for_status()
-            data = response.json()
-
+            response.json()  # Preserve response-shape validation without retaining unused data.
         return ToolResult(
             title="✅ 资源已保存",
             output=f"资源 ID: {resource_id}\n类型: {content_type}\n标题: {title}",
@@ -758,7 +685,8 @@ async def tool_search(
     """
     try:
         params = {"q": query, "top_k": top_k}
-        if status: params["status"] = status
+        if status:
+            params["status"] = status
         async with httpx.AsyncClient(timeout=30.0) as client:
             res = await client.get(f"{KNOWHUB_API}/api/tool/search", params=params)
             res.raise_for_status()
@@ -778,7 +706,8 @@ async def tool_list(
     """列出工具列表 (Tool)"""
     try:
         params = {"limit": limit, "offset": offset}
-        if status: params["status"] = status
+        if status:
+            params["status"] = status
         async with httpx.AsyncClient(timeout=30.0) as client:
             res = await client.get(f"{KNOWHUB_API}/api/tool", params=params)
             res.raise_for_status()
@@ -860,7 +789,8 @@ async def requirement_list(
     """列出需求列表 (Requirement)"""
     try:
         params = {"limit": limit, "offset": offset}
-        if status: params["status"] = status
+        if status:
+            params["status"] = status
         async with httpx.AsyncClient(timeout=30.0) as client:
             res = await client.get(f"{KNOWHUB_API}/api/requirement", params=params)
             res.raise_for_status()

+ 1 - 1
agent/agent/tools/builtin/resource.py

@@ -4,7 +4,7 @@
 
 import os
 import httpx
-from typing import List, Dict, Optional, Any
+from typing import Optional
 from agent.tools import tool, ToolResult
 
 KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999").rstrip("/")

+ 1 - 2
agent/agent/tools/builtin/skill.py

@@ -5,7 +5,6 @@ Agent 可以调用此工具来加载特定的 skill 文档
 """
 
 import os
-import subprocess
 from pathlib import Path
 from typing import Optional
 
@@ -154,7 +153,7 @@ async def skill(
             output += setup_warning
 
         if skill_obj.guidelines:
-            output += f"## Guidelines\n\n"
+            output += "## Guidelines\n\n"
             for i, guideline in enumerate(skill_obj.guidelines, 1):
                 output += f"{i}. {guideline}\n"
             output += "\n"

+ 1 - 1
agent/agent/tools/builtin/subagent.py

@@ -794,7 +794,7 @@ async def agent(
                 parsed_task = json.loads(task_str)
                 if isinstance(parsed_task, list):
                     task = parsed_task
-            except:
+            except (json.JSONDecodeError, TypeError):
                 pass
 
     single = isinstance(task, str)

+ 0 - 5
agent/agent/tools/builtin/toolhub.py

@@ -485,16 +485,11 @@ async def toolhub_call(
             if isinstance(result, dict):
                 # 收集所有图片(单张 image 字段 + images 列表字段)
                 raw_images = []
-                has_single_image = False
-                has_images_list = False
-
                 if result.get("image") and isinstance(result["image"], str):
                     raw_images.append(result["image"])
-                    has_single_image = True
 
                 if result.get("images") and isinstance(result["images"], list):
                     raw_images.extend(result["images"])
-                    has_images_list = True
 
                 if raw_images:
                     images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)

+ 129 - 1
agent/agent/tools/registry.py

@@ -56,6 +56,15 @@ class ToolStats:
 
 class ToolRegistry:
 	"""工具注册表"""
+	_REGISTRATION_FIELDS = (
+		"schema",
+		"url_patterns",
+		"hidden_params",
+		"inject_params",
+		"groups",
+		"capabilities",
+		"ui_metadata",
+	)
 
 	def __init__(self):
 		self._tools: Dict[str, Dict[str, Any]] = {}
@@ -100,7 +109,7 @@ class ToolRegistry:
 				logger.error(f"Failed to generate schema for {func_name}: {e}")
 				raise
 
-		self._tools[func_name] = {
+		registration = {
 			"func": func,
 			"schema": schema,
 			"url_patterns": url_patterns,
@@ -115,6 +124,62 @@ class ToolRegistry:
 			}
 		}
 
+		existing = self._tools.get(func_name)
+		if existing is not None:
+			same_func = existing["func"] is func
+			same_definition = self._callable_origin(existing["func"]) == self._callable_origin(func)
+			same_metadata = all(
+				existing.get(field) == registration.get(field)
+				for field in self._REGISTRATION_FIELDS
+			)
+			if same_func and same_metadata:
+				# 重复 import 或装配代码可能对同一函数重复注册。这是安全的
+				# 幂等操作,且不应重置已累计的调用统计。
+				logger.debug("[ToolRegistry] Already registered (idempotent): %s", func_name)
+				return
+			if same_definition and same_metadata:
+				# Host composition can recreate a closure from the same factory
+				# definition to bind a fresh adapter/gateway. Replace only the
+				# callable and preserve accumulated statistics.
+				existing["func"] = func
+				logger.debug(
+					"[ToolRegistry] Rebound same-definition tool: %s (%s)",
+					func_name,
+					self._describe_callable(func),
+				)
+				return
+			if self._is_host_layer_override(existing, registration):
+				# A Host may deliberately replace a framework builtin with a
+				# capability-equivalent adapter. This preserves the historical
+				# layering contract while still rejecting builtin/builtin clashes,
+				# capability escalation, and competing Host implementations.
+				self._tools[func_name] = registration
+				logger.warning(
+					"[ToolRegistry] Host replaced builtin tool: %s (%s -> %s)",
+					func_name,
+					self._describe_callable(existing["func"]),
+					self._describe_callable(func),
+				)
+				return
+
+			existing_source = self._describe_callable(existing["func"])
+			attempted_source = self._describe_callable(func)
+			if same_func:
+				changed_fields = [
+					field
+					for field in self._REGISTRATION_FIELDS
+					if existing.get(field) != registration.get(field)
+				]
+				detail = f"registration metadata differs: {', '.join(changed_fields)}"
+			else:
+				detail = "a different callable already owns this name"
+			raise ValueError(
+				f"Duplicate tool registration for '{func_name}': {detail}; "
+				f"existing={existing_source}, attempted={attempted_source}"
+			)
+
+		self._tools[func_name] = registration
+
 		# 初始化统计
 		self._stats[func_name] = ToolStats()
 
@@ -125,6 +190,45 @@ class ToolRegistry:
 			f"url_patterns={url_patterns or 'none'})"
 		)
 
+	@staticmethod
+	def _describe_callable(func: Callable) -> str:
+		"""返回可用于定位重复注册来源的稳定描述。"""
+		module = getattr(func, "__module__", type(func).__module__)
+		qualname = getattr(func, "__qualname__", getattr(func, "__name__", type(func).__qualname__))
+		code = getattr(func, "__code__", None)
+		if code is None:
+			return f"{module}.{qualname}"
+		return f"{module}.{qualname} ({code.co_filename}:{code.co_firstlineno})"
+
+	@staticmethod
+	def _callable_origin(func: Callable) -> tuple[str, str, Optional[str], Optional[int]]:
+		"""Identify one source definition while allowing fresh closure instances."""
+		module = getattr(func, "__module__", type(func).__module__)
+		qualname = getattr(
+			func,
+			"__qualname__",
+			getattr(func, "__name__", type(func).__qualname__),
+		)
+		code = getattr(func, "__code__", None)
+		if code is None:
+			return module, qualname, None, None
+		return module, qualname, code.co_filename, code.co_firstlineno
+
+	@staticmethod
+	def _is_host_layer_override(
+		existing: Dict[str, Any],
+		registration: Dict[str, Any],
+	) -> bool:
+		existing_module = getattr(existing["func"], "__module__", "")
+		attempted_module = getattr(registration["func"], "__module__", "")
+		existing_capabilities = existing.get("capabilities", frozenset())
+		return (
+			existing_module.startswith(("agent.tools.builtin.", "agent.trace."))
+			and not attempted_module.startswith("agent.")
+			and bool(existing_capabilities)
+			and registration.get("capabilities") == existing_capabilities
+		)
+
 	@staticmethod
 	def _resolve_key_path(context: Dict[str, Any], key_path: str) -> Any:
 		"""
@@ -251,6 +355,7 @@ class ToolRegistry:
 			uid: 用户ID(自动注入)
 			context: 额外上下文
 			sensitive_data: 敏感数据字典(用于替换 <secret> 占位符)
+			inject_values: 兼容注入值;仅在调用参数未提供时生效
 
 		Returns:
 			JSON 字符串格式的结果
@@ -288,6 +393,12 @@ class ToolRegistry:
 			if "context" in hidden_params and "context" in sig.parameters:
 				kwargs["context"] = context
 
+			# 旧调用方可以在执行时提供默认注入值。模型明确给出的
+			# 参数优先,且仍只允许函数签名中存在的字段。
+			for param_name, value in (inject_values or {}).items():
+				if param_name in sig.parameters and param_name not in kwargs:
+					kwargs[param_name] = value
+
 			# 注入参数(inject_params)
 			inject_params = tool_info.get("inject_params", {})
 			for param_name, rule in inject_params.items():
@@ -569,9 +680,26 @@ def tool(
 			...
 	"""
 	def decorator(func: Callable) -> Callable:
+		schema = None
+		if description is not None or param_descriptions:
+			from agent.tools.schema import SchemaGenerator
+
+			schema = SchemaGenerator.generate(
+				func,
+				hidden_params=hidden_params or [],
+			)
+			function_schema = schema["function"]
+			if description is not None:
+				function_schema["description"] = description
+			properties = function_schema["parameters"].get("properties", {})
+			for param_name, param_description in (param_descriptions or {}).items():
+				if param_name in properties:
+					properties[param_name]["description"] = param_description
+
 		# 注册到全局 registry
 		_global_registry.register(
 			func,
+			schema=schema,
 			requires_confirmation=requires_confirmation,
 			editable_params=editable_params,
 			display=display,

+ 8 - 4
agent/agent/tools/utils/image.py

@@ -14,13 +14,15 @@
 import asyncio
 import base64
 import io
+import logging
 import math
-from pathlib import Path
 from typing import List, Optional, Sequence, Tuple
 
 import httpx
 from PIL import Image, ImageDraw, ImageFont
 
+logger = logging.getLogger(__name__)
+
 
 # ── 网格拼图默认参数 ──
 DEFAULT_THUMB_SIZE = 250         # 每格缩略图边长
@@ -58,7 +60,7 @@ def _load_fonts(title_size: int = 16, index_size: int = 32):
                 ImageFont.truetype(path, title_size),
                 ImageFont.truetype(path, index_size),
             )
-        except Exception:
+        except OSError:
             continue
     default = ImageFont.load_default()
     return default, default
@@ -72,7 +74,8 @@ async def _load_image_from_url(client: httpx.AsyncClient, url: str) -> Optional[
         resp = await client.get(url, timeout=15.0)
         resp.raise_for_status()
         return Image.open(io.BytesIO(resp.content)).convert("RGB")
-    except Exception:
+    except (httpx.HTTPError, OSError, ValueError) as exc:
+        logger.debug("Failed to load image URL %s: %s", url, exc)
         return None
 
 
@@ -80,7 +83,8 @@ def _load_image_from_path(path: str) -> Optional[Image.Image]:
     """从本地路径加载图片,失败返回 None"""
     try:
         return Image.open(path).convert("RGB")
-    except Exception:
+    except (OSError, ValueError) as exc:
+        logger.debug("Failed to load image path %s: %s", path, exc)
         return None
 
 

+ 6 - 0
agent/agent/trace/__init__.py

@@ -15,6 +15,7 @@ from .attachments import AttachmentRef
 from .protocols import TraceAttachmentStore, TraceStore
 from .store import FileSystemTraceStore
 from .trace_id import generate_trace_id, generate_sub_trace_id, parse_parent_trace_id
+from .tree_dump import StepTreeDumper, dump_json, dump_markdown, dump_tree
 
 __all__ = [
     # Models
@@ -34,4 +35,9 @@ __all__ = [
     "generate_trace_id",
     "generate_sub_trace_id",
     "parse_parent_trace_id",
+    # Debug helpers (kept for the documented compatibility import)
+    "StepTreeDumper",
+    "dump_tree",
+    "dump_json",
+    "dump_markdown",
 ]

+ 255 - 0
agent/agent/trace/_cognition_store.py

@@ -0,0 +1,255 @@
+"""Cognition-log persistence behind the FileSystemTraceStore facade."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List
+
+
+class TraceCognitionStore:
+    """Own cognition-log behavior without changing the public TraceStore API."""
+
+    def __init__(self, owner: Any) -> None:
+        self._owner = owner
+
+    def _get_cognition_log_file(self, trace_id: str) -> Path:
+        """获取 cognition_log.json 文件路径"""
+        return self._owner._get_trace_dir(trace_id) / "cognition_log.json"
+
+    def _get_knowledge_log_file(self, trace_id: str) -> Path:
+        """兼容旧接口:优先使用 cognition_log,回退到 knowledge_log"""
+        cognition_file = self._owner._get_cognition_log_file(trace_id)
+        if cognition_file.exists():
+            return cognition_file
+        legacy_file = self._owner._get_trace_dir(trace_id) / "knowledge_log.json"
+        if legacy_file.exists():
+            return legacy_file
+        return cognition_file  # 新建时用 cognition_log
+
+    async def get_cognition_log(self, trace_id: str) -> Dict[str, Any]:
+        """读取认知日志"""
+        log_file = self._owner._get_cognition_log_file(trace_id)
+        if log_file.exists():
+            return json.loads(log_file.read_text(encoding="utf-8"))
+        # 兼容旧格式:如果只有 knowledge_log.json,读取并转换
+        legacy_file = self._owner._get_trace_dir(trace_id) / "knowledge_log.json"
+        if legacy_file.exists():
+            return json.loads(legacy_file.read_text(encoding="utf-8"))
+        return {"trace_id": trace_id, "events": []}
+
+    async def get_knowledge_log(self, trace_id: str) -> Dict[str, Any]:
+        """兼容旧接口"""
+        log = await self._owner.get_cognition_log(trace_id)
+        # 旧格式用 entries,新格式用 events
+        if "entries" not in log and "events" in log:
+            log["entries"] = log["events"]
+        return log
+
+    async def append_cognition_event(
+        self,
+        trace_id: str,
+        event: Dict[str, Any],
+    ) -> None:
+        """追加认知事件到 cognition_log.json。
+
+        所有事件共有字段:
+            type: str         事件类型(见下表)
+            timestamp: str    ISO 格式时间戳(框架自动写入)
+
+        已定义的事件类型及典型字段:
+
+            type="query" — 知识注入查询(goal focus 时触发)
+                sequence, goal_id, query, response, source_ids, sources
+
+            type="evaluation" — 知识评估(Goal 完成/压缩前/任务结束触发)
+                knowledge_id, eval_result{relevance, utility, notes}, trigger_event
+
+            type="extraction_pending" — 反思侧分支暂存的待审核提取(Phase 1.2+)
+                extraction_id, sequence, goal_id, branch_id, payload
+                (payload 字段与 knowledge_save 参数一一对应)
+
+            type="extraction_reviewed" — 人工审核决策(CLI / HTTP API 写入)
+                extraction_id, decision("approve"/"edit"/"discard"), edited_payload?
+
+            type="extraction_committed" — 已上传到 KnowHub
+                extraction_id, knowledge_id
+
+            type="reflection" — Dream 的 per-trace 反思摘要(Phase 2.4 / 3.1)
+                sequence_range: [start, end]    本次反思覆盖的消息区间
+                summary: str                    LLM 生成的反思摘要
+                consumed_at: 可选, ISO 时间戳   当跨 trace 整合已消化此反思时写入
+
+        其他字段可按需附加,不做强校验(演进友好)。
+        """
+        log = await self._owner.get_cognition_log(trace_id)
+        if "events" not in log:
+            log["events"] = log.pop("entries", [])
+        event["timestamp"] = datetime.now().isoformat()
+        log["events"].append(event)
+        log_file = self._owner._get_cognition_log_file(trace_id)
+        log_file.write_text(
+            json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8"
+        )
+
+    async def append_knowledge_entry(
+        self,
+        trace_id: str,
+        knowledge_id: str,
+        goal_id: str,
+        injected_at_sequence: int,
+        task: str,
+        content: str,
+    ) -> None:
+        """兼容旧接口:追加知识注入记录(转换为 query 事件)"""
+        await self._owner.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "query",
+                "sequence": injected_at_sequence,
+                "goal_id": goal_id,
+                "query": task,
+                "response": "",
+                "source_ids": [knowledge_id],
+                "sources": [
+                    {"id": knowledge_id, "task": task, "content": content[:500]}
+                ],
+            },
+        )
+
+    async def update_knowledge_evaluation(
+        self,
+        trace_id: str,
+        knowledge_id: str,
+        eval_result: Dict[str, Any],
+        trigger_event: str,
+    ) -> None:
+        """更新知识评估结果(兼容旧格式 + 新 cognition_log 格式)
+
+        旧格式:更新 entries[] 中匹配 knowledge_id 的条目的 eval_result
+        新格式:追加 evaluation 事件到 events[]
+        """
+        log = await self._owner.get_cognition_log(trace_id)
+        events = log.get("events", log.get("entries", []))
+
+        # 旧格式兼容:直接更新 entries 中的 eval_result 字段
+        if "entries" in log:
+            matching = [
+                (i, e)
+                for i, e in enumerate(log["entries"])
+                if e.get("knowledge_id") == knowledge_id
+                and e.get("eval_result") is None
+            ]
+            if matching:
+                matching.sort(
+                    key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True
+                )
+                _, entry = matching[0]
+                entry["eval_result"] = eval_result
+                entry["evaluated_at"] = datetime.now().isoformat()
+                entry["evaluated_at_trigger"] = trigger_event
+                log_file = self._owner._get_knowledge_log_file(trace_id)
+                log_file.write_text(
+                    json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8"
+                )
+                return
+
+        # 新格式:追加 evaluation 事件
+        # 找到包含该 knowledge_id 的最近 query 事件
+        query_events = [
+            e
+            for e in events
+            if e.get("type") == "query" and knowledge_id in e.get("source_ids", [])
+        ]
+        query_sequence = query_events[-1]["sequence"] if query_events else None
+
+        await self._owner.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "evaluation",
+                "sequence": max((e.get("sequence", 0) for e in events), default=0) + 1,
+                "query_sequence": query_sequence,
+                "trigger": trigger_event,
+                "assessments": [
+                    {
+                        "source_id": knowledge_id,
+                        "status": eval_result.get("eval_status", ""),
+                        "reason": eval_result.get("reason", ""),
+                    }
+                ],
+            },
+        )
+
+    async def get_pending_knowledge_entries(
+        self, trace_id: str
+    ) -> List[Dict[str, Any]]:
+        """获取所有待评估的知识条目(兼容旧格式 + 新格式)"""
+        log = await self._owner.get_cognition_log(trace_id)
+
+        # 旧格式
+        if "entries" in log:
+            return [e for e in log["entries"] if e.get("eval_result") is None]
+
+        # 新格式:找没有对应 evaluation 事件的 query 事件
+        events = log.get("events", [])
+        query_events = [e for e in events if e.get("type") == "query"]
+        eval_events = [e for e in events if e.get("type") == "evaluation"]
+
+        # 已评估的 query sequences
+        evaluated_sequences = {e.get("query_sequence") for e in eval_events}
+
+        pending = []
+        for qe in query_events:
+            if qe.get("sequence") not in evaluated_sequences:
+                # 转为旧格式兼容(runner 中的评估逻辑期望此格式)
+                for source in qe.get("sources", []):
+                    pending.append(
+                        {
+                            "knowledge_id": source.get("id", ""),
+                            "goal_id": qe.get("goal_id", ""),
+                            "injected_at_sequence": qe.get("sequence", 0),
+                            "task": source.get("task", ""),
+                            "content": source.get("content", ""),
+                            "query_sequence": qe.get("sequence"),
+                        }
+                    )
+        return pending
+
+    async def update_user_feedback(
+        self, trace_id: str, knowledge_id: str, user_feedback: Dict[str, Any]
+    ) -> None:
+        """记录用户对知识的反馈(confirm/override)"""
+        log = await self._owner.get_cognition_log(trace_id)
+
+        # 旧格式
+        if "entries" in log:
+            matching = [
+                (i, e)
+                for i, e in enumerate(log["entries"])
+                if e.get("knowledge_id") == knowledge_id
+            ]
+            if matching:
+                matching.sort(
+                    key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True
+                )
+                _, entry = matching[0]
+                entry["user_feedback"] = user_feedback
+            log_file = self._owner._get_knowledge_log_file(trace_id)
+            log_file.write_text(
+                json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8"
+            )
+            return
+
+        # 新格式:追加 user_feedback 事件(或直接记录在 evaluation 上)
+        await self._owner.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "user_feedback",
+                "knowledge_id": knowledge_id,
+                "feedback": user_feedback,
+            },
+        )
+
+
+__all__ = ["TraceCognitionStore"]

+ 9 - 2
agent/agent/trace/api.py

@@ -6,6 +6,7 @@ Trace RESTful API
 
 import os
 import json
+import logging
 import httpx
 from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
@@ -14,6 +15,8 @@ from pydantic import BaseModel
 
 from .protocols import TraceStore
 
+logger = logging.getLogger(__name__)
+
 
 router = APIRouter(prefix="/api/traces", tags=["traces"])
 
@@ -275,7 +278,11 @@ async def submit_knowledge_feedback(trace_id: str, req: KnowledgeFeedbackRequest
                 updated_count += 1
             except Exception as e:
                 # 记录警告但不中断整体提交
-                print(f"[KnowledgeFeedback] KnowHub 更新失败 {item.knowledge_id}: {e}")
+                logger.warning(
+                    "KnowHub knowledge update failed: knowledge_id=%s error=%s",
+                    item.knowledge_id,
+                    e,
+                )
 
     return {"status": "ok", "updated": updated_count}
 
@@ -318,7 +325,7 @@ async def extract_comment_proxy(req: Dict[str, Any]):
                     if "task" in parsed and "content" in parsed:
                         raw = part
                         break
-                except Exception:
+                except (json.JSONDecodeError, TypeError):
                     continue
         extracted = json.loads(raw)
         task = extracted.get("task", "").strip()

+ 0 - 1
agent/agent/trace/examples_api.py

@@ -2,7 +2,6 @@
 Examples API - 提供 examples 项目列表和 prompt 读取接口
 """
 
-import os
 from typing import List, Optional
 from pathlib import Path
 from fastapi import APIRouter, HTTPException

+ 4 - 4
agent/agent/trace/goal_tool.py

@@ -5,7 +5,7 @@ Goal 工具 - 计划管理
 """
 
 import logging
-from typing import Optional, List, TYPE_CHECKING
+from typing import Optional, TYPE_CHECKING
 
 from agent.tools import tool
 
@@ -42,7 +42,7 @@ async def inject_knowledge_for_goal(
     """
     # 检查是否启用知识注入
     if knowledge_config and not getattr(knowledge_config, 'enable_injection', True):
-        logger.debug(f"[Knowledge Inject] 知识注入已禁用,跳过")
+        logger.debug("[Knowledge Inject] 知识注入已禁用,跳过")
         return None
 
     try:
@@ -92,12 +92,12 @@ async def inject_knowledge_for_goal(
                             "sources": [],
                         }
                     )
-                    logger.info(f"[Knowledge Inject] 已记录 query 事件到 cognition_log")
+                    logger.info("[Knowledge Inject] 已记录 query 事件到 cognition_log")
 
             return f"📚 已注入 {knowledge_count} 条相关知识"
         else:
             goal.knowledge = []
-            logger.info(f"[Knowledge Inject] 未找到相关知识")
+            logger.info("[Knowledge Inject] 未找到相关知识")
             return None
 
     except Exception as e:

+ 2 - 2
agent/agent/trace/run_api.py

@@ -708,8 +708,8 @@ async def list_running():
                             trace_info["is_active"] = time_since_activity < 30
                             trace_info["seconds_since_activity"] = int(time_since_activity)
                         trace_info["status"] = trace.status
-                except Exception:
-                    pass
+                except Exception as exc:
+                    logger.debug("Failed to inspect running trace %s: %s", tid, exc)
 
             running.append(trace_info)
 

+ 57 - 211
agent/agent/trace/store.py

@@ -32,6 +32,7 @@ from typing import Dict, List, Optional, Any
 from .attachments import AttachmentRef
 from .models import Trace, Message
 from .goal_models import GoalTree, Goal, GoalStats
+from ._cognition_store import TraceCognitionStore
 
 logger = logging.getLogger(__name__)
 
@@ -42,6 +43,7 @@ class FileSystemTraceStore:
     def __init__(self, base_path: str = ".trace"):
         self.base_path = Path(base_path)
         self.base_path.mkdir(exist_ok=True)
+        self._cognition = TraceCognitionStore(self)
 
     def _get_trace_dir(self, trace_id: str) -> Path:
         """获取 trace 目录"""
@@ -299,7 +301,8 @@ class FileSystemTraceStore:
                     data["completed_at"] = datetime.fromisoformat(data["completed_at"])
 
                 traces.append(Trace.from_dict(data))
-            except Exception:
+            except Exception as exc:
+                logger.warning("Skipping unreadable trace metadata %s: %s", meta_file, exc)
                 continue
 
         # 排序(最新的在前)
@@ -318,7 +321,8 @@ class FileSystemTraceStore:
         try:
             data = json.loads(goal_file.read_text(encoding="utf-8"))
             return GoalTree.from_dict(data)
-        except Exception:
+        except Exception as exc:
+            logger.warning("Failed to load GoalTree %s: %s", goal_file, exc)
             return None
 
     async def update_goal_tree(self, trace_id: str, tree: GoalTree) -> None:
@@ -341,19 +345,22 @@ class FileSystemTraceStore:
         event_data = {"goal": goal.to_dict(), "parent_id": goal.parent_id}
         await self.append_event(trace_id, "goal_added", event_data)
 
-        # 打印详细的 goal 信息
         desc_preview = (
             goal.description[:80] + "..."
             if len(goal.description) > 80
             else goal.description
         )
-        print(f"[Goal Added] ID={goal.id}, Parent={goal.parent_id or 'root'}")
-        print(f"  📝 {desc_preview}")
+        logger.info(
+            "Goal added: id=%s parent=%s description=%s",
+            goal.id,
+            goal.parent_id or "root",
+            desc_preview,
+        )
         if goal.reason:
             reason_preview = (
                 goal.reason[:60] + "..." if len(goal.reason) > 60 else goal.reason
             )
-            print(f"  💡 {reason_preview}")
+            logger.debug("Goal reason: %s", reason_preview)
 
     async def update_goal(
         self,
@@ -397,8 +404,11 @@ class FileSystemTraceStore:
             "goal_updated",
             {"goal_id": goal_id, "updates": updates, "affected_goals": affected_goals},
         )
-        print(
-            f"[DEBUG] Pushed goal_updated event: goal_id={goal_id}, updates={updates}, affected={len(affected_goals)}"
+        logger.debug(
+            "Goal update event appended: goal_id=%s updates=%s affected=%d",
+            goal_id,
+            updates,
+            len(affected_goals),
         )
 
         # Goal 完成时触发知识评估
@@ -589,8 +599,6 @@ class FileSystemTraceStore:
             goal.self_stats.total_tokens += message.tokens
         if message.cost:
             goal.self_stats.total_cost += message.cost
-        # TODO: 更新 preview(工具调用摘要)
-
         # 更新自身 cumulative_stats
         goal.cumulative_stats.message_count += 1
         if message.tokens:
@@ -670,8 +678,8 @@ class FileSystemTraceStore:
                 try:
                     data = json.loads(message_file.read_text(encoding="utf-8"))
                     return Message.from_dict(data)
-                except Exception:
-                    pass
+                except Exception as exc:
+                    logger.warning("Skipping unreadable message %s: %s", message_file, exc)
 
         return None
 
@@ -691,7 +699,8 @@ class FileSystemTraceStore:
                 data = json.loads(message_file.read_text(encoding="utf-8"))
                 msg = Message.from_dict(data)
                 messages.append(msg)
-            except Exception:
+            except Exception as exc:
+                logger.warning("Skipping unreadable message %s: %s", message_file, exc)
                 continue
 
         # 按 sequence 排序
@@ -899,7 +908,12 @@ class FileSystemTraceStore:
                     event = json.loads(line.strip())
                     if event.get("event_id", 0) > since_event_id:
                         events.append(event)
-                except Exception:
+                except Exception as exc:
+                    logger.warning(
+                        "Skipping unreadable event in %s: %s",
+                        events_file,
+                        exc,
+                    )
                     continue
 
         return events
@@ -934,86 +948,26 @@ class FileSystemTraceStore:
 
         return event_id
 
-    # ===== Cognition Log 管理 =====
+    # ===== Cognition Log compatibility facade =====
 
     def _get_cognition_log_file(self, trace_id: str) -> Path:
-        """获取 cognition_log.json 文件路径"""
-        return self._get_trace_dir(trace_id) / "cognition_log.json"
+        return self._cognition._get_cognition_log_file(trace_id)
 
     def _get_knowledge_log_file(self, trace_id: str) -> Path:
-        """兼容旧接口:优先使用 cognition_log,回退到 knowledge_log"""
-        cognition_file = self._get_cognition_log_file(trace_id)
-        if cognition_file.exists():
-            return cognition_file
-        legacy_file = self._get_trace_dir(trace_id) / "knowledge_log.json"
-        if legacy_file.exists():
-            return legacy_file
-        return cognition_file  # 新建时用 cognition_log
+        return self._cognition._get_knowledge_log_file(trace_id)
 
     async def get_cognition_log(self, trace_id: str) -> Dict[str, Any]:
-        """读取认知日志"""
-        log_file = self._get_cognition_log_file(trace_id)
-        if log_file.exists():
-            return json.loads(log_file.read_text(encoding="utf-8"))
-        # 兼容旧格式:如果只有 knowledge_log.json,读取并转换
-        legacy_file = self._get_trace_dir(trace_id) / "knowledge_log.json"
-        if legacy_file.exists():
-            return json.loads(legacy_file.read_text(encoding="utf-8"))
-        return {"trace_id": trace_id, "events": []}
+        return await self._cognition.get_cognition_log(trace_id)
 
     async def get_knowledge_log(self, trace_id: str) -> Dict[str, Any]:
-        """兼容旧接口"""
-        log = await self.get_cognition_log(trace_id)
-        # 旧格式用 entries,新格式用 events
-        if "entries" not in log and "events" in log:
-            log["entries"] = log["events"]
-        return log
+        return await self._cognition.get_knowledge_log(trace_id)
 
     async def append_cognition_event(
         self,
         trace_id: str,
         event: Dict[str, Any],
     ) -> None:
-        """追加认知事件到 cognition_log.json。
-
-        所有事件共有字段:
-            type: str         事件类型(见下表)
-            timestamp: str    ISO 格式时间戳(框架自动写入)
-
-        已定义的事件类型及典型字段:
-
-            type="query" — 知识注入查询(goal focus 时触发)
-                sequence, goal_id, query, response, source_ids, sources
-
-            type="evaluation" — 知识评估(Goal 完成/压缩前/任务结束触发)
-                knowledge_id, eval_result{relevance, utility, notes}, trigger_event
-
-            type="extraction_pending" — 反思侧分支暂存的待审核提取(Phase 1.2+)
-                extraction_id, sequence, goal_id, branch_id, payload
-                (payload 字段与 knowledge_save 参数一一对应)
-
-            type="extraction_reviewed" — 人工审核决策(CLI / HTTP API 写入)
-                extraction_id, decision("approve"/"edit"/"discard"), edited_payload?
-
-            type="extraction_committed" — 已上传到 KnowHub
-                extraction_id, knowledge_id
-
-            type="reflection" — Dream 的 per-trace 反思摘要(Phase 2.4 / 3.1)
-                sequence_range: [start, end]    本次反思覆盖的消息区间
-                summary: str                    LLM 生成的反思摘要
-                consumed_at: 可选, ISO 时间戳   当跨 trace 整合已消化此反思时写入
-
-        其他字段可按需附加,不做强校验(演进友好)。
-        """
-        log = await self.get_cognition_log(trace_id)
-        if "events" not in log:
-            log["events"] = log.pop("entries", [])
-        event["timestamp"] = datetime.now().isoformat()
-        log["events"].append(event)
-        log_file = self._get_cognition_log_file(trace_id)
-        log_file.write_text(
-            json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8"
-        )
+        await self._cognition.append_cognition_event(trace_id, event)
 
     async def append_knowledge_entry(
         self,
@@ -1024,20 +978,13 @@ class FileSystemTraceStore:
         task: str,
         content: str,
     ) -> None:
-        """兼容旧接口:追加知识注入记录(转换为 query 事件)"""
-        await self.append_cognition_event(
-            trace_id=trace_id,
-            event={
-                "type": "query",
-                "sequence": injected_at_sequence,
-                "goal_id": goal_id,
-                "query": task,
-                "response": "",
-                "source_ids": [knowledge_id],
-                "sources": [
-                    {"id": knowledge_id, "task": task, "content": content[:500]}
-                ],
-            },
+        await self._cognition.append_knowledge_entry(
+            trace_id,
+            knowledge_id,
+            goal_id,
+            injected_at_sequence,
+            task,
+            content,
         )
 
     async def update_knowledge_evaluation(
@@ -1047,130 +994,29 @@ class FileSystemTraceStore:
         eval_result: Dict[str, Any],
         trigger_event: str,
     ) -> None:
-        """更新知识评估结果(兼容旧格式 + 新 cognition_log 格式)
-
-        旧格式:更新 entries[] 中匹配 knowledge_id 的条目的 eval_result
-        新格式:追加 evaluation 事件到 events[]
-        """
-        log = await self.get_cognition_log(trace_id)
-        events = log.get("events", log.get("entries", []))
-
-        # 旧格式兼容:直接更新 entries 中的 eval_result 字段
-        if "entries" in log:
-            matching = [
-                (i, e)
-                for i, e in enumerate(log["entries"])
-                if e.get("knowledge_id") == knowledge_id
-                and e.get("eval_result") is None
-            ]
-            if matching:
-                matching.sort(
-                    key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True
-                )
-                _, entry = matching[0]
-                entry["eval_result"] = eval_result
-                entry["evaluated_at"] = datetime.now().isoformat()
-                entry["evaluated_at_trigger"] = trigger_event
-                log_file = self._get_knowledge_log_file(trace_id)
-                log_file.write_text(
-                    json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8"
-                )
-                return
-
-        # 新格式:追加 evaluation 事件
-        # 找到包含该 knowledge_id 的最近 query 事件
-        query_events = [
-            e
-            for e in events
-            if e.get("type") == "query" and knowledge_id in e.get("source_ids", [])
-        ]
-        query_sequence = query_events[-1]["sequence"] if query_events else None
-
-        await self.append_cognition_event(
-            trace_id=trace_id,
-            event={
-                "type": "evaluation",
-                "sequence": max((e.get("sequence", 0) for e in events), default=0) + 1,
-                "query_sequence": query_sequence,
-                "trigger": trigger_event,
-                "assessments": [
-                    {
-                        "source_id": knowledge_id,
-                        "status": eval_result.get("eval_status", ""),
-                        "reason": eval_result.get("reason", ""),
-                    }
-                ],
-            },
+        await self._cognition.update_knowledge_evaluation(
+            trace_id,
+            knowledge_id,
+            eval_result,
+            trigger_event,
         )
 
     async def get_pending_knowledge_entries(
-        self, trace_id: str
+        self,
+        trace_id: str,
     ) -> List[Dict[str, Any]]:
-        """获取所有待评估的知识条目(兼容旧格式 + 新格式)"""
-        log = await self.get_cognition_log(trace_id)
-
-        # 旧格式
-        if "entries" in log:
-            return [e for e in log["entries"] if e.get("eval_result") is None]
-
-        # 新格式:找没有对应 evaluation 事件的 query 事件
-        events = log.get("events", [])
-        query_events = [e for e in events if e.get("type") == "query"]
-        eval_events = [e for e in events if e.get("type") == "evaluation"]
-
-        # 已评估的 query sequences
-        evaluated_sequences = {e.get("query_sequence") for e in eval_events}
-
-        pending = []
-        for qe in query_events:
-            if qe.get("sequence") not in evaluated_sequences:
-                # 转为旧格式兼容(runner 中的评估逻辑期望此格式)
-                for source in qe.get("sources", []):
-                    pending.append(
-                        {
-                            "knowledge_id": source.get("id", ""),
-                            "goal_id": qe.get("goal_id", ""),
-                            "injected_at_sequence": qe.get("sequence", 0),
-                            "task": source.get("task", ""),
-                            "content": source.get("content", ""),
-                            "query_sequence": qe.get("sequence"),
-                        }
-                    )
-        return pending
+        return await self._cognition.get_pending_knowledge_entries(trace_id)
 
     async def update_user_feedback(
-        self, trace_id: str, knowledge_id: str, user_feedback: Dict[str, Any]
+        self,
+        trace_id: str,
+        knowledge_id: str,
+        user_feedback: Dict[str, Any],
     ) -> None:
-        """记录用户对知识的反馈(confirm/override)"""
-        log = await self.get_cognition_log(trace_id)
-
-        # 旧格式
-        if "entries" in log:
-            matching = [
-                (i, e)
-                for i, e in enumerate(log["entries"])
-                if e.get("knowledge_id") == knowledge_id
-            ]
-            if matching:
-                matching.sort(
-                    key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True
-                )
-                _, entry = matching[0]
-                entry["user_feedback"] = user_feedback
-            log_file = self._get_knowledge_log_file(trace_id)
-            log_file.write_text(
-                json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8"
-            )
-            return
-
-        # 新格式:追加 user_feedback 事件(或直接记录在 evaluation 上)
-        await self.append_cognition_event(
-            trace_id=trace_id,
-            event={
-                "type": "user_feedback",
-                "knowledge_id": knowledge_id,
-                "feedback": user_feedback,
-            },
+        await self._cognition.update_user_feedback(
+            trace_id,
+            knowledge_id,
+            user_feedback,
         )
 
 

+ 21 - 825
agent/agent/trace/tree_dump.py

@@ -1,825 +1,21 @@
-"""
-Step 树 Debug 输出
-
-将 Step 树以完整格式输出到文件,便于开发调试。
-
-使用方式:
-    1. 命令行实时查看:
-       watch -n 0.5 cat .trace/tree.txt
-
-    2. VS Code 打开文件自动刷新:
-       code .trace/tree.txt
-
-    3. 代码中使用:
-       from agent.trace import dump_tree
-       dump_tree(trace, steps)
-"""
-
-import json
-from datetime import datetime
-from pathlib import Path
-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:
-    """Step 树 Debug 输出器"""
-
-    def __init__(self, output_path: str = DEFAULT_DUMP_PATH):
-        self.output_path = Path(output_path)
-        self.output_path.parent.mkdir(parents=True, exist_ok=True)
-
-    def dump(
-        self,
-        trace: Optional[Dict[str, Any]] = None,
-        steps: Optional[List[Dict[str, Any]]] = None,
-        title: str = "Step Tree Debug",
-    ) -> str:
-        """
-        输出完整的树形结构到文件
-
-        Args:
-            trace: Trace 字典(可选)
-            steps: Step 字典列表
-            title: 输出标题
-
-        Returns:
-            输出的文本内容
-        """
-        lines = []
-
-        # 标题和时间
-        lines.append("=" * 60)
-        lines.append(f" {title}")
-        lines.append(f" Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
-        lines.append("=" * 60)
-        lines.append("")
-
-        # Trace 信息
-        if trace:
-            lines.append("## Trace")
-            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("")
-
-        # 统计摘要
-        if steps:
-            lines.append("## Statistics")
-            stats = self._calculate_statistics(steps)
-            lines.append(f"  Total steps: {stats['total']}")
-            lines.append(f"  By type:")
-            for step_type, count in sorted(stats['by_type'].items()):
-                lines.append(f"    {step_type}: {count}")
-            lines.append(f"  By status:")
-            for status, count in sorted(stats['by_status'].items()):
-                lines.append(f"    {status}: {count}")
-            if stats['total_duration_ms'] > 0:
-                lines.append(f"  Total duration: {stats['total_duration_ms']}ms")
-            if stats['total_tokens'] > 0:
-                lines.append(f"  Total tokens: {stats['total_tokens']}")
-            if stats['total_cost'] > 0:
-                lines.append(f"  Total cost: ${stats['total_cost']:.4f}")
-            lines.append("")
-
-        # Step 树
-        if steps:
-            lines.append("## Steps")
-            lines.append("")
-
-            # 构建树结构
-            tree = self._build_tree(steps)
-            tree_output = self._render_tree(tree, steps)
-            lines.append(tree_output)
-
-        content = "\n".join(lines)
-
-        # 写入文件
-        self.output_path.write_text(content, encoding="utf-8")
-
-        return content
-
-    def _calculate_statistics(self, steps: List[Dict[str, Any]]) -> Dict[str, Any]:
-        """计算统计信息"""
-        stats = {
-            'total': len(steps),
-            'by_type': {},
-            'by_status': {},
-            'total_duration_ms': 0,
-            'total_tokens': 0,
-            'total_cost': 0.0,
-        }
-
-        for step in steps:
-            # 按类型统计
-            step_type = step.get('step_type', 'unknown')
-            stats['by_type'][step_type] = stats['by_type'].get(step_type, 0) + 1
-
-            # 按状态统计
-            status = step.get('status', 'unknown')
-            stats['by_status'][status] = stats['by_status'].get(status, 0) + 1
-
-            # 累计指标
-            if step.get('duration_ms'):
-                stats['total_duration_ms'] += step.get('duration_ms', 0)
-            if step.get('tokens'):
-                stats['total_tokens'] += step.get('tokens', 0)
-            if step.get('cost'):
-                stats['total_cost'] += step.get('cost', 0.0)
-
-        return stats
-
-    def _build_tree(self, steps: List[Dict[str, Any]]) -> Dict[str, List[str]]:
-        """构建父子关系映射"""
-        # parent_id -> [child_ids]
-        children: Dict[str, List[str]] = {"__root__": []}
-
-        for step in steps:
-            step_id = step.get("step_id", "")
-            parent_id = step.get("parent_id")
-
-            if parent_id is None:
-                children["__root__"].append(step_id)
-            else:
-                if parent_id not in children:
-                    children[parent_id] = []
-                children[parent_id].append(step_id)
-
-        return children
-
-    def _render_tree(
-        self,
-        tree: Dict[str, List[str]],
-        steps: List[Dict[str, Any]],
-        parent_id: str = "__root__",
-        indent: int = 0,
-    ) -> str:
-        """递归渲染树结构"""
-        # step_id -> step 映射
-        step_map = {s.get("step_id"): s for s in steps}
-
-        lines = []
-        child_ids = tree.get(parent_id, [])
-
-        for i, step_id in enumerate(child_ids):
-            step = step_map.get(step_id, {})
-            is_last = i == len(child_ids) - 1
-
-            # 渲染当前节点
-            node_output = self._render_node(step, indent, is_last)
-            lines.append(node_output)
-
-            # 递归渲染子节点
-            if step_id in tree:
-                child_output = self._render_tree(tree, steps, step_id, indent + 1)
-                lines.append(child_output)
-
-        return "\n".join(lines)
-
-    def _render_node(self, step: Dict[str, Any], indent: int, is_last: bool) -> str:
-        """渲染单个节点的完整信息"""
-        lines = []
-
-        # 缩进和连接符
-        prefix = "  " * indent
-        connector = "└── " if is_last else "├── "
-        child_prefix = "  " * indent + ("    " if is_last else "│   ")
-
-        # 状态图标
-        status = step.get("status", "unknown")
-        status_icons = {
-            "completed": "✓",
-            "in_progress": "→",
-            "planned": "○",
-            "failed": "✗",
-            "skipped": "⊘",
-            "awaiting_approval": "⏸",
-        }
-        icon = status_icons.get(status, "?")
-
-        # 类型和描述
-        step_type = step.get("step_type", "unknown")
-        description = step.get("description", "")
-
-        # 第一行:类型和描述
-        lines.append(f"{prefix}{connector}[{icon}] {step_type}: {description}")
-
-        # 详细信息
-        step_id = step.get("step_id", "")[:8]  # 只显示前 8 位
-        lines.append(f"{child_prefix}id: {step_id}...")
-
-        # 关键字段:sequence, status, parent_id
-        sequence = step.get("sequence")
-        if sequence is not None:
-            lines.append(f"{child_prefix}sequence: {sequence}")
-        lines.append(f"{child_prefix}status: {status}")
-
-        parent_id = step.get("parent_id")
-        if parent_id:
-            lines.append(f"{child_prefix}parent_id: {parent_id[:8]}...")
-
-        # 执行指标
-        if step.get("duration_ms") is not None:
-            lines.append(f"{child_prefix}duration: {step.get('duration_ms')}ms")
-        if step.get("tokens") is not None:
-            lines.append(f"{child_prefix}tokens: {step.get('tokens')}")
-        if step.get("cost") is not None:
-            lines.append(f"{child_prefix}cost: ${step.get('cost'):.4f}")
-
-        # summary(如果有)
-        if step.get("summary"):
-            summary = step.get("summary", "")
-            # 截断长 summary
-            if len(summary) > 100:
-                summary = summary[:100] + "..."
-            lines.append(f"{child_prefix}summary: {summary}")
-
-        # 错误信息(结构化显示)
-        error = step.get("error")
-        if error:
-            lines.append(f"{child_prefix}error:")
-            lines.append(f"{child_prefix}  code: {error.get('code', 'UNKNOWN')}")
-            error_msg = error.get('message', '')
-            if len(error_msg) > 200:
-                error_msg = error_msg[:200] + "..."
-            lines.append(f"{child_prefix}  message: {error_msg}")
-            lines.append(f"{child_prefix}  retryable: {error.get('retryable', True)}")
-
-        # data 内容(格式化输出,更激进的截断)
-        data = step.get("data", {})
-        if data:
-            lines.append(f"{child_prefix}data:")
-            data_lines = self._format_data(data, child_prefix + "  ", max_value_len=150)
-            lines.append(data_lines)
-
-        # 时间
-        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"{child_prefix}time: {time_part}")
-
-        lines.append("")  # 空行分隔
-        return "\n".join(lines)
-
-    def _format_data(self, data: Dict[str, Any], prefix: str, max_value_len: int = 150) -> str:
-        """格式化 data 字典(更激进的截断策略)"""
-        lines = []
-
-        for key, value in data.items():
-            # 格式化值
-            if isinstance(value, str):
-                # 检测图片数据
-                if value.startswith("data:image") or (len(value) > 10000 and not "\n" in value[:100]):
-                    lines.append(f"{prefix}{key}: [IMAGE_DATA: {len(value)} chars, truncated]")
-                    continue
-
-                if len(value) > max_value_len:
-                    value_str = value[:max_value_len] + f"... ({len(value)} chars)"
-                else:
-                    value_str = value
-                # 处理多行字符串
-                if "\n" in value_str:
-                    first_line = value_str.split("\n")[0]
-                    line_count = value.count("\n") + 1
-                    value_str = first_line + f"... ({line_count} lines)"
-            elif isinstance(value, (dict, list)):
-                value_str = json.dumps(value, ensure_ascii=False, indent=2)
-                if len(value_str) > max_value_len:
-                    value_str = value_str[:max_value_len] + "..."
-                # 缩进多行
-                value_str = value_str.replace("\n", "\n" + prefix + "  ")
-            else:
-                value_str = str(value)
-
-            lines.append(f"{prefix}{key}: {value_str}")
-
-        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("")
-
-        # 统计摘要
-        if steps:
-            lines.append("## Statistics")
-            lines.append("")
-            stats = self._calculate_statistics(steps)
-            lines.append(f"- **Total steps**: {stats['total']}")
-            lines.append("")
-            lines.append("**By type:**")
-            lines.append("")
-            for step_type, count in sorted(stats['by_type'].items()):
-                lines.append(f"- `{step_type}`: {count}")
-            lines.append("")
-            lines.append("**By status:**")
-            lines.append("")
-            for status, count in sorted(stats['by_status'].items()):
-                lines.append(f"- `{status}`: {count}")
-            lines.append("")
-            if stats['total_duration_ms'] > 0:
-                lines.append(f"- **Total duration**: {stats['total_duration_ms']}ms")
-            if stats['total_tokens'] > 0:
-                lines.append(f"- **Total tokens**: {stats['total_tokens']}")
-            if stats['total_cost'] > 0:
-                lines.append(f"- **Total cost**: ${stats['total_cost']:.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": "⊘",
-            "awaiting_approval": "⏸",
-        }
-        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}...`")
-
-        # 关键字段
-        sequence = step.get("sequence")
-        if sequence is not None:
-            lines.append(f"- **sequence**: {sequence}")
-        lines.append(f"- **status**: {status}")
-
-        parent_id = step.get("parent_id")
-        if parent_id:
-            lines.append(f"- **parent_id**: `{parent_id[:16]}...`")
-
-        # 执行指标
-        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("")
-
-        # 错误信息
-        error = step.get("error")
-        if error:
-            lines.append("<details>")
-            lines.append("<summary><b>❌ Error</b></summary>")
-            lines.append("")
-            lines.append(f"- **code**: `{error.get('code', 'UNKNOWN')}`")
-            lines.append(f"- **retryable**: {error.get('retryable', True)}")
-            lines.append(f"- **message**:")
-            lines.append("```")
-            error_msg = error.get('message', '')
-            if len(error_msg) > 500:
-                error_msg = error_msg[:500] + "..."
-            lines.append(error_msg)
-            lines.append("```")
-            lines.append("")
-            lines.append("</details>")
-            lines.append("")
-
-        # Summary
-        if step.get("summary"):
-            lines.append("<details>")
-            lines.append("<summary><b>📝 Summary</b></summary>")
-            lines.append("")
-            summary = step.get('summary', '')
-            if len(summary) > 1000:
-                summary = summary[:1000] + "..."
-            lines.append(f"```\n{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": "💬",
-            "output": "📤",
-            "arguments": "⚙️",
-        }
-        icon = icon_map.get(key, "📄")
-
-        # 特殊处理:跳过 None 值
-        if value is None:
-            return ""
-
-        # 特殊处理 messages 中的图片引用
-        if key == 'messages' and isinstance(value, list):
-            # 统计图片数量
-            image_count = 0
-            for msg in value:
-                if isinstance(msg, dict):
-                    content = msg.get('content', [])
-                    if isinstance(content, list):
-                        for item in content:
-                            if isinstance(item, dict) and item.get('type') == 'image_url':
-                                url = item.get('image_url', {}).get('url', '')
-                                if url.startswith('blob://'):
-                                    image_count += 1
-
-            if image_count > 0:
-                # 显示图片摘要
-                lines = []
-                lines.append("<details>")
-                lines.append(f"<summary><b>📨 Messages (含 {image_count} 张图片)</b></summary>")
-                lines.append("")
-                lines.append("```json")
-
-                # 渲染消息,图片显示为简化格式
-                simplified_messages = []
-                for msg in value:
-                    if isinstance(msg, dict):
-                        simplified_msg = msg.copy()
-                        content = msg.get('content', [])
-                        if isinstance(content, list):
-                            new_content = []
-                            for item in content:
-                                if isinstance(item, dict) and item.get('type') == 'image_url':
-                                    url = item.get('image_url', {}).get('url', '')
-                                    if url.startswith('blob://'):
-                                        blob_ref = url.replace('blob://', '')
-                                        size = item.get('image_url', {}).get('size', 0)
-                                        size_kb = size / 1024 if size > 0 else 0
-                                        new_content.append({
-                                            'type': 'image_url',
-                                            'image_url': {
-                                                'url': f'[IMAGE: {blob_ref[:8]}... ({size_kb:.1f}KB)]'
-                                            }
-                                        })
-                                    else:
-                                        new_content.append(item)
-                                else:
-                                    new_content.append(item)
-                            simplified_msg['content'] = new_content
-                        simplified_messages.append(simplified_msg)
-                    else:
-                        simplified_messages.append(msg)
-
-                lines.append(json.dumps(simplified_messages, ensure_ascii=False, indent=2))
-                lines.append("```")
-                lines.append("")
-                lines.append("</details>")
-                return "\n".join(lines)
-
-        # 判断是否需要折叠(长内容或复杂结构)
-        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 and not "\n" in value[:100]):
-                    lines.append("```")
-                    lines.append(f"[IMAGE DATA: {len(value)} chars, truncated for display]")
-                    lines.append("```")
-                elif len(value) > 2000:
-                    # 超长文本,只显示前500字符
-                    lines.append("```")
-                    lines.append(value[:500])
-                    lines.append(f"... (truncated, total {len(value)} chars)")
-                    lines.append("```")
-                else:
-                    lines.append("```")
-                    lines.append(value)
-                    lines.append("```")
-            elif isinstance(value, (dict, list)):
-                # 递归截断图片 base64
-                truncated_value = self._truncate_image_data(value)
-                json_str = json.dumps(truncated_value, ensure_ascii=False, indent=2)
-
-                # 如果 JSON 太长,也截断
-                if len(json_str) > 3000:
-                    json_str = json_str[:3000] + "\n... (truncated)"
-
-                lines.append("```json")
-                lines.append(json_str)
-                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)"
-                # 检测 blob 引用
-                elif isinstance(value, str) and value.startswith("blob://"):
-                    blob_ref = value.replace("blob://", "")
-                    result[key] = f"<BLOB_REF: {blob_ref[:8]}...>"
-                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,
-    steps: Optional[List[Any]] = None,
-    output_path: str = DEFAULT_DUMP_PATH,
-    title: str = "Step Tree Debug",
-) -> str:
-    """
-    便捷函数:输出 Step 树到文件
-
-    Args:
-        trace: Trace 对象或字典
-        steps: Step 对象或字典列表
-        output_path: 输出文件路径
-        title: 输出标题
-
-    Returns:
-        输出的文本内容
-
-    示例:
-        from agent.debug import dump_tree
-
-        # 每次 step 变化后调用
-        dump_tree(trace, steps)
-
-        # 自定义路径
-        dump_tree(trace, steps, output_path=".debug/my_trace.txt")
-    """
-    # 转换为字典
-    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(trace_dict, steps_list, title)
-
-
-def dump_json(
-    trace: Optional[Any] = None,
-    steps: Optional[List[Any]] = None,
-    output_path: str = DEFAULT_JSON_PATH,
-) -> str:
-    """
-    输出完整的 JSON 格式(用于程序化分析)
-
-    Args:
-        trace: Trace 对象或字典
-        steps: Step 对象或字典列表
-        output_path: 输出文件路径
-
-    Returns:
-        JSON 字符串
-    """
-    path = Path(output_path)
-    path.parent.mkdir(parents=True, exist_ok=True)
-
-    # 转换为字典
-    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)
-
-    data = {
-        "generated_at": datetime.now().isoformat(),
-        "trace": trace_dict,
-        "steps": steps_list,
-    }
-
-    content = json.dumps(data, ensure_ascii=False, indent=2)
-    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)
+"""Compatibility exports for the relocated trace tree debug helpers."""
+
+from agent.debug.tree_dump import (
+    DEFAULT_DUMP_PATH,
+    DEFAULT_JSON_PATH,
+    DEFAULT_MD_PATH,
+    StepTreeDumper,
+    dump_json,
+    dump_markdown,
+    dump_tree,
+)
+
+__all__ = [
+    "DEFAULT_DUMP_PATH",
+    "DEFAULT_JSON_PATH",
+    "DEFAULT_MD_PATH",
+    "StepTreeDumper",
+    "dump_tree",
+    "dump_json",
+    "dump_markdown",
+]

+ 2 - 2
agent/agent/trace/upload_api.py

@@ -8,7 +8,7 @@ import os
 import shutil
 import tempfile
 import zipfile
-from typing import List, Dict, Any
+from typing import List, Dict
 from fastapi import APIRouter, UploadFile, File, HTTPException
 from pydantic import BaseModel
 
@@ -180,7 +180,7 @@ async def upload_traces(file: UploadFile = File(...)):
         elif imported and failed:
             message = f"Imported {len(imported)} trace(s), {len(failed)} failed"
         elif not imported and failed:
-            message = f"Failed to import all traces"
+            message = "Failed to import all traces"
         else:
             message = "No valid traces found in the zip file"
 

+ 22 - 0
agent/im-client/_framework_import.py

@@ -0,0 +1,22 @@
+"""Load the packaged IM implementation from legacy standalone scripts."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+from pathlib import Path
+from types import ModuleType
+
+
+def load(module_name: str) -> ModuleType:
+    try:
+        return importlib.import_module(module_name)
+    except ModuleNotFoundError as exc:
+        if exc.name != "agent":
+            raise
+        project_root = str(Path(__file__).resolve().parent.parent)
+        sys.path.insert(0, project_root)
+        try:
+            return importlib.import_module(module_name)
+        finally:
+            sys.path.remove(project_root)

+ 6 - 331
agent/im-client/client.py

@@ -1,335 +1,10 @@
-import asyncio
-import json
-import logging
-import os
-import tempfile
-import uuid
-from datetime import datetime
-from pathlib import Path
+"""Compatibility module for the packaged :mod:`agent.im_client.client`."""
 
-import websockets
-from filelock import FileLock
+from _framework_import import load
 
-from protocol import IMMessage, IMResponse
-from notifier import AgentNotifier, ConsoleNotifier
+_module = load("agent.im_client.client")
 
-logging.basicConfig(level=logging.INFO, format="%(asctime)s [CLIENT:%(name)s] %(message)s")
-
-
-class ChatWindow:
-    """单个聊天窗口的数据管理。"""
-
-    def __init__(self, chat_id: str, data_dir: Path):
-        self.chat_id = chat_id
-        self.data_dir = data_dir
-        self.data_dir.mkdir(parents=True, exist_ok=True)
-
-        self.chatbox_path = data_dir / "chatbox.jsonl"
-        self.in_pending_path = data_dir / "in_pending.json"
-        self.out_pending_path = data_dir / "out_pending.jsonl"
-
-        # 文件锁
-        self._in_pending_lock = FileLock(str(data_dir / ".in_pending.lock"))
-        self._out_pending_lock = FileLock(str(data_dir / ".out_pending.lock"))
-        self._chatbox_lock = FileLock(str(data_dir / ".chatbox.lock"))
-
-        # 初始化文件
-        if not self.chatbox_path.exists():
-            self.chatbox_path.write_text("")
-        if not self.in_pending_path.exists():
-            self.in_pending_path.write_text("[]")
-        if not self.out_pending_path.exists():
-            self.out_pending_path.write_text("")
-
-    def append_to_in_pending(self, msg: dict):
-        with self._in_pending_lock:
-            pending = self._load_json_array(self.in_pending_path)
-            pending.append(msg)
-            self._atomic_write_json(self.in_pending_path, pending)
-
-    def read_in_pending(self) -> list[dict]:
-        with self._in_pending_lock:
-            return self._load_json_array(self.in_pending_path)
-
-    def clear_in_pending(self):
-        with self._in_pending_lock:
-            self._atomic_write_json(self.in_pending_path, [])
-
-    def append_to_chatbox(self, msg: dict):
-        with self._chatbox_lock:
-            with open(self.chatbox_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    def append_to_out_pending(self, msg: dict):
-        with self._out_pending_lock:
-            with open(self.out_pending_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    @staticmethod
-    def _load_json_array(path: Path) -> list:
-        if not path.exists():
-            return []
-        text = path.read_text(encoding="utf-8").strip()
-        if not text:
-            return []
-        try:
-            data = json.loads(text)
-            return data if isinstance(data, list) else []
-        except json.JSONDecodeError:
-            return []
-
-    @staticmethod
-    def _atomic_write_json(path: Path, data):
-        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
-        try:
-            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
-                json.dump(data, f, ensure_ascii=False, indent=2)
-            os.replace(tmp_path, str(path))
-        except Exception:
-            if os.path.exists(tmp_path):
-                os.unlink(tmp_path)
-            raise
-
-
-class IMClient:
-    """IM Client - 一个实例管理多个聊天窗口。
-
-    一个 Agent (contact_id) 对应一个 IMClient 实例。
-    该实例可以管理多个 chat_id(窗口),每个窗口有独立的消息存储。
-    """
-
-    def __init__(
-        self,
-        contact_id: str,
-        server_url: str = "ws://localhost:8000",
-        data_dir: str | None = None,
-        notify_interval: float = 30.0,
-    ):
-        self.contact_id = contact_id
-        self.server_url = server_url
-        self.notify_interval = notify_interval
-
-        self.base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
-        self.base_dir.mkdir(parents=True, exist_ok=True)
-
-        # 窗口管理
-        self._windows: dict[str, ChatWindow] = {}
-        self._notifiers: dict[str, AgentNotifier] = {}
-
-        self.ws = None
-        self.log = logging.getLogger(contact_id)
-        self._send_queue = asyncio.Queue()
-
-        # 消息回调:{chat_id: callback} 或 {"*": callback} 匹配所有窗口
-        self._on_message_callbacks: dict[str, callable] = {}
-
-    def open_window(self, chat_id: str | None = None, notifier: AgentNotifier | None = None) -> str:
-        """打开一个新窗口。
-
-        Args:
-            chat_id: 窗口 ID(留空自动生成)
-            notifier: 该窗口的通知器
-
-        Returns:
-            窗口的 chat_id
-        """
-        if chat_id is None:
-            chat_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
-
-        if chat_id in self._windows:
-            return chat_id
-
-        window_dir = self.base_dir / "windows" / chat_id
-        self._windows[chat_id] = ChatWindow(chat_id, window_dir)
-        self._notifiers[chat_id] = notifier or ConsoleNotifier()
-
-        self.log.info(f"打开窗口: {chat_id}")
-        return chat_id
-
-    def close_window(self, chat_id: str):
-        """关闭一个窗口。"""
-        self._windows.pop(chat_id, None)
-        self._notifiers.pop(chat_id, None)
-
-    def on_message(self, callback: callable, chat_id: str = "*"):
-        """注册消息回调。收到消息时立即触发,无需轮询。
-
-        Args:
-            callback: 回调函数,签名 async def callback(msg: dict)
-            chat_id: 监听的窗口 ID,"*" 表示所有窗口
-        """
-        self._on_message_callbacks[chat_id] = callback
-        self.log.info(f"关闭窗口: {chat_id}")
-
-    def list_windows(self) -> list[str]:
-        """列出所有打开的窗口。"""
-        return list(self._windows.keys())
-
-    async def run(self):
-        """启动 Client 服务,自动重连。"""
-        while True:
-            try:
-                # 连接时不带 chat_id,因为一个实例管理多个窗口
-                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id=__multi__"
-                self.log.info(f"连接 {ws_url} ...")
-                async with websockets.connect(ws_url) as ws:
-                    self.ws = ws
-                    self.log.info("已连接")
-                    await asyncio.gather(
-                        self._ws_listener(),
-                        self._send_worker(),
-                        self._pending_notifier(),
-                    )
-            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
-                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
-                self.ws = None
-                await asyncio.sleep(5)
-            except asyncio.CancelledError:
-                self.log.info("服务停止")
-                break
-
-    async def _ws_listener(self):
-        """监听 WebSocket,根据 receiver_chat_id 分发到对应窗口。"""
-        async for raw in self.ws:
-            try:
-                data = json.loads(raw)
-            except json.JSONDecodeError:
-                self.log.warning(f"收到无效 JSON: {raw}")
-                continue
-
-            if "sender" in data and "receiver" in data:
-                # 聊天消息
-                receiver_chat_id = data.get("receiver_chat_id")
-
-                if receiver_chat_id and receiver_chat_id in self._windows:
-                    # 定向发送到指定窗口
-                    window = self._windows[receiver_chat_id]
-                    window.append_to_in_pending(data)
-                    window.append_to_chatbox(data)
-                    self.log.info(f"收到消息 -> 窗口 {receiver_chat_id}: {data['sender']}")
-                    await self._fire_on_message(receiver_chat_id, data)
-                elif not receiver_chat_id:
-                    # 广播到所有窗口
-                    for chat_id, window in self._windows.items():
-                        window.append_to_in_pending(data)
-                        window.append_to_chatbox(data)
-                        await self._fire_on_message(chat_id, data)
-                    self.log.info(f"收到消息 -> 广播到 {len(self._windows)} 个窗口: {data['sender']}")
-                else:
-                    self.log.warning(f"收到消息但窗口 {receiver_chat_id} 不存在")
-
-            elif "status" in data:
-                # 发送回执
-                resp = IMResponse(**data)
-                if resp.status == "success":
-                    self.log.info(f"消息 {resp.msg_id} 发送成功")
-                else:
-                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
-
-    async def _fire_on_message(self, chat_id: str, data: dict):
-        """触发消息回调。"""
-        # 精确匹配
-        cb = self._on_message_callbacks.get(chat_id)
-        if cb is None:
-            # 通配符匹配
-            cb = self._on_message_callbacks.get("*")
-        if cb:
-            try:
-                await cb(data)
-            except Exception as e:
-                self.log.error(f"on_message 回调异常: {e}")
-
-    async def _send_worker(self):
-        """从队列取消息并发送。"""
-        while True:
-            msg_data = await self._send_queue.get()
-            msg = IMMessage(sender=self.contact_id, **msg_data)
-            try:
-                await self.ws.send(msg.model_dump_json())
-                self.log.info(f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}")
-                # 记录到发送方窗口的 chatbox
-                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
-                    self._windows[msg.sender_chat_id].append_to_chatbox(msg.model_dump())
-            except Exception as e:
-                self.log.error(f"发送失败: {e}")
-                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
-                    self._windows[msg.sender_chat_id].append_to_out_pending(msg.model_dump())
-
-    async def _pending_notifier(self):
-        """轮询各窗口的 in_pending,有新消息就调通知回调。"""
-        while True:
-            for chat_id, window in list(self._windows.items()):
-                pending = window.read_in_pending()
-                if pending:
-                    senders = list(set(m.get("sender", "unknown") for m in pending))
-                    count = len(pending)
-                    notifier = self._notifiers.get(chat_id)
-                    if notifier:
-                        try:
-                            await notifier.notify(count=count, from_contacts=senders)
-                        except Exception as e:
-                            self.log.error(f"窗口 {chat_id} 通知回调异常: {e}")
-            await asyncio.sleep(self.notify_interval)
-
-    # ── Agent 调用的工具方法 ──
-
-    def read_pending(self, chat_id: str) -> list[dict]:
-        """读取某个窗口的待处理消息,并清空。"""
-        window = self._windows.get(chat_id)
-        if window is None:
-            return []
-        pending = window.read_in_pending()
-        if pending:
-            window.clear_in_pending()
-        return pending
-
-    def send_message(
-        self,
-        chat_id: str,
-        receiver: str,
-        content: str,
-        msg_type: str = "chat",
-        receiver_chat_id: str | None = None,
-    ):
-        """从某个窗口发送消息。"""
-        msg_data = {
-            "sender_chat_id": chat_id,
-            "receiver": receiver,
-            "content": content,
-            "msg_type": msg_type,
-            "receiver_chat_id": receiver_chat_id,
-        }
-        self._send_queue.put_nowait(msg_data)
-
-    def get_chat_history(self, chat_id: str, peer_id: str | None = None, limit: int = 20) -> list[dict]:
-        """查询某个窗口的聊天历史。"""
-        window = self._windows.get(chat_id)
-        if window is None or not window.chatbox_path.exists():
-            return []
-
-        lines = window.chatbox_path.read_text(encoding="utf-8").strip().splitlines()
-        messages = []
-        for line in reversed(lines):
-            if not line.strip():
-                continue
-            try:
-                m = json.loads(line)
-            except json.JSONDecodeError:
-                continue
-
-            if peer_id and m.get("sender") != peer_id and m.get("receiver") != peer_id:
-                continue
-
-            messages.append({
-                "sender": m.get("sender", "unknown"),
-                "receiver": m.get("receiver", "unknown"),
-                "content": m.get("content", ""),
-                "msg_type": m.get("msg_type", "chat"),
-            })
-
-            if len(messages) >= limit:
-                break
-
-        messages.reverse()
-        return messages
+ChatWindow = _module.ChatWindow
+IMClient = _module.IMClient
 
+__all__ = ["ChatWindow", "IMClient"]

+ 0 - 248
agent/im-client/client.py.bak

@@ -1,248 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-import tempfile
-import uuid
-from datetime import datetime
-from pathlib import Path
-
-import websockets
-from filelock import FileLock
-
-from protocol import IMMessage, IMResponse
-from notifier import AgentNotifier, ConsoleNotifier
-
-logging.basicConfig(level=logging.INFO, format="%(asctime)s [CLIENT:%(name)s] %(message)s")
-
-
-class IMClient:
-    """IM Client 长驻服务。
-
-    通过 WebSocket 连接 Server,通过文件与 Agent 交互。
-
-    文件约定 (data/{contact_id}/):
-        chatbox.jsonl     — 所有消息历史(收发都记录)
-        in_pending.json   — 收到的待处理消息 (JSON 数组)
-        out_pending.jsonl — 发送失败的消息
-
-    窗口模式 (window_mode=True):
-        每次运行生成新的 chat_id,消息按 chat_id 隔离
-        文件结构变为: data/{contact_id}/{chat_id}/...
-    """
-
-    def __init__(
-        self,
-        contact_id: str,
-        server_url: str = "ws://localhost:8000",
-        data_dir: str | None = None,
-        notifier: AgentNotifier | None = None,
-        notify_interval: float = 30.0,
-        window_mode: bool = False,
-        chat_id: str | None = None,
-    ):
-        self.contact_id = contact_id
-        self.server_url = server_url
-        self.notifier = notifier or ConsoleNotifier()
-        self.notify_interval = notify_interval
-        self.window_mode = window_mode
-
-        # 窗口模式:生成或使用指定的 chat_id
-        base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
-        if window_mode:
-            self.chat_id = chat_id or datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
-            self.data_dir = base_dir / "windows" / self.chat_id
-        else:
-            self.chat_id = None
-            self.data_dir = base_dir
-
-        self.data_dir.mkdir(parents=True, exist_ok=True)
-
-        self.chatbox_path = self.data_dir / "chatbox.jsonl"
-        self.in_pending_path = self.data_dir / "in_pending.json"
-        self.out_pending_path = self.data_dir / "out_pending.jsonl"
-
-        # 文件锁
-        self._in_pending_lock = FileLock(str(self.data_dir / ".in_pending.lock"))
-        self._out_pending_lock = FileLock(str(self.data_dir / ".out_pending.lock"))
-        self._chatbox_lock = FileLock(str(self.data_dir / ".chatbox.lock"))
-
-        self.ws = None
-        self.log = logging.getLogger(f"{contact_id}:{self.chat_id}" if self.chat_id else contact_id)
-        self._send_queue = asyncio.Queue()
-
-        # 初始化文件
-        if not self.chatbox_path.exists():
-            self.chatbox_path.write_text("")
-        if not self.in_pending_path.exists():
-            self.in_pending_path.write_text("[]")
-        if not self.out_pending_path.exists():
-            self.out_pending_path.write_text("")
-
-    async def run(self):
-        """启动 Client 服务,自动重连。"""
-        while True:
-            try:
-                # 构造 WebSocket URL,带上 chat_id 参数
-                chat_id_param = self.chat_id or "default"
-                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id={chat_id_param}"
-                self.log.info(f"连接 {ws_url} ...")
-                async with websockets.connect(ws_url) as ws:
-                    self.ws = ws
-                    self.log.info("已连接")
-                    await asyncio.gather(
-                        self._ws_listener(),
-                        self._send_worker(),
-                        self._pending_notifier(),
-                    )
-            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
-                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
-                self.ws = None
-                await asyncio.sleep(5)
-            except asyncio.CancelledError:
-                self.log.info("服务停止")
-                break
-
-    # ── 协程 1: WebSocket 收消息 ──
-
-    async def _ws_listener(self):
-        """监听 WebSocket,聊天消息写 in_pending 和 chatbox,回执打日志。"""
-        async for raw in self.ws:
-            try:
-                data = json.loads(raw)
-            except json.JSONDecodeError:
-                self.log.warning(f"收到无效 JSON: {raw}")
-                continue
-
-            if "sender" in data and "receiver" in data:
-                # 聊天消息
-                self.log.info(f"收到消息: {data['sender']} -> {data['content'][:50]}")
-                self._append_to_in_pending(data)
-                self._append_to_chatbox(data)
-            elif "status" in data:
-                # 发送回执
-                resp = IMResponse(**data)
-                if resp.status == "success":
-                    self.log.info(f"消息 {resp.msg_id} 发送成功")
-                else:
-                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
-
-    # ── 协程 2: 发送队列处理 ──
-
-    async def _send_worker(self):
-        """从队列取消息并发送,失败则写入 out_pending。"""
-        while True:
-            msg_data = await self._send_queue.get()
-            # 填充 sender_chat_id
-            msg = IMMessage(
-                sender=self.contact_id,
-                sender_chat_id=self.chat_id or "default",
-                **msg_data
-            )
-            try:
-                await self.ws.send(msg.model_dump_json())
-                self.log.info(f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}")
-                # 记录到 chatbox
-                self._append_to_chatbox(msg.model_dump())
-            except Exception as e:
-                self.log.error(f"发送失败: {e}")
-                # 写入 out_pending
-                self._append_to_out_pending(msg.model_dump())
-
-    # ── 协程 3: 轮询 in_pending 通知 Agent ──
-
-    async def _pending_notifier(self):
-        """轮询 in_pending.json,有新消息就调通知回调。"""
-        while True:
-            pending = self._read_in_pending()
-            if pending:
-                senders = list(set(m.get("sender", "unknown") for m in pending))
-                count = len(pending)
-                try:
-                    await self.notifier.notify(count=count, from_contacts=senders)
-                except Exception as e:
-                    self.log.error(f"通知回调异常: {e}")
-            await asyncio.sleep(self.notify_interval)
-
-    # ── 文件操作 (原子性) ──
-
-    def _append_to_in_pending(self, msg: dict):
-        """将收到的消息追加到 in_pending.json。"""
-        with self._in_pending_lock:
-            pending = self._load_json_array(self.in_pending_path)
-            pending.append(msg)
-            self._atomic_write_json(self.in_pending_path, pending)
-
-    def _read_in_pending(self) -> list[dict]:
-        """读取 in_pending.json (不清空)。"""
-        with self._in_pending_lock:
-            return self._load_json_array(self.in_pending_path)
-
-    def _append_to_chatbox(self, msg: dict):
-        """追加消息到 chatbox.jsonl。"""
-        with self._chatbox_lock:
-            with open(self.chatbox_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    def _append_to_out_pending(self, msg: dict):
-        """追加发送失败的消息到 out_pending.jsonl。"""
-        with self._out_pending_lock:
-            with open(self.out_pending_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    # ── Agent 调用的工具方法 ──
-
-    def read_pending(self) -> list[dict]:
-        """Agent 读取 in_pending 中的消息,并清空。"""
-        with self._in_pending_lock:
-            pending = self._load_json_array(self.in_pending_path)
-            if not pending:
-                return []
-            # 清空 in_pending
-            self._atomic_write_json(self.in_pending_path, [])
-            return pending
-
-    def send_message(self, receiver: str, content: str, msg_type: str = "chat", receiver_chat_id: str | None = None):
-        """Agent 调用:将消息放入发送队列。
-
-        Args:
-            receiver: 接收方 contact_id
-            content: 消息内容
-            msg_type: 消息类型
-            receiver_chat_id: 接收方窗口 ID(指定则定向发送,否则广播给该 contact_id 的所有窗口)
-        """
-        msg_data = {
-            "receiver": receiver,
-            "content": content,
-            "msg_type": msg_type,
-            "receiver_chat_id": receiver_chat_id
-        }
-        self._send_queue.put_nowait(msg_data)
-
-    # ── 工具方法 ──
-
-    @staticmethod
-    def _load_json_array(path: Path) -> list:
-        if not path.exists():
-            return []
-        text = path.read_text(encoding="utf-8").strip()
-        if not text:
-            return []
-        try:
-            data = json.loads(text)
-            return data if isinstance(data, list) else []
-        except json.JSONDecodeError:
-            return []
-
-    @staticmethod
-    def _atomic_write_json(path: Path, data):
-        """原子写入:先写临时文件再 rename。"""
-        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
-        try:
-            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
-                json.dump(data, f, ensure_ascii=False, indent=2)
-            os.replace(tmp_path, str(path))
-        except Exception:
-            if os.path.exists(tmp_path):
-                os.unlink(tmp_path)
-            raise

+ 6 - 18
agent/im-client/notifier.py

@@ -1,22 +1,10 @@
-import logging
+"""Compatibility module for the packaged :mod:`agent.im_client.notifier`."""
 
-log = logging.getLogger(__name__)
+from _framework_import import load
 
+_module = load("agent.im_client.notifier")
 
-class AgentNotifier:
-    """Agent 通知接口基类。
+AgentNotifier = _module.AgentNotifier
+ConsoleNotifier = _module.ConsoleNotifier
 
-    当 pending.json 有新消息时,Client 会调用 notify()。
-    每个 Agent 可以继承此类实现自己的通知方式。
-    """
-
-    async def notify(self, count: int, from_contacts: list[str]):
-        raise NotImplementedError
-
-
-class ConsoleNotifier(AgentNotifier):
-    """默认实现:打印到控制台。"""
-
-    async def notify(self, count: int, from_contacts: list[str]):
-        sources = ", ".join(from_contacts)
-        log.info(f"[IM 通知] 你有 {count} 条新消息,来自: {sources}")
+__all__ = ["AgentNotifier", "ConsoleNotifier"]

+ 6 - 19
agent/im-client/protocol.py

@@ -1,23 +1,10 @@
-from pydantic import BaseModel
-from typing import Optional
-import uuid
+"""Compatibility module for the packaged :mod:`agent.im_client.protocol`."""
 
+from _framework_import import load
 
-class IMMessage(BaseModel):
-    msg_id: str = ""
-    sender: str
-    receiver: str
-    content: str
-    msg_type: str = "chat"  # chat | image | video | system
-    sender_chat_id: Optional[str] = None  # 发送方窗口 ID
-    receiver_chat_id: Optional[str] = None  # 接收方窗口 ID(指定则定向,否则广播)
+_module = load("agent.im_client.protocol")
 
-    def model_post_init(self, __context):
-        if not self.msg_id:
-            self.msg_id = uuid.uuid4().hex[:12]
+IMMessage = _module.IMMessage
+IMResponse = _module.IMResponse
 
-
-class IMResponse(BaseModel):
-    status: str  # "success" | "failed"
-    msg_id: str
-    error: Optional[str] = None
+__all__ = ["IMMessage", "IMResponse"]

+ 4 - 0
agent/pyproject.toml

@@ -24,6 +24,8 @@ dev = [
 browser = [
     "browser-use>=0.11.0",
     "langchain_core>=0.3.0",
+    "PyMySQL>=1.1.0",
+    "DBUtils>=3.1.0",
 ]
 server = [
     "fastapi>=0.115.0",
@@ -36,6 +38,8 @@ feishu = [
 all = [
     "browser-use>=0.11.0",
     "langchain_core>=0.3.0",
+    "PyMySQL>=1.1.0",
+    "DBUtils>=3.1.0",
     "fastapi>=0.115.0",
     "uvicorn[standard]>=0.32.0",
     "websockets>=13.0",

+ 93 - 0
agent/tests/test_anthropic_protocol.py

@@ -0,0 +1,93 @@
+import base64
+
+from agent.llm import openrouter, yescode
+from agent.llm.anthropic_protocol import (
+    ANTHROPIC_MODEL_EXACT,
+    ANTHROPIC_MODEL_FUZZY,
+    build_anthropic_result,
+    normalize_tool_call_ids,
+    parse_anthropic_response,
+    to_anthropic_messages,
+)
+
+
+def test_provider_model_tables_share_one_source_of_truth():
+    assert yescode.MODEL_EXACT is ANTHROPIC_MODEL_EXACT
+    assert yescode.MODEL_FUZZY is ANTHROPIC_MODEL_FUZZY
+    assert openrouter._OR_MODEL_EXACT is ANTHROPIC_MODEL_EXACT
+    assert openrouter._OR_MODEL_FUZZY is ANTHROPIC_MODEL_FUZZY
+    assert yescode._resolve_model("anthropic/claude-sonnet-4.5") == (
+        "claude-sonnet-4-5-20250929"
+    )
+    assert openrouter._resolve_openrouter_model("claude-sonnet-4.5") == (
+        "anthropic/claude-sonnet-4-5-20250929"
+    )
+
+
+def test_unknown_model_fallback_remains_provider_specific():
+    assert yescode._resolve_model("vendor/new-model") == "new-model"
+    assert openrouter._resolve_openrouter_model("vendor/new-model") == (
+        "vendor/new-model"
+    )
+
+
+def test_tool_call_id_normalization_links_assistant_and_tool_without_mutation():
+    messages = [
+        {
+            "role": "assistant",
+            "tool_calls": [{"id": "call_old", "function": {"name": "read"}}],
+        },
+        {"role": "tool", "tool_call_id": "call_old", "content": "ok"},
+    ]
+
+    normalized = normalize_tool_call_ids(messages, "toolu")
+
+    assert normalized[0]["tool_calls"][0]["id"] == "toolu_000000"
+    assert normalized[1]["tool_call_id"] == "toolu_000000"
+    assert messages[0]["tool_calls"][0]["id"] == "call_old"
+    assert normalize_tool_call_ids(normalized, "toolu") is normalized
+
+
+def test_yescode_and_openrouter_keep_their_image_nesting_contracts():
+    png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + (2).to_bytes(4, "big") + (
+        3
+    ).to_bytes(4, "big")
+    uri = "data:image/png;base64," + base64.b64encode(png).decode()
+    messages = [
+        {
+            "role": "tool",
+            "tool_call_id": "toolu_1",
+            "content": [{"type": "image_url", "image_url": {"url": uri}}],
+        }
+    ]
+
+    _, yescode_messages = yescode._convert_messages_to_anthropic(messages)
+    _, shared_messages = to_anthropic_messages(messages)
+
+    nested = yescode_messages[0]["content"][0]["content"][0]
+    assert nested["type"] == "image"
+    assert "_image_meta" not in nested
+    sibling = shared_messages[0]["content"][1]
+    assert sibling["type"] == "image"
+    assert sibling["_image_meta"] == {"width": 2, "height": 3}
+
+
+def test_anthropic_response_and_result_shape_remain_compatible():
+    parsed = parse_anthropic_response(
+        {
+            "content": [
+                {"type": "text", "text": "done"},
+                {"type": "tool_use", "id": "1", "name": "read", "input": {}},
+            ],
+            "stop_reason": "tool_use",
+            "usage": {"input_tokens": 4, "output_tokens": 2},
+        }
+    )
+    result = build_anthropic_result(parsed, "claude-sonnet-4-6")
+
+    assert result["content"] == "done"
+    assert result["tool_calls"][0]["function"]["name"] == "read"
+    assert result["finish_reason"] == "tool_calls"
+    assert result["prompt_tokens"] == 4
+    assert result["completion_tokens"] == 2
+    assert "cost" in result

+ 166 - 0
agent/tests/test_browser_governance.py

@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import pytest
+
+from agent.tools.builtin.browser import downloads, providers
+
+
+class _FakeResponse:
+    def __init__(self, status_code: int, chunks: tuple[bytes, ...] = ()) -> None:
+        self.status_code = status_code
+        self._chunks = chunks
+
+    async def __aenter__(self) -> "_FakeResponse":
+        return self
+
+    async def __aexit__(self, *_args: object) -> None:
+        return None
+
+    async def aiter_bytes(self):
+        for chunk in self._chunks:
+            yield chunk
+
+
+class _FakeClient:
+    response = _FakeResponse(200)
+
+    def __init__(self, **_kwargs: object) -> None:
+        pass
+
+    async def __aenter__(self) -> "_FakeClient":
+        return self
+
+    async def __aexit__(self, *_args: object) -> None:
+        return None
+
+    def stream(self, _method: str, _url: str) -> _FakeResponse:
+        return self.response
+
+
+class _ContainerProvider:
+    async def create(self, url: str, account_name: str) -> Dict[str, Any]:
+        return {"success": True, "url": url, "account_name": account_name}
+
+
+class _CookieProvider:
+    def get_cookie_row(self, cookie_type: str) -> Optional[Dict[str, Any]]:
+        return {"type": cookie_type, "cookies": "session=test"}
+
+
+@pytest.fixture(autouse=True)
+def _reset_providers() -> None:
+    yield
+    providers.reset_browser_providers()
+
+
+@pytest.mark.asyncio
+async def test_download_accepts_partial_content_and_sanitizes_name(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    _FakeClient.response = _FakeResponse(206, (b"abc", b"def"))
+    monkeypatch.setattr(downloads.httpx, "AsyncClient", _FakeClient)
+
+    result = await downloads.download_direct_url(
+        "https://files.example.invalid/path/report.txt",
+        "../../escaped.txt",
+        download_dir=tmp_path,
+    )
+
+    assert result.error is None
+    assert result.metadata["status_code"] == 206
+    assert result.metadata["bytes"] == 6
+    assert Path(result.metadata["path"]) == tmp_path / "escaped.txt"
+    assert (tmp_path / "escaped.txt").read_bytes() == b"abcdef"
+    assert not (tmp_path / ".escaped.txt.part").exists()
+
+
+@pytest.mark.asyncio
+async def test_download_failure_always_returns_tool_result(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    _FakeClient.response = _FakeResponse(404)
+    monkeypatch.setattr(downloads.httpx, "AsyncClient", _FakeClient)
+    stale_partial = tmp_path / ".missing.bin.part"
+    stale_partial.write_bytes(b"stale")
+
+    result = await downloads.download_direct_url(
+        "https://files.example.invalid/missing.bin",
+        "",
+        download_dir=tmp_path,
+    )
+
+    assert result.error == "HTTP 404"
+    assert result.metadata["status_code"] == 404
+    assert not list(tmp_path.iterdir())
+
+
+@pytest.mark.asyncio
+async def test_download_infers_and_decodes_url_filename(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    _FakeClient.response = _FakeResponse(200, (b"content",))
+    monkeypatch.setattr(downloads.httpx, "AsyncClient", _FakeClient)
+
+    result = await downloads.download_direct_url(
+        "https://files.example.invalid/%E6%8A%A5%E5%91%8A.txt",
+        "",
+        download_dir=tmp_path,
+    )
+
+    assert result.error is None
+    assert Path(result.metadata["path"]).name == "报告.txt"
+    assert (tmp_path / "报告.txt").read_bytes() == b"content"
+
+
+@pytest.mark.asyncio
+async def test_container_provider_normalizes_unexpected_failures(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    class _BrokenClient:
+        def __init__(self, **_kwargs: object) -> None:
+            raise TypeError("malformed service response")
+
+    monkeypatch.setattr(providers.httpx, "AsyncClient", _BrokenClient)
+    provider = providers.HttpContainerProvider(
+        base_url="https://browser.example.invalid",
+        startup_delay=0,
+        retry_delay=0,
+    )
+
+    result = await provider.create("https://example.invalid", "account")
+
+    assert result["success"] is False
+    assert result["error"] == "malformed service response"
+
+
+def test_browser_providers_are_host_replaceable() -> None:
+    container = _ContainerProvider()
+    cookie = _CookieProvider()
+
+    providers.configure_browser_providers(
+        container_provider=container,
+        cookie_provider=cookie,
+    )
+
+    assert providers.get_browser_container_provider() is container
+    assert providers.get_browser_cookie_provider() is cookie
+
+
+def test_base_class_no_longer_knows_host_endpoint_or_cookie_table() -> None:
+    source = (
+        Path(__file__).parents[1]
+        / "agent"
+        / "tools"
+        / "builtin"
+        / "browser"
+        / "baseClass.py"
+    ).read_text(encoding="utf-8")
+
+    assert "47.84.182.56" not in source
+    assert "agent_channel_cookies" not in source

+ 76 - 0
agent/tests/test_builtin_cleanup_compatibility.py

@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+
+def test_legacy_glob_module_reexports_the_canonical_tool() -> None:
+    from agent.tools.builtin.file.glob import LIMIT as canonical_limit
+    from agent.tools.builtin.file.glob import glob_files as canonical_glob
+    from agent.tools.builtin.glob_tool import LIMIT as legacy_limit
+    from agent.tools.builtin.glob_tool import glob_files as legacy_glob
+
+    assert legacy_glob is canonical_glob
+    assert legacy_limit == canonical_limit == 100
+    assert canonical_glob.__module__ == "agent.tools.builtin.file.glob"
+
+
+def test_trace_tree_dump_imports_remain_compatible() -> None:
+    from agent.debug.tree_dump import dump_tree as canonical_dump_tree
+    from agent.trace import dump_tree as package_dump_tree
+    from agent.trace.tree_dump import DEFAULT_DUMP_PATH, dump_tree as legacy_dump_tree
+
+    assert legacy_dump_tree is canonical_dump_tree
+    assert package_dump_tree is canonical_dump_tree
+    assert DEFAULT_DUMP_PATH == ".trace/tree.txt"
+
+
+@pytest.mark.asyncio
+async def test_canonical_glob_still_lists_matching_files(tmp_path: Path) -> None:
+    from agent.tools.builtin.file.glob import glob_files
+
+    expected = tmp_path / "example.py"
+    expected.write_text("pass\n", encoding="utf-8")
+    (tmp_path / "ignored.txt").write_text("ignored\n", encoding="utf-8")
+
+    result = await glob_files("*.py", path=str(tmp_path))
+
+    assert result.error is None
+    assert result.metadata["count"] == 1
+    assert result.output == str(expected)
+
+
+def test_browser_download_uses_stdlib_url_decode_and_time() -> None:
+    source_path = (
+        Path(__file__).parents[1]
+        / "agent"
+        / "tools"
+        / "builtin"
+        / "browser"
+        / "downloads.py"
+    )
+    tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
+
+    imported_names = {
+        alias.asname or alias.name
+        for node in tree.body
+        if isinstance(node, ast.Import)
+        for alias in node.names
+    }
+    urllib_imports = {
+        alias.asname or alias.name
+        for node in tree.body
+        if isinstance(node, ast.ImportFrom) and node.module == "urllib.parse"
+        for alias in node.names
+    }
+
+    assert "time" in imported_names
+    assert "unquote" in urllib_imports
+    assert "unquote" not in {
+        alias.name
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Import)
+        for alias in node.names
+    }

+ 107 - 0
agent/tests/test_content_collage.py

@@ -0,0 +1,107 @@
+from PIL import Image
+import pytest
+
+from agent.tools.builtin.content import collage
+from agent.tools.builtin.content.platforms import aigc_channel, x, youtube
+
+
+@pytest.mark.asyncio
+async def test_collage_filters_failed_images_and_keeps_matching_labels(monkeypatch):
+    image = Image.new("RGB", (4, 4), "white")
+    captured = {}
+
+    async def fake_load(urls):
+        return [(urls[0], image), (urls[1], None)]
+
+    def fake_grid(*, images, labels):
+        captured["images"] = images
+        captured["labels"] = labels
+        return image
+
+    async def fake_upload(image_bytes, filename):
+        captured["filename"] = filename
+        return "https://cdn.example/collage.png"
+
+    monkeypatch.setattr(collage, "load_images", fake_load)
+    monkeypatch.setattr(collage, "build_image_grid", fake_grid)
+    monkeypatch.setattr(collage, "_upload_bytes", fake_upload)
+
+    result = await collage.render_image_collage(
+        ["one", "two"], labels=["first", "second"], filename_prefix="test"
+    )
+
+    assert result == {"type": "url", "url": "https://cdn.example/collage.png"}
+    assert captured["images"] == [image]
+    assert captured["labels"] == ["first"]
+    assert captured["filename"].startswith("test_")
+
+
+@pytest.mark.asyncio
+async def test_collage_falls_back_to_base64_when_upload_fails(monkeypatch):
+    image = Image.new("RGB", (2, 2), "white")
+
+    async def fake_load(urls):
+        return [(urls[0], image)]
+
+    async def failed_upload(image_bytes, filename):
+        raise RuntimeError("offline")
+
+    monkeypatch.setattr(collage, "load_images", fake_load)
+    monkeypatch.setattr(collage, "build_image_grid", lambda **kwargs: image)
+    monkeypatch.setattr(collage, "_upload_bytes", failed_upload)
+
+    result = await collage.render_image_collage(["one"])
+
+    assert result["type"] == "base64"
+    assert result["media_type"] == "image/png"
+    assert result["data"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("adapter", "args", "expected_prefix"),
+    [
+        (aigc_channel._build_images_collage, (["u"],), "collage_detail"),
+        (x._build_images_collage, (["u"],), "x_detail_collage"),
+    ],
+)
+async def test_detail_adapters_delegate_to_shared_collage(
+    monkeypatch, adapter, args, expected_prefix
+):
+    module = __import__(adapter.__module__, fromlist=["render_image_collage"])
+    captured = {}
+
+    async def fake_render(urls, **kwargs):
+        captured.update(kwargs)
+        return {"type": "url", "url": "ok"}
+
+    monkeypatch.setattr(module, "render_image_collage", fake_render)
+
+    assert await adapter(*args) == {"type": "url", "url": "ok"}
+    assert captured["filename_prefix"] == expected_prefix
+
+
+@pytest.mark.asyncio
+async def test_search_adapters_keep_platform_selection_rules(monkeypatch):
+    calls = []
+
+    async def fake_render(urls, **kwargs):
+        calls.append((urls, kwargs))
+        return {"type": "url", "url": "ok"}
+
+    monkeypatch.setattr(x, "render_image_collage", fake_render)
+    monkeypatch.setattr(youtube, "render_image_collage", fake_render)
+
+    await x._build_tweet_collage(
+        [{"image_url_list": [{"image_url": "x.jpg"}], "channel_account_name": "a"}]
+    )
+    await youtube._build_video_collage(
+        [{"thumbnails": [{"url": "yt.jpg"}], "title": "video"}]
+    )
+
+    assert calls[0][0] == ["x.jpg"]
+    assert calls[0][1]["labels"] == ["@a"]
+    assert calls[0][1]["filename_prefix"] == "x_collage"
+    assert calls[1][0] == ["yt.jpg"]
+    assert calls[1][1]["labels"] == ["video"]
+    assert calls[1][1]["filename_prefix"] == "youtube_collage"

+ 105 - 0
agent/tests/test_governance_public_contract.py

@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import inspect
+from pathlib import Path
+
+from agent.core.runner import AgentRunner
+from agent.orchestration.coordinator import TaskCoordinator
+from agent.trace.store import FileSystemTraceStore
+from agent.tools.builtin import browser
+from agent.tools.builtin.browser import baseClass
+from agent.tools.builtin.feishu.feishu_agent import FeishuAgent, execute_tool
+
+
+def _parameter_names(callable_obj) -> tuple[str, ...]:
+    return tuple(inspect.signature(callable_obj).parameters)
+
+
+def test_runner_and_coordinator_public_call_shapes_are_frozen() -> None:
+    assert _parameter_names(AgentRunner) == (
+        "trace_store",
+        "tool_registry",
+        "llm_call",
+        "utility_llm_call",
+        "skills_dir",
+        "goal_tree",
+        "debug",
+        "logger_name",
+        "task_coordinator",
+        "tool_policy",
+    )
+    assert _parameter_names(AgentRunner.run) == (
+        "self",
+        "messages",
+        "config",
+        "inject_skills",
+        "skill_recency_threshold",
+    )
+    assert _parameter_names(AgentRunner.run_result) == (
+        "self",
+        "messages",
+        "config",
+        "on_event",
+        "inject_skills",
+    )
+    assert _parameter_names(TaskCoordinator) == (
+        "task_store",
+        "artifact_store",
+        "trace_store",
+        "config",
+        "event_sink",
+        "executor",
+        "validation_policy",
+        "deterministic_validator",
+        "evidence_provider",
+    )
+    assert _parameter_names(TaskCoordinator.decide_task) == (
+        "self",
+        "root_trace_id",
+        "task_id",
+        "validation_id",
+        "action",
+        "payload",
+        "idempotency_key",
+    )
+
+
+def test_trace_store_public_state_and_constructor_remain_stable(tmp_path) -> None:
+    assert str(inspect.signature(FileSystemTraceStore)) == "(base_path: str = '.trace')"
+    store = FileSystemTraceStore(str(tmp_path))
+    assert isinstance(store.base_path, Path)
+    assert store.base_path == tmp_path
+
+
+def test_legacy_browser_package_exports_keep_object_identity() -> None:
+    legacy_exports = (
+        "init_browser_session",
+        "get_browser_session",
+        "get_browser_live_url",
+        "cleanup_browser_session",
+        "kill_browser_session",
+        "browser_navigate",
+        "browser_search",
+        "browser_back",
+        "browser_interact",
+        "browser_scroll",
+        "browser_screenshot",
+        "browser_elements",
+        "browser_read",
+        "browser_extract",
+        "browser_tabs",
+        "browser_cookies",
+        "browser_wait",
+        "browser_js",
+        "browser_download",
+    )
+
+    for name in legacy_exports:
+        assert getattr(browser, name) is getattr(baseClass, name)
+
+
+def test_feishu_agent_legacy_entrypoints_keep_call_shapes() -> None:
+    assert str(inspect.signature(FeishuAgent)) == "()"
+    assert _parameter_names(FeishuAgent.handle_message) == ("self", "event")
+    assert _parameter_names(FeishuAgent.start) == ("self",)
+    assert _parameter_names(execute_tool) == ("name", "arguments")

+ 53 - 0
agent/tests/test_import_side_effects.py

@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).parents[1]
+
+
+def _run_isolated(code: str) -> dict:
+    result = subprocess.run(
+        [sys.executable, "-c", code],
+        cwd=PROJECT_ROOT,
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+    return json.loads(result.stdout)
+
+
+def test_import_agent_does_not_configure_logging_or_mutate_sys_path() -> None:
+    result = _run_isolated(
+        """
+import json
+import logging
+import sys
+
+handlers_before = tuple(logging.getLogger().handlers)
+path_before = tuple(sys.path)
+import agent
+print(json.dumps({
+    "handlers_unchanged": tuple(logging.getLogger().handlers) == handlers_before,
+    "path_unchanged": tuple(sys.path) == path_before,
+}))
+"""
+    )
+
+    assert result == {"handlers_unchanged": True, "path_unchanged": True}
+
+
+def test_packaged_im_client_is_the_builtin_runtime() -> None:
+    result = _run_isolated(
+        """
+import json
+from agent.im_client import IMClient
+from agent.tools.builtin.im.chat import IMClient as BuiltinIMClient
+print(json.dumps({"same_client": IMClient is BuiltinIMClient}))
+"""
+    )
+
+    assert result == {"same_client": True}

+ 0 - 1
agent/tests/test_legacy_feishu_credentials.py

@@ -8,7 +8,6 @@ import pytest
 
 _FEISHU_FILES = (
     "chat.py",
-    "chat_test.py",
     "feishu_client.py",
 )
 _CREDENTIAL_NAMES = {"FEISHU_APP_ID", "FEISHU_APP_SECRET"}

+ 166 - 0
agent/tests/test_runner_components.py

@@ -0,0 +1,166 @@
+import pytest
+
+from agent import AgentRunner as PublicAgentRunner
+from agent import RunConfig as PublicRunConfig
+from agent.core.runner import AgentRunner, RunConfig
+from agent.tools.registry import ToolRegistry
+from agent.trace.models import Trace
+
+
+def test_runner_public_imports_remain_stable():
+    assert PublicAgentRunner is AgentRunner
+    assert PublicRunConfig is RunConfig
+
+
+@pytest.mark.asyncio
+async def test_prompt_runtime_preserves_host_prompt_and_iteration_constraint(tmp_path):
+    runner = AgentRunner(skills_dir=str(tmp_path))
+
+    prompt = await runner._build_system_prompt(
+        RunConfig(max_iterations=7),
+        base_prompt="host contract",
+    )
+
+    assert prompt.startswith("host contract\n\n## Execution Constraint")
+    assert "最多可以用 7 轮交互" in prompt
+
+
+@pytest.mark.asyncio
+async def test_task_name_runtime_uses_current_utility_model_callback():
+    runner = AgentRunner()
+
+    async def utility_llm_call(**_kwargs):
+        return {"content": "stable title"}
+
+    # Preserve the historical ability to configure this dependency after
+    # construction; the internal component must not capture a stale value.
+    runner.utility_llm_call = utility_llm_call
+
+    assert (
+        await runner._generate_task_name([{"role": "user", "content": "task"}])
+        == "stable title"
+    )
+
+
+@pytest.mark.asyncio
+async def test_task_name_runtime_keeps_text_fallback_on_utility_failure():
+    async def failing_utility(**_kwargs):
+        raise RuntimeError("unavailable")
+
+    runner = AgentRunner(utility_llm_call=failing_utility)
+    text = "x" * 60
+
+    assert await runner._generate_task_name([{"role": "user", "content": text}]) == (
+        "x" * 50 + "..."
+    )
+
+
+@pytest.mark.asyncio
+async def test_tool_runtime_keeps_runner_override_hooks() -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        return "ok"
+
+    registry.register(
+        sample,
+        schema={
+            "type": "function",
+            "function": {
+                "name": "sample",
+                "description": "sample",
+                "parameters": {"type": "object", "properties": {}},
+            },
+        },
+    )
+
+    class ExtensibleRunner(AgentRunner):
+        policy_calls = 0
+        context_calls = 0
+        schema_calls = 0
+
+        def _resolve_run_policy(self, config, trace=None):
+            self.policy_calls += 1
+            return super()._resolve_run_policy(config, trace)
+
+        def _build_protected_tool_context(
+            self,
+            config,
+            trace,
+            goal_tree,
+            sequence,
+            side_branch=None,
+        ):
+            self.context_calls += 1
+            return super()._build_protected_tool_context(
+                config,
+                trace,
+                goal_tree,
+                sequence,
+                side_branch=side_branch,
+            )
+
+        def _get_tool_schemas(
+            self,
+            tools=None,
+            tool_groups=None,
+            exclude_tools=None,
+        ):
+            self.schema_calls += 1
+            return super()._get_tool_schemas(
+                tools,
+                tool_groups,
+                exclude_tools,
+            )
+
+    runner = ExtensibleRunner(tool_registry=registry)
+    runner._get_run_tool_schemas(RunConfig(tools=["sample"], tool_groups=[]))
+    result = await runner._execute_authorized_tool(
+        "sample",
+        {},
+        "call-1",
+        RunConfig(tools=["sample"], tool_groups=[]),
+        Trace(trace_id="legacy", mode="agent", agent_role="legacy"),
+        None,
+        1,
+    )
+
+    assert result == "ok"
+    assert runner.policy_calls == 2
+    assert runner.context_calls == 1
+    assert runner.schema_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_image_runtime_keeps_runner_override_hooks() -> None:
+    class ExtensibleRunner(AgentRunner):
+        image_calls = 0
+
+        async def _process_image_size(
+            self,
+            base64_url,
+            max_size=512,
+            min_size=11,
+        ):
+            self.image_calls += 1
+            return "data:image/jpeg;base64,processed"
+
+    runner = ExtensibleRunner()
+    messages = [
+        {
+            "role": "tool",
+            "content": [
+                {
+                    "type": "image_url",
+                    "image_url": {"url": "data:image/png;base64,source"},
+                }
+            ],
+        },
+        {"role": "assistant", "content": "observed"},
+    ]
+
+    optimized = await runner._optimize_images(messages, "gpt-4o")
+
+    assert runner.image_calls == 1
+    assert optimized[0]["content"][0]["image_url"]["url"].endswith("processed")
+    assert messages[0]["content"][0]["image_url"]["url"].endswith("source")

+ 17 - 0
agent/tests/test_shared_diff_utils.py

@@ -0,0 +1,17 @@
+from agent.tools.builtin.file import edit, write
+from agent.tools.builtin.file.diff_utils import create_unified_diff
+
+
+def test_file_tools_share_one_diff_implementation():
+    assert edit._create_diff is create_unified_diff
+    assert write._create_diff is create_unified_diff
+
+
+def test_unified_diff_contract_is_stable():
+    diff = create_unified_diff("demo.txt", "old\n", "new\n")
+
+    assert "--- a/demo.txt" in diff
+    assert "+++ b/demo.txt" in diff
+    assert "-old" in diff
+    assert "+new" in diff
+    assert create_unified_diff("demo.txt", "same", "same") == "(无变更)"

+ 311 - 0
agent/tests/test_tool_registry_contract.py

@@ -0,0 +1,311 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from agent.tools.models import ToolCapability
+from agent.tools.registry import ToolRegistry, get_tool_registry, tool
+
+
+def _schema(name: str) -> dict:
+    return {
+        "type": "function",
+        "function": {
+            "name": name,
+            "description": name,
+            "parameters": {"type": "object", "properties": {}},
+        },
+    }
+
+
+@pytest.mark.asyncio
+async def test_registering_the_same_tool_twice_is_idempotent() -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        return "ok"
+
+    registry.register(
+        sample,
+        schema=_schema("sample"),
+        groups=["test"],
+        capabilities=[ToolCapability.READ],
+    )
+    assert await registry.execute("sample", {}) == "ok"
+
+    registry.register(
+        sample,
+        schema=_schema("sample"),
+        groups=["test"],
+        capabilities=[ToolCapability.READ],
+    )
+
+    assert registry.get_tool_names() == ["sample"]
+    assert registry.get_stats("sample")["sample"]["call_count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_same_factory_definition_rebinds_a_fresh_host_adapter() -> None:
+    registry = ToolRegistry()
+
+    class Gateway:
+        def __init__(self, value: str) -> None:
+            self.value = value
+
+    def make_tool(gateway: Gateway):
+        async def host_tool() -> str:
+            return gateway.value
+
+        return host_tool
+
+    registry.register(
+        make_tool(Gateway("first")),
+        schema=_schema("host_tool"),
+        capabilities=[ToolCapability.READ],
+    )
+    assert await registry.execute("host_tool", {}) == "first"
+
+    registry.register(
+        make_tool(Gateway("second")),
+        schema=_schema("host_tool"),
+        capabilities=[ToolCapability.READ],
+    )
+
+    assert await registry.execute("host_tool", {}) == "second"
+    assert registry.get_stats("host_tool")["host_tool"]["call_count"] == 2
+
+
+def test_same_name_from_a_different_callable_is_rejected() -> None:
+    registry = ToolRegistry()
+
+    async def original() -> str:
+        return "original"
+
+    async def replacement() -> str:
+        return "replacement"
+
+    replacement.__name__ = original.__name__
+    registry.register(original, schema=_schema("original"))
+
+    with pytest.raises(
+        ValueError,
+        match=r"Duplicate tool registration for 'original'.*different callable",
+    ) as exc_info:
+        registry.register(replacement, schema=_schema("original"))
+
+    message = str(exc_info.value)
+    assert "original" in message
+    assert "replacement" in message
+    assert registry._tools["original"]["func"] is original
+
+
+def test_reregistering_a_tool_with_different_metadata_is_rejected() -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        return "ok"
+
+    registry.register(sample, schema=_schema("sample"), groups=["read"])
+
+    with pytest.raises(
+        ValueError,
+        match=r"Duplicate tool registration for 'sample'.*metadata differs: groups",
+    ):
+        registry.register(sample, schema=_schema("sample"), groups=["write"])
+
+
+@pytest.mark.asyncio
+async def test_legacy_execute_inject_values_are_defaults_not_overrides() -> None:
+    registry = ToolRegistry()
+
+    async def sample(value: str) -> str:
+        return value
+
+    registry.register(sample, schema=_schema("sample"))
+
+    assert await registry.execute(
+        "sample",
+        {},
+        inject_values={"value": "injected", "unknown": "ignored"},
+    ) == "injected"
+    assert await registry.execute(
+        "sample",
+        {"value": "explicit"},
+        inject_values={"value": "injected"},
+    ) == "explicit"
+
+
+@pytest.mark.asyncio
+async def test_host_can_replace_a_capability_matched_submission_protocol() -> None:
+    registry = ToolRegistry()
+
+    async def submit_attempt() -> str:
+        return "framework"
+
+    submit_attempt.__module__ = "agent.tools.builtin.example"
+
+    registry.register(
+        submit_attempt,
+        schema=_schema("submit_attempt"),
+        groups=["orchestration_worker"],
+        capabilities=[ToolCapability.ATTEMPT_SUBMIT],
+    )
+    assert await registry.execute("submit_attempt", {}) == "framework"
+
+    async def submit_attempt() -> str:  # noqa: F811
+        return "host"
+
+    submit_attempt.__module__ = "host.tools"
+
+    registry.register(
+        submit_attempt,
+        schema={
+            **_schema("submit_attempt"),
+            "x-host-contract": True,
+        },
+        groups=["host"],
+        capabilities=[ToolCapability.ATTEMPT_SUBMIT],
+    )
+
+    assert await registry.execute("submit_attempt", {}) == "host"
+    assert registry.get_stats("submit_attempt")["submit_attempt"]["call_count"] == 2
+
+    async def competing_host_tool() -> str:
+        return "competing host"
+
+    competing_host_tool.__name__ = "submit_attempt"
+    competing_host_tool.__module__ = "another_host.tools"
+    with pytest.raises(ValueError, match="different callable"):
+        registry.register(
+            competing_host_tool,
+            schema=_schema("submit_attempt"),
+            groups=["another_host"],
+            capabilities=[ToolCapability.ATTEMPT_SUBMIT],
+        )
+
+
+def test_host_cannot_replace_a_builtin_with_different_capabilities() -> None:
+    registry = ToolRegistry()
+
+    async def read_contract() -> str:
+        return "framework"
+
+    read_contract.__module__ = "agent.tools.builtin.example"
+    registry.register(
+        read_contract,
+        schema=_schema("read_contract"),
+        capabilities=[ToolCapability.READ],
+    )
+
+    async def read_contract() -> str:  # noqa: F811
+        return "host"
+
+    read_contract.__module__ = "host.tools"
+    with pytest.raises(ValueError, match="different callable"):
+        registry.register(
+            read_contract,
+            schema=_schema("read_contract"),
+            capabilities=[ToolCapability.WRITE],
+        )
+
+
+def test_builtin_tool_registration_contract() -> None:
+    """Guard the security-relevant metadata without snapshotting optional tools."""
+    registry = get_tool_registry()
+    schemas = registry.get_schemas()
+    schema_names = [schema["function"]["name"] for schema in schemas]
+
+    assert len(schema_names) == len(set(schema_names))
+    assert set(schema_names) == set(registry.get_tool_names())
+
+    expected = {
+        "read_file": (
+            "agent.tools.builtin.file.read",
+            {"core"},
+            {ToolCapability.READ},
+        ),
+        "write_file": (
+            "agent.tools.builtin.file.write",
+            {"core"},
+            {ToolCapability.WRITE},
+        ),
+        "glob_files": (
+            "agent.tools.builtin.file.glob",
+            {"core"},
+            {ToolCapability.READ},
+        ),
+        "bash_command": (
+            "agent.tools.builtin.bash",
+            {"system"},
+            {ToolCapability.WRITE},
+        ),
+        "agent": (
+            "agent.tools.builtin.subagent",
+            {"core"},
+            {ToolCapability.AGENT_SPAWN},
+        ),
+        "task_plan": (
+            "agent.tools.builtin.orchestration",
+            {"orchestration_planner"},
+            {ToolCapability.TASK_CONTROL},
+        ),
+        "submit_attempt": (
+            "agent.tools.builtin.orchestration",
+            {"orchestration_worker"},
+            {ToolCapability.ATTEMPT_SUBMIT},
+        ),
+        "submit_validation": (
+            "agent.tools.builtin.orchestration",
+            {"orchestration_validator"},
+            {ToolCapability.VALIDATION_SUBMIT},
+        ),
+    }
+
+    actual = {
+        name: (
+            registry._tools[name]["func"].__module__,
+            set(registry._tools[name]["groups"]),
+            set(registry.get_capabilities(name)),
+        )
+        for name in expected
+    }
+    assert actual == expected, json.dumps(
+        {
+            name: {
+                "module": module,
+                "groups": sorted(groups),
+                "capabilities": sorted(map(str, capabilities)),
+            }
+            for name, (module, groups, capabilities) in actual.items()
+        },
+        ensure_ascii=False,
+        indent=2,
+    )
+
+
+def test_tool_decorator_applies_explicit_schema_descriptions() -> None:
+    registry = get_tool_registry()
+
+    try:
+        @tool(
+            description="Explicit tool description",
+            param_descriptions={"value": "Explicit value description"},
+        )
+        async def governance_description_probe(value: str) -> str:
+            """Docstring description.
+
+            Args:
+                value: Docstring value description.
+            """
+            return value
+
+        schema = registry.get_schemas(["governance_description_probe"])[0]["function"]
+        assert schema["description"] == "Explicit tool description"
+        assert (
+            schema["parameters"]["properties"]["value"]["description"]
+            == "Explicit value description"
+        )
+    finally:
+        registry._tools.pop("governance_description_probe", None)
+        registry._stats.pop("governance_description_probe", None)

+ 77 - 0
agent/tests/test_trace_cognition_store.py

@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from agent.trace.models import Trace
+from agent.trace.store import FileSystemTraceStore
+
+
+@pytest.mark.asyncio
+async def test_cognition_facade_preserves_new_event_flow(tmp_path) -> None:
+    store = FileSystemTraceStore(str(tmp_path))
+    await store.create_trace(Trace(trace_id="trace-new", mode="agent"))
+
+    await store.append_cognition_event(
+        "trace-new",
+        {
+            "type": "query",
+            "sequence": 4,
+            "goal_id": "goal-1",
+            "source_ids": ["knowledge-1"],
+            "sources": [
+                {
+                    "id": "knowledge-1",
+                    "task": "task",
+                    "content": "content",
+                }
+            ],
+        },
+    )
+
+    pending = await store.get_pending_knowledge_entries("trace-new")
+    assert [item["knowledge_id"] for item in pending] == ["knowledge-1"]
+
+    await store.update_knowledge_evaluation(
+        "trace-new",
+        "knowledge-1",
+        {"eval_status": "helpful", "reason": "used"},
+        "goal_complete",
+    )
+
+    assert await store.get_pending_knowledge_entries("trace-new") == []
+    compatibility_log = await store.get_knowledge_log("trace-new")
+    assert compatibility_log["entries"] is compatibility_log["events"]
+
+
+@pytest.mark.asyncio
+async def test_cognition_facade_preserves_legacy_log_update(tmp_path) -> None:
+    store = FileSystemTraceStore(str(tmp_path))
+    await store.create_trace(Trace(trace_id="trace-legacy", mode="agent"))
+    legacy_file = tmp_path / "trace-legacy" / "knowledge_log.json"
+    legacy_file.write_text(
+        json.dumps(
+            {
+                "trace_id": "trace-legacy",
+                "entries": [
+                    {
+                        "knowledge_id": "knowledge-legacy",
+                        "injected_at_sequence": 2,
+                        "eval_result": None,
+                    }
+                ],
+            }
+        ),
+        encoding="utf-8",
+    )
+
+    await store.update_knowledge_evaluation(
+        "trace-legacy",
+        "knowledge-legacy",
+        {"eval_status": "unused", "reason": "not needed"},
+        "goal_complete",
+    )
+
+    persisted = json.loads(legacy_file.read_text(encoding="utf-8"))
+    assert persisted["entries"][0]["eval_result"]["eval_status"] == "unused"

Некоторые файлы не были показаны из-за большого количества измененных файлов