Browse Source

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

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

冲突处理保留 ValidationPlan.preflight_rule_ids 与确定性预检短路;Coordinator 使用拆分后的 DecisionEngine、TaskGraph、GoalProjection,同时维持现有公开调用形状和 Host 工具替换合同。治理变更严格限定在 agent/,未纳入工作区中已有的 script_build_host 改动。
SamLee 1 ngày trước cách đây
mục cha
commit
c68d06ffd8
90 tập tin đã thay đổi với 6386 bổ sung4591 xóa
  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 json
 import sys
 import sys
 from pathlib import Path
 from pathlib import Path
-from typing import List, Optional
+from typing import Optional
 
 
 from agent.trace.store import FileSystemTraceStore
 from agent.trace.store import FileSystemTraceStore
 from agent.trace.extraction_review import (
 from agent.trace.extraction_review import (

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

@@ -4,8 +4,8 @@
 提供暂停/继续、交互式菜单、经验总结等功能。
 提供暂停/继续、交互式菜单、经验总结等功能。
 """
 """
 
 
+import logging
 import sys
 import sys
-import asyncio
 from typing import Optional, Dict, Any
 from typing import Optional, Dict, Any
 from pathlib import Path
 from pathlib import Path
 
 
@@ -13,6 +13,8 @@ from agent.core.runner import AgentRunner
 from agent.trace import TraceStore
 from agent.trace import TraceStore
 from agent.trace.models import Message, Trace
 from agent.trace.models import Message, Trace
 
 
+logger = logging.getLogger(__name__)
+
 
 
 # ===== 非阻塞 stdin 检测 =====
 # ===== 非阻塞 stdin 检测 =====
 
 
@@ -43,8 +45,8 @@ def check_stdin() -> Optional[str]:
                 return 'pause'
                 return 'pause'
             if cmd in ('q', 'quit'):
             if cmd in ('q', 'quit'):
                 return 'quit'
                 return 'quit'
-        except Exception:
-            pass
+        except (OSError, UnicodeError) as exc:
+            logger.debug("Failed to read agent control file: %s", exc)
 
 
     # 2. 检查终端/管道输入
     # 2. 检查终端/管道输入
     if sys.platform == 'win32':
     if sys.platform == 'win32':
@@ -68,8 +70,8 @@ def check_stdin() -> Optional[str]:
                         return 'pause'
                         return 'pause'
                     if line in ('q', 'quit'):
                     if line in ('q', 'quit'):
                         return 'quit'
                         return 'quit'
-            except Exception:
-                pass
+            except (OSError, ValueError) as exc:
+                logger.debug("Failed to poll stdin: %s", exc)
         return None
         return None
     else:
     else:
         # Unix/Mac: 使用 select(支持终端和管道)
         # Unix/Mac: 使用 select(支持终端和管道)
@@ -191,7 +193,7 @@ class InteractiveController:
                     print("未输入任何内容,取消操作")
                     print("未输入任何内容,取消操作")
                     continue
                     continue
 
 
-                print(f"\n将插入干预消息并继续执行...")
+                print("\n将插入干预消息并继续执行...")
                 # 从 store 读取实际的 last_sequence
                 # 从 store 读取实际的 last_sequence
                 live_trace = await self.store.get_trace(trace_id)
                 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
                 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 = []
             messages_to_add = []
             if intervention_msg:
             if intervention_msg:
                 messages_to_add.append({"role": "user", "content": intervention_msg})
                 messages_to_add.append({"role": "user", "content": intervention_msg})
-                print(f"\n✓ 将插入干预消息")
+                print("\n✓ 将插入干预消息")
 
 
             # 调用 runner.run() 续跑
             # 调用 runner.run() 续跑
             print("\n开始执行...")
             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_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)
     user_content = _build_dream_input(reflections, existing_files, memory_config)
     prompt = memory_config.dream_prompt or DEFAULT_DREAM_PROMPT
     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 glob as _glob
 import logging
 import logging
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, List, Optional, Tuple
 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
 import asyncio
@@ -48,28 +39,22 @@ from agent.trace.compaction import (
     estimate_tokens,
     estimate_tokens,
 )
 )
 from agent.skill.models import Skill
 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 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.memory import MemoryConfig
-from agent.core.presets import get_preset
 from agent.orchestration.models import AgentRole, CompletionPolicy
 from agent.orchestration.models import AgentRole, CompletionPolicy
 from agent.orchestration.policy import (
 from agent.orchestration.policy import (
     DefaultToolPolicy,
     DefaultToolPolicy,
     ResolvedAgentPolicy,
     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 (
 from agent.core.prompts import (
-    DEFAULT_SYSTEM_PREFIX,
     TRUNCATION_HINT,
     TRUNCATION_HINT,
     AGENT_INTERRUPTED_SUMMARY,
     AGENT_INTERRUPTED_SUMMARY,
-    TASK_NAME_GENERATION_SYSTEM_PROMPT,
-    TASK_NAME_FALLBACK,
     build_tool_interrupted_message,
     build_tool_interrupted_message,
     build_agent_continue_hint,
     build_agent_continue_hint,
-    ROLE_CONTRACTS,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -197,8 +182,7 @@ class RunConfig:
     # None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
     # None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
     memory: Optional["MemoryConfig"] = None
     memory: Optional["MemoryConfig"] = None
 
 
-    # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
-    # 工具可用性现在由 @tool(groups=[...]) 声明 + RunConfig.tool_groups 过滤控制。
+    # Tool availability is resolved from registry groups and role policy.
 
 
 
 
 @dataclass
 @dataclass
@@ -281,12 +265,17 @@ class AgentRunner:
         # 图片优化缓存(避免重复处理)
         # 图片优化缓存(避免重复处理)
         # key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
         # key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
         self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
         self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
+        self._image_runtime = RunnerImageRuntime(self)
 
 
         # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
         # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
         # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
         # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
         self._current_memory_config: Optional[MemoryConfig] = None
         self._current_memory_config: Optional[MemoryConfig] = None
         self.task_coordinator = task_coordinator
         self.task_coordinator = task_coordinator
         self.tool_policy = tool_policy or DefaultToolPolicy()
         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")
             await broadcast_trace_status_changed(config.trace_id, "running")
         except Exception:
         except Exception:
-            pass
+            self.log.debug("Trace status broadcast failed", exc_info=True)
 
 
         return trace_obj, goal_tree, sequence
         return trace_obj, goal_tree, sequence
 
 
@@ -1279,7 +1268,7 @@ class AgentRunner:
 
 
                         await broadcast_trace_status_changed(trace_id, "stopped")
                         await broadcast_trace_status_changed(trace_id, "stopped")
                     except Exception:
                     except Exception:
-                        pass
+                        self.log.debug("Stopped status broadcast failed", exc_info=True)
                     trace_obj = await self.trace_store.get_trace(trace_id)
                     trace_obj = await self.trace_store.get_trace(trace_id)
                     if trace_obj:
                     if trace_obj:
                         yield trace_obj
                         yield trace_obj
@@ -2301,8 +2290,11 @@ class AgentRunner:
                                         }
                                         }
                                     )
                                     )
                             img_count = len(tool_content_for_llm) - 1  # 减去 text 块
                             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:
                         else:
                             tool_result_text = tool_text
                             tool_result_text = tool_text
@@ -3184,645 +3176,47 @@ class AgentRunner:
     # ===== 辅助方法 =====
     # ===== 辅助方法 =====
 
 
     async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
     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(
     async def _process_image_size(
         self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
         self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
     ) -> Optional[str]:
     ) -> 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(
     async def _generate_image_description(
         self, image_url: str, current_model: str
         self, image_url: str, current_model: str
     ) -> 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(
     def _add_cache_control(
         self, messages: List[Dict], model: str, enable: bool
         self, messages: List[Dict], model: str, enable: bool
     ) -> List[Dict]:
     ) -> 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(
     def _resolve_run_policy(
         self,
         self,
         config: RunConfig,
         config: RunConfig,
         trace: Optional[Trace] = None,
         trace: Optional[Trace] = None,
     ) -> ResolvedAgentPolicy:
     ) -> 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(
     def _get_run_tool_schemas(
         self,
         self,
         config: RunConfig,
         config: RunConfig,
         policy: Optional[ResolvedAgentPolicy] = None,
         policy: Optional[ResolvedAgentPolicy] = None,
     ) -> List[Dict]:
     ) -> 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(
     def _build_protected_tool_context(
         self,
         self,
@@ -3832,31 +3226,16 @@ class AgentRunner:
         sequence: int,
         sequence: int,
         side_branch: Optional[Dict[str, Any]] = None,
         side_branch: Optional[Dict[str, Any]] = None,
     ) -> Dict[str, Any]:
     ) -> 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(
     async def _execute_authorized_tool(
         self,
         self,
@@ -3869,26 +3248,17 @@ class AgentRunner:
         sequence: int,
         sequence: int,
         side_branch: Optional[Dict[str, Any]] = None,
         side_branch: Optional[Dict[str, Any]] = None,
     ) -> Any:
     ) -> 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_name,
             tool_args,
             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(
     def _get_tool_schemas(
@@ -3897,28 +3267,11 @@ class AgentRunner:
         tool_groups: Optional[List[str]] = None,
         tool_groups: Optional[List[str]] = None,
         exclude_tools: Optional[List[str]] = None,
         exclude_tools: Optional[List[str]] = None,
     ) -> List[Dict]:
     ) -> 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(
     async def _build_system_prompt(
         self, config: RunConfig, base_prompt: Optional[str] = None
         self, config: RunConfig, base_prompt: Optional[str] = None
@@ -3940,108 +3293,20 @@ class AgentRunner:
             base_prompt: 已有 system 内容(来自消息),
             base_prompt: 已有 system 内容(来自消息),
                          None 时使用 config.system_prompt 或 preset.system_prompt
                          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:
     async def _generate_task_name(self, messages: List[Dict]) -> str:
         """生成任务名称:优先使用 utility_llm,fallback 到文本截取"""
         """生成任务名称:优先使用 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:
     def _format_skills(self, skills: List[Skill]) -> str:
         if not skills:
         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
 import httpx
 from typing import List, Dict, Any, Optional
 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__)
 logger = logging.getLogger(__name__)
@@ -65,10 +64,8 @@ async def anthropic_native_llm_call(
 
 
     # 记录使用的配置(只在第一次调用时输出)
     # 记录使用的配置(只在第一次调用时输出)
     if not hasattr(anthropic_native_llm_call, '_logged_config'):
     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_native_llm_call._logged_config = True
 
 
     anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
     anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
@@ -114,17 +111,7 @@ async def anthropic_native_llm_call(
 
 
     # Debug: 检查 cache_control 是否存在
     # Debug: 检查 cache_control 是否存在
     if logger.isEnabledFor(logging.DEBUG):
     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:
         if cache_control_count > 0:
             logger.debug(f"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
             logger.debug(f"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
 
 
@@ -160,7 +147,6 @@ async def anthropic_native_llm_call(
                     last_exception = e
                     last_exception = e
                     continue
                     continue
                 logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
                 logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
-                print(f"[Anthropic Native] API Error {status}: {error_body[:500]}")
                 raise
                 raise
                 
                 
             except _RETRYABLE_EXCEPTIONS as e:
             except _RETRYABLE_EXCEPTIONS as e:
@@ -174,23 +160,7 @@ async def anthropic_native_llm_call(
     else:
     else:
         raise last_exception  # type: ignore[misc]
         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"):
 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 os
 import json
 import json
-import sys
+import logging
 import httpx
 import httpx
 from typing import List, Dict, Any, Optional
 from typing import List, Dict, Any, Optional
 
 
@@ -16,6 +16,9 @@ from .usage import TokenUsage
 from .pricing import calculate_cost
 from .pricing import calculate_cost
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
 def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
     """
     """
     Dump完整的LLM请求用于调试(需要设置 AGENT_DEBUG=1)
     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)
         "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]]:
 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", "")
             content_text = msg.get("content", "")
 
 
             if not tool_name:
             if not tool_name:
-                print(f"[WARNING] Tool message missing 'name' field, skipping")
+                logger.warning("Gemini tool message missing name; skipping")
                 continue
                 continue
 
 
             # 尝试解析为 JSON
             # 尝试解析为 JSON
@@ -197,7 +198,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
                                 }
                                 }
                             })
                             })
                         except Exception as e:
                         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:
             if parts:
                 gemini_role = "model" if role == "assistant" else "user"
                 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)
         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"
         endpoint = f"{base_url}/models/{model}:generateContent"
@@ -372,17 +377,22 @@ def create_gemini_llm_call(
 
 
         except httpx.HTTPStatusError as e:
         except httpx.HTTPStatusError as e:
             error_body = e.response.text
             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
             raise
         except Exception as e:
         except Exception as e:
-            print(f"[Gemini HTTP] Request failed: {e}")
+            logger.error("[Gemini HTTP] Request failed: %s", e)
             raise
             raise
 
 
         # Debug: 输出原始响应(如果启用)
         # Debug: 输出原始响应(如果启用)
         if os.getenv("AGENT_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 = ""
         content = ""
@@ -411,7 +421,10 @@ def create_gemini_llm_call(
                 # Gemini 返回了格式错误的函数调用
                 # Gemini 返回了格式错误的函数调用
                 # 提取 finishMessage 中的内容作为 content
                 # 提取 finishMessage 中的内容作为 content
                 finish_message = candidate.get("finishMessage", "")
                 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}"
                 content = f"[模型尝试调用工具但格式错误]\n\n{finish_message}"
             else:
             else:
                 # 正常解析
                 # 正常解析

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

@@ -15,64 +15,37 @@ OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 
 """
 """
 
 
 import os
 import os
-import json
 import asyncio
 import asyncio
 import logging
 import logging
 import httpx
 import httpx
 from pathlib import Path
 from pathlib import Path
 from typing import List, Dict, Any, Optional
 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
 from .pricing import calculate_cost
 
 
 logger = logging.getLogger(__name__)
 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 ──────────────────────
 # ── OpenRouter Anthropic endpoint: model name mapping ──────────────────────
 # Local copy of yescode's model tables so this module is self-contained.
 # 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:
 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,
     Strips ``anthropic/`` prefix, resolves aliases / dot-notation,
     and re-prepends ``anthropic/`` for OpenRouter routing.
     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:
 def _sanitize_schema_name(title: str) -> str:
@@ -212,237 +146,14 @@ def _ensure_strict_schema(schema: Dict) -> Dict:
     result.pop("$schema", None)
     result.pop("$schema", None)
 
 
     return result
     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 ─────────────────────────────────────
 # ── 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(
 async def _openrouter_anthropic_call(
     messages: List[Dict[str, Any]],
     messages: List[Dict[str, Any]],
     model: str,
     model: str,
@@ -586,7 +253,6 @@ async def _openrouter_anthropic_call(
                     _img_count += 1
                     _img_count += 1
     if _img_count:
     if _img_count:
         logger.info("[OpenRouter/Anthropic] payload contains %d image block(s)", _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] = {
     payload: Dict[str, Any] = {
         "model": resolved_model,
         "model": resolved_model,
@@ -616,17 +282,7 @@ async def _openrouter_anthropic_call(
 
 
     # Debug: 检查 cache_control 是否存在
     # Debug: 检查 cache_control 是否存在
     if logger.isEnabledFor(logging.DEBUG):
     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:
         if cache_control_count > 0:
             logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
             logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
 
 
@@ -660,9 +316,7 @@ async def _openrouter_anthropic_call(
                     await asyncio.sleep(wait)
                     await asyncio.sleep(wait)
                     last_exception = e
                     last_exception = e
                     continue
                     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)
                 logger.error("[OpenRouter/Anthropic] HTTP %d error body: %s", status, error_body)
-                print(f"[OpenRouter/Anthropic] API Error {status}: {error_body[:500]}")
                 raise
                 raise
 
 
             except _RETRYABLE_EXCEPTIONS as e:
             except _RETRYABLE_EXCEPTIONS as e:
@@ -679,23 +333,7 @@ async def _openrouter_anthropic_call(
     else:
     else:
         raise last_exception  # type: ignore[misc]
         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]]:
 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 os
 import re
 import re
-from dataclasses import dataclass, field
+import logging
+from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, Any, Optional, List
 from typing import Dict, Any, Optional, List
 import yaml
 import yaml
@@ -20,6 +21,9 @@ import yaml
 from .usage import TokenUsage
 from .usage import TokenUsage
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 @dataclass
 @dataclass
 class ModelPricing:
 class ModelPricing:
     """
     """
@@ -170,7 +174,7 @@ class PricingCalculator:
 
 
         for path in search_paths:
         for path in search_paths:
             if path.exists():
             if path.exists():
-                print(f"[Pricing] Loaded config from: {path}")
+                logger.info("Pricing config loaded from %s", path)
                 return str(path)
                 return str(path)
 
 
         return None
         return None
@@ -201,7 +205,8 @@ class PricingCalculator:
         """
         """
         内置默认定价表
         内置默认定价表
 
 
-        价格来源:各提供商官网(2024-12 更新)
+        这些值仅是无外部配置时的兼容兜底;生产环境应通过
+        ``AGENT_PRICING_CONFIG`` 提供经过校验的实时价格。
         单位:美元 / 1M tokens
         单位:美元 / 1M tokens
         """
         """
         return [
         return [

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

@@ -6,7 +6,7 @@ Prompt Wrapper - 为 .prompt 文件提供 Prompt 实现
 
 
 import base64
 import base64
 from pathlib import Path
 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
 from agent.llm.prompts.loader import load_prompt, get_message
 
 
 
 

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

@@ -13,9 +13,8 @@ Token Usage 数据模型和费用计算
 - PricingCalculator: 策略模式,根据定价表计算费用
 - 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)
 @dataclass(frozen=True)
@@ -82,8 +81,6 @@ class TokenUsage:
 
 
         这里返回等效的全价 tokens 数
         这里返回等效的全价 tokens 数
         """
         """
-        # 普通输入 = 总输入 - 缓存读取
-        regular_input = self.input_tokens - self.cache_read_tokens
         # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
         # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
         # 简化:返回原始值,让 PricingCalculator 处理
         # 简化:返回原始值,让 PricingCalculator 处理
         return self.input_tokens
         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 asyncio
 import logging
 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:
 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:
 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(
 async def yescode_llm_call(
     messages: List[Dict[str, Any]],
     messages: List[Dict[str, Any]],
     model: str = "claude-sonnet-4.5",
     model: str = "claude-sonnet-4.5",
     tools: Optional[List[Dict]] = None,
     tools: Optional[List[Dict]] = None,
-    **kwargs
+    **kwargs,
 ) -> Dict[str, Any]:
 ) -> 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")
     base_url = os.getenv("YESCODE_BASE_URL")
     api_key = os.getenv("YESCODE_API_KEY")
     api_key = os.getenv("YESCODE_API_KEY")
-
     if not base_url:
     if not base_url:
         raise ValueError("YESCODE_BASE_URL environment variable not set")
         raise ValueError("YESCODE_BASE_URL environment variable not set")
     if not api_key:
     if not api_key:
         raise ValueError("YESCODE_API_KEY environment variable not set")
         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)
     api_model = _resolve_model(model)
-
-    # 跨 Provider 续跑时,重写不兼容的 tool_call_id
     messages = _normalize_tool_call_ids(messages, "toolu")
     messages = _normalize_tool_call_ids(messages, "toolu")
-
-    # 转换消息格式
     system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
     system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
 
 
-    # 构建 Anthropic 格式请求
     payload = {
     payload = {
         "model": api_model,
         "model": api_model,
         "messages": anthropic_messages,
         "messages": anthropic_messages,
         "max_tokens": kwargs.get("max_tokens", 16384),
         "max_tokens": kwargs.get("max_tokens", 16384),
     }
     }
-
     if system_prompt:
     if system_prompt:
         payload["system"] = system_prompt
         payload["system"] = system_prompt
-
     if tools:
     if tools:
         payload["tools"] = _convert_tools_to_anthropic(tools)
         payload["tools"] = _convert_tools_to_anthropic(tools)
-
     if "temperature" in kwargs:
     if "temperature" in kwargs:
         payload["temperature"] = kwargs["temperature"]
         payload["temperature"] = kwargs["temperature"]
 
 
@@ -397,7 +96,6 @@ async def yescode_llm_call(
         "user-agent": "claude-code/1.0.0",
         "user-agent": "claude-code/1.0.0",
     }
     }
 
 
-    # 调用 API(带重试)
     max_retries = 5
     max_retries = 5
     last_exception = None
     last_exception = None
     for attempt in range(max_retries):
     for attempt in range(max_retries):
@@ -407,81 +105,63 @@ async def yescode_llm_call(
                 response.raise_for_status()
                 response.raise_for_status()
                 result = response.json()
                 result = response.json()
                 break
                 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(
                     logger.warning(
                         "[Yescode] HTTP %d (attempt %d/%d), retrying in %ds: %s",
                         "[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)
                     await asyncio.sleep(wait)
-                    last_exception = e
+                    last_exception = exc
                     continue
                     continue
                 logger.error("[Yescode] Error %d: %s", status, error_body)
                 logger.error("[Yescode] Error %d: %s", status, error_body)
-                print(f"[Yescode] API Error {status}: {error_body[:500]}")
                 raise
                 raise
-
-            except _RETRYABLE_EXCEPTIONS as e:
-                last_exception = e
+            except _RETRYABLE_EXCEPTIONS as exc:
+                last_exception = exc
                 if attempt < max_retries - 1:
                 if attempt < max_retries - 1:
-                    wait = 2 ** attempt * 2
+                    wait = 2**attempt * 2
                     logger.warning(
                     logger.warning(
                         "[Yescode] %s (attempt %d/%d), retrying in %ds",
                         "[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)
                     await asyncio.sleep(wait)
                     continue
                     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
                 raise
-
-            except Exception as e:
-                logger.error("[Yescode] Request failed: %s", e)
+            except Exception as exc:
+                logger.error("[Yescode] Request failed: %s", exc)
                 raise
                 raise
     else:
     else:
         raise last_exception  # type: ignore[misc]
         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(
     async def llm_call(
         messages: List[Dict[str, Any]],
         messages: List[Dict[str, Any]],
         model: str = model,
         model: str = model,
         tools: Optional[List[Dict]] = None,
         tools: Optional[List[Dict]] = None,
-        **kwargs
+        **kwargs,
     ) -> Dict[str, Any]:
     ) -> Dict[str, Any]:
         return await yescode_llm_call(messages, model, tools, **kwargs)
         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",
+]

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 286 - 285
agent/agent/orchestration/coordinator.py


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

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

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

@@ -785,10 +785,12 @@ def stage_timeout(
 def _failure_stats(
 def _failure_stats(
     stats: Optional[ExecutionStats],
     stats: Optional[ExecutionStats],
     fallback: FailureCode,
     fallback: FailureCode,
+    *,
+    override: bool = False,
 ) -> ExecutionStats:
 ) -> ExecutionStats:
     if stats is None:
     if stats is None:
         return ExecutionStats(failure_code=fallback)
         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 stats
     return replace(stats, failure_code=fallback)
     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 pathlib import Path
 from typing import List, Dict, Optional
 from typing import List, Dict, Optional
 import logging
 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 import read_file
 from agent.tools.builtin.file.read_images import read_images
 from agent.tools.builtin.file.read_images import read_images
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.write import write_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.file.grep import grep_content
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.subagent import agent, evaluate
 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)
 # Memory / Dream(见 agent/docs/memory.md)
 from agent.tools.builtin.memory import dream
 from agent.tools.builtin.memory import dream
 # 知识上传/查询已统一到 agent 工具:
 # 知识上传/查询已统一到 agent 工具:
@@ -40,18 +42,14 @@ from agent.tools.builtin.orchestration import (
     submit_attempt,
     submit_attempt,
     submit_validation,
     submit_validation,
 )
 )
-# 导入浏览器工具以触发注册 (因 P1 流水线不需要,且加载缓慢,暂时全局屏蔽)
+# Browser tools are opt-in because importing browser-use is comparatively heavy.
 # import agent.tools.builtin.browser  # noqa: F401
 # import agent.tools.builtin.browser  # noqa: F401
 
 
-try:
+if importlib.util.find_spec("lark_oapi") is not None:
     import agent.tools.builtin.feishu
     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
     import agent.tools.builtin.im
-except ImportError:
-    pass  # optional IM/server dependencies
 
 
 __all__ = [
 __all__ = [
     # 文件操作
     # 文件操作
@@ -64,18 +62,10 @@ __all__ = [
     # 系统工具
     # 系统工具
     "bash_command",
     "bash_command",
     "skill",
     "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",
     "list_skills",
     "agent",
     "agent",
     "evaluate",
     "evaluate",
-    # 内容工具族(重构自 search.py + crawler.py)
+    # 内容工具族
     "content_platforms",
     "content_platforms",
     "content_search",
     "content_search",
     "content_detail",
     "content_detail",
@@ -103,6 +93,6 @@ __all__ = [
     "submit_attempt",
     "submit_attempt",
     "submit_validation",
     "submit_validation",
     # Memory & Knowledge 提取审核
     # 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 原生工具适配器
 
 
 基于 browser-use 实现的浏览器自动化工具集。
 基于 browser-use 实现的浏览器自动化工具集。
-28 个原始工具已合并为 14 个语义化入口(2026-04 重构)
+对外保留稳定的语义化工具入口
 """
 """
 
 
 from agent.tools.builtin.browser.baseClass import (
 from agent.tools.builtin.browser.baseClass import (
@@ -29,6 +29,12 @@ from agent.tools.builtin.browser.baseClass import (
     browser_js,
     browser_js,
     browser_download,
     browser_download,
 )
 )
+from agent.tools.builtin.browser.providers import (
+    BrowserContainerProvider,
+    BrowserCookieProvider,
+    configure_browser_providers,
+    reset_browser_providers,
+)
 
 
 __all__ = [
 __all__ = [
     # 会话管理
     # 会话管理
@@ -37,6 +43,10 @@ __all__ = [
     'get_browser_live_url',
     'get_browser_live_url',
     'cleanup_browser_session',
     'cleanup_browser_session',
     'kill_browser_session',
     'kill_browser_session',
+    'BrowserContainerProvider',
+    'BrowserCookieProvider',
+    'configure_browser_providers',
+    'reset_browser_providers',
 
 
     # @tool 入口
     # @tool 入口
     'browser_navigate',
     'browser_navigate',

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

@@ -43,35 +43,29 @@ Native Browser-Use Tools Adapter
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
 """
 """
 import logging
 import logging
-import sys
-import os
 import json
 import json
-import httpx
 import asyncio
 import asyncio
-import aiohttp
 import re
 import re
 import base64
 import base64
 from urllib.parse import urlparse, parse_qs, unquote
 from urllib.parse import urlparse, parse_qs, unquote
 from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from pathlib import Path
 from pathlib import Path
 from langchain_core.runnables import RunnableLambda
 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
 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 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 的核心类
 # 导入 browser-use 的核心类
 from browser_use import BrowserSession, BrowserProfile
 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
 from browser_use.tools.service import Tools
+
 try:
 try:
     from browser_use.tools.views import ReadContentAction  # type: ignore
     from browser_use.tools.views import ReadContentAction  # type: ignore
 except Exception:
 except Exception:
@@ -81,8 +75,9 @@ except Exception:
         goal: str
         goal: str
         source: str = "page"
         source: str = "page"
         context: str = ""
         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
 _live_url: Optional[str] = None
 
 
 async def create_container(url: str, account_name: str = "liuwenwu") -> Dict[str, Any]:
 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(
 async def init_browser_session(
     browser_type: str = "local",
     browser_type: str = "local",
@@ -260,8 +135,9 @@ async def init_browser_session(
     }
     }
 
 
     if browser_type == "container":
     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)
         container_info = await create_container(url=url, account_name=profile_name)
         if not container_info["success"]:
         if not container_info["success"]:
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
@@ -269,13 +145,13 @@ async def init_browser_session(
         await asyncio.sleep(3)
         await asyncio.sleep(3)
 
 
     elif browser_type == "cloud":
     elif browser_type == "cloud":
-        print("🌐 使用云浏览器模式")
+        logger.info("Initializing cloud browser session")
         session_params["use_cloud"] = True
         session_params["use_cloud"] = True
         if profile_name and profile_name != "default":
         if profile_name and profile_name != "default":
             session_params["cloud_profile_id"] = profile_name
             session_params["cloud_profile_id"] = profile_name
 
 
     else:  # local
     else:  # local
-        print("💻 使用本地浏览器模式")
+        logger.info("Initializing local browser session")
         session_params["is_local"] = True
         session_params["is_local"] = True
         if user_data_dir is None and profile_name:
         if user_data_dir is None and profile_name:
             user_data_dir = str(Path.home() / ".browser_use" / "profiles" / 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()
     _browser_tools = Tools()
     _file_system = FileSystem(base_dir=str(save_dir))
     _file_system = FileSystem(base_dir=str(save_dir))
 
 
-    print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
+    logger.info("Browser session initialized: downloads=%s", save_dir)
 
 
     # 云浏览器:捕获 live URL
     # 云浏览器:捕获 live URL
     if browser_type == "cloud":
     if browser_type == "cloud":
@@ -314,7 +190,7 @@ async def init_browser_session(
             parsed = urllib.parse.urlparse(cdp_url)
             parsed = urllib.parse.urlparse(cdp_url)
             host_url = f"https://{parsed.hostname}"
             host_url = f"https://{parsed.hostname}"
             _live_url = f"https://live.browser-use.com?wss={urllib.parse.quote(host_url)}"
             _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:
     if browser_type in ["local", "cloud"] and url:
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
@@ -355,10 +231,10 @@ async def get_browser_session() -> tuple[BrowserSession, Tools]:
                 )
                 )
                 alive = True
                 alive = True
         except Exception:
         except Exception:
-            pass
+            logger.debug("Browser CDP health check failed", exc_info=True)
 
 
         if not alive:
         if not alive:
-            print("⚠️ 浏览器会话连接已断开,正在重新初始化...")
+            logger.warning("Browser session disconnected; reinitializing")
             try:
             try:
                 await cleanup_browser_session()
                 await cleanup_browser_session()
             except Exception:
             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]:
 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 "")
     parsed = urlparse(url or "")
     domain = parsed.netloc or ""
     domain = parsed.netloc or ""
     domain = domain.replace("www.", "")
     domain = domain.replace("www.", "")
@@ -516,27 +390,9 @@ def _fetch_cookie_row(cookie_type: str) -> Optional[Dict[str, Any]]:
     if not cookie_type:
     if not cookie_type:
         return None
         return None
     try:
     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
         return None
 
 
 
 
@@ -725,71 +581,11 @@ class DownloadLinkCaptureHandler(logging.Handler):
                 # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
                 # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
                 if "..." not in url:
                 if "..." not in url:
                     self.captured_url = url
                     self.captured_url = url
-                    # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
 
 
 async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
 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:
 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="输入失败",
             title="输入失败",
             output="",
             output="",
             error=f"Failed to input text into element {index}: {str(e)}",
             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)
                 return unquote(found)
                 
                 
     except Exception:
     except Exception:
-        pass # 解析失败则返回原链接
+        logger.debug("Search redirect URL parsing failed", exc_info=True)
     
     
     return url
     return url
 
 
@@ -1387,7 +1183,6 @@ async def extraction_adapter(input_data):
         if clean_url != original_url:
         if clean_url != original_url:
             content = content.replace(original_url, clean_url)
             content = content.replace(original_url, clean_url)
     
     
-    from argparse import Namespace
     return Namespace(completion=content)
     return Namespace(completion=content)
 
 
 async def browser_extract_content(query: str, extract_links: bool = False,
 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', '')
                 content_type = ct_result.get('result', {}).get('value', '')
                 is_pdf = 'pdf' in content_type.lower()
                 is_pdf = 'pdf' in content_type.lower()
             except Exception:
             except Exception:
-                pass
+                logger.debug("Browser PDF content-type probe failed", exc_info=True)
 
 
         if not is_pdf:
         if not is_pdf:
             return None
             return None
@@ -1497,12 +1292,12 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
 
 
         value = result.get('result', {}).get('value', '')
         value = result.get('result', {}).get('value', '')
         if not value:
         if not value:
-            print("⚠️ CDP fetch PDF: 无返回值")
+            logger.warning("CDP PDF fetch returned no value")
             return None
             return None
 
 
         data = json.loads(value)
         data = json.loads(value)
         if 'error' in data:
         if 'error' in data:
-            print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
+            logger.warning("CDP PDF fetch failed: %s", data["error"])
             return None
             return None
 
 
         # 从 data URL 中提取 base64 并解码
         # 从 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:
         with open(save_path, 'wb') as f:
             f.write(pdf_bytes)
             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
         return save_path
 
 
     except Exception as e:
     except Exception as e:
-        print(f"⚠️ PDF 检测/下载异常: {e}")
+        logger.warning("CDP PDF detection/download failed: %s", e)
         return None
         return None
 
 
 
 
@@ -1885,12 +1680,12 @@ async def browser_wait_for_user_action(message: str = "Please complete the actio
         import asyncio
         import asyncio
 
 
         print(f"\n{'='*60}")
         print(f"\n{'='*60}")
-        print(f"⏸️  WAITING FOR USER ACTION")
+        print("⏸️  WAITING FOR USER ACTION")
         print(f"{'='*60}")
         print(f"{'='*60}")
         print(f"📝 {message}")
         print(f"📝 {message}")
         print(f"⏱️  Timeout: {timeout} seconds")
         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")
         print(f"{'='*60}\n")
 
 
         # Wait for user input or timeout
         # 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
         url: 文件 URL
         save_name: 保存文件名(可选,默认自动推断)
         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',
     'cleanup_browser_session',
     'kill_browser_session',
     'kill_browser_session',
 
 
-    # 13 个 @tool 入口
+    # 14 个 @tool 入口
     'browser_navigate',
     'browser_navigate',
     'browser_search',
     'browser_search',
     'browser_back',
     '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 json
+import logging
 import os
 import os
 import time
 import time
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
 
 
+logger = logging.getLogger(__name__)
+
 # CWD 锚定 —— 每个调用项目有独立缓存目录,避免 /tmp 跨会话污染
 # CWD 锚定 —— 每个调用项目有独立缓存目录,避免 /tmp 跨会话污染
 _CACHE_DIR = Path(os.getcwd()) / ".cache" / "content_search"
 _CACHE_DIR = Path(os.getcwd()) / ".cache" / "content_search"
 _CACHE_DIR.mkdir(parents=True, exist_ok=True)
 _CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -34,7 +37,8 @@ def _load_raw(trace_id: str) -> dict:
             p.unlink(missing_ok=True)
             p.unlink(missing_ok=True)
             return {}
             return {}
         return data
         return data
-    except Exception:
+    except (OSError, json.JSONDecodeError, TypeError) as exc:
+        logger.debug("Failed to read content cache %s: %s", p, exc)
         return {}
         return {}
 
 
 
 
@@ -44,8 +48,8 @@ def _save_raw(trace_id: str, data: dict) -> None:
         _cache_path(trace_id).write_text(
         _cache_path(trace_id).write_text(
             json.dumps(data, ensure_ascii=False), encoding="utf-8"
             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(
 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 asyncio
 import json
 import json
+import logging
 import subprocess
 import subprocess
 import tempfile
 import tempfile
 from pathlib import Path
 from pathlib import Path
@@ -14,6 +15,8 @@ from typing import Dict, List, Optional
 
 
 from agent.tools import tool, ToolResult
 from agent.tools import tool, ToolResult
 
 
+logger = logging.getLogger(__name__)
+
 VIDEO_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "youtube_videos"
 VIDEO_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "youtube_videos"
 VIDEO_DOWNLOAD_DIR.mkdir(exist_ok=True)
 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():
         if result.returncode == 0 and output_path.exists():
             return str(output_path)
             return str(output_path)
         return None
         return None
-    except Exception:
+    except (OSError, subprocess.SubprocessError) as exc:
+        logger.warning("YouTube video download failed for %s: %s", video_id, exc)
         return None
         return None
 
 
 
 

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

@@ -6,6 +6,7 @@ AIGC-Channel 平台实现(9 个中文平台)
 """
 """
 
 
 import json
 import json
+import logging
 import re
 import re
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
 
 
@@ -13,11 +14,13 @@ import httpx
 
 
 from agent.tools.models import ToolResult
 from agent.tools.models import ToolResult
 from agent.tools.builtin.content.quality import get_post_evaluator
 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 (
 from agent.tools.builtin.content.registry import (
     PlatformDef, ParamSpec, register_platform,
     PlatformDef, ParamSpec, register_platform,
 )
 )
 
 
+logger = logging.getLogger(__name__)
+
 BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
 BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
 DEFAULT_TIMEOUT = 60.0
 DEFAULT_TIMEOUT = 60.0
 
 
@@ -188,8 +191,8 @@ async def search(
                 }
                 }
                 post["_quality_score"] = eval_res["total_score"]
                 post["_quality_score"] = eval_res["total_score"]
                 post["_quality_grade"] = eval_res["grade"]
                 post["_quality_grade"] = eval_res["grade"]
-            except Exception:
-                pass
+            except Exception as exc:
+                logger.debug("AIGC post quality evaluation failed: %s", exc)
                 
                 
         summary_item = {
         summary_item = {
             "index": idx,
             "index": idx,
@@ -230,34 +233,13 @@ KEEP_INDIVIDUAL = 8     # 单张图片保留数量;剩余图片合并为 1 张
 
 
 
 
 async def _build_images_collage(urls: List[str]) -> Optional[Dict[str, Any]]:
 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(
 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]:
 async def _build_collage(posts: List[Dict[str, Any]]) -> Optional[str]:
-    """封面图网格拼图"""
+    """Select post covers and render the search-result collage."""
+
     urls, titles = [], []
     urls, titles = [], []
     for post in posts:
     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")
             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 平台 ──
 # ── 注册所有 AIGC 平台 ──

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

@@ -5,15 +5,18 @@ X (Twitter) 平台实现
 """
 """
 
 
 import json
 import json
+import logging
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
 
 
 import httpx
 import httpx
 
 
 from agent.tools.models import ToolResult
 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.registry import PlatformDef, register_platform
 from agent.tools.builtin.content.quality import get_post_evaluator
 from agent.tools.builtin.content.quality import get_post_evaluator
 
 
+logger = logging.getLogger(__name__)
+
 CRAWLER_URL = "http://crawler.aiddit.com/crawler/x/keyword"
 CRAWLER_URL = "http://crawler.aiddit.com/crawler/x/keyword"
 COMMENT_URL = "http://crawler.aiddit.com/crawler/x/comment"
 COMMENT_URL = "http://crawler.aiddit.com/crawler/x/comment"
 DEFAULT_TIMEOUT = 60.0
 DEFAULT_TIMEOUT = 60.0
@@ -64,8 +67,8 @@ async def search(
                         "quality_grade": eval_res["grade"]
                         "quality_grade": eval_res["grade"]
                     }
                     }
                     tweet["_quality_score"] = eval_res["total_score"]
                     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 = {
             summary_item = {
                 "index": idx,
                 "index": idx,
@@ -101,34 +104,13 @@ KEEP_INDIVIDUAL = 8
 
 
 
 
 async def _build_images_collage(urls: List[str]) -> Optional[Dict[str, Any]]:
 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]]:
 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]:
 async def _build_tweet_collage(tweets: List[Dict[str, Any]]) -> Optional[str]:
     urls, titles = [], []
     urls, titles = [], []
     for tweet in tweets:
     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")
             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 json
+import logging
 import re
 import re
 import time
 import time
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
@@ -13,11 +14,13 @@ import httpx
 
 
 from agent.tools.models import ToolResult
 from agent.tools.models import ToolResult
 from agent.tools.builtin.content.quality import get_post_evaluator
 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 (
 from agent.tools.builtin.content.registry import (
     PlatformDef, ParamSpec, register_platform,
     PlatformDef, ParamSpec, register_platform,
 )
 )
 
 
+logger = logging.getLogger(__name__)
+
 CRAWLER_BASE_URL = "http://crawler.aiddit.com/crawler"
 CRAWLER_BASE_URL = "http://crawler.aiddit.com/crawler"
 DEFAULT_TIMEOUT = 60.0
 DEFAULT_TIMEOUT = 60.0
 
 
@@ -198,8 +201,8 @@ async def search(
                     }
                     }
                     video["_quality_score"] = eval_res["total_score"]
                     video["_quality_score"] = eval_res["total_score"]
                     video["_quality_grade"] = eval_res["grade"]
                     video["_quality_grade"] = eval_res["grade"]
-                except Exception:
-                    pass
+                except Exception as exc:
+                    logger.debug("YouTube quality evaluation failed: %s", exc)
             
             
             summary_item = {
             summary_item = {
                 "index": idx,
                 "index": idx,
@@ -282,8 +285,8 @@ async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None)
                         inner2 = inner.get("data", {})
                         inner2 = inner.get("data", {})
                         if isinstance(inner2, dict):
                         if isinstance(inner2, dict):
                             captions_text = inner2.get("content")
                             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 时才跑) ──
     # ── 3) 视频文件下载(用户显式 extras.download_video=True 时才跑) ──
     video_path = None
     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:
     if transcript_text and transcript_text != captions_text:
         memory_parts.append("transcript")
         memory_parts.append("transcript")
     if detail_error:
     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 ""
     memory_extra = f" with {'+'.join(memory_parts)}" if memory_parts else ""
 
 
     title = video_info.get("title") or post.get("title") or content_id
     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]:
 async def _build_video_collage(videos: List[Dict[str, Any]]) -> Optional[str]:
     urls, titles = [], []
     urls, titles = [], []
     for video in videos:
     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")
             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 json
+import logging
 import os
 import os
 import uuid
 import uuid
 from typing import Any, Dict, Optional
 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.youtube       # noqa: F401
 import agent.tools.builtin.content.platforms.x             # 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:
 def _get_trace_id(context: Optional[Dict[str, Any]]) -> str:
     """从 context 取 trace_id,回退到环境变量或自动生成"""
     """从 context 取 trace_id,回退到环境变量或自动生成"""
@@ -50,8 +53,8 @@ def _convert_post_timestamps(post: dict) -> None:
             if ts > 1000000000000:
             if ts > 1000000000000:
                 ts = ts / 1000
                 ts = ts / 1000
             post[field] = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
             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 ──
 # ── content_platforms ──
@@ -91,7 +94,7 @@ async def content_platforms(
         result = [p.summary() for p in hits]
         result = [p.summary() for p in hits]
 
 
     return ToolResult(
     return ToolResult(
-        title=f"内容平台" + (f" ({platform})" if platform else ""),
+        title="内容平台" + (f" ({platform})" if platform else ""),
         output=json.dumps(result, ensure_ascii=False, indent=2),
         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 base64
 import httpx
 import httpx
 import asyncio
 import asyncio
+import logging
 from typing import Optional, List, Dict, Any
 from typing import Optional, List, Dict, Any
 from .feishu_client import FeishuClient
 from .feishu_client import FeishuClient
 from agent.tools import tool, ToolResult, ToolContext
 from agent.tools import tool, ToolResult, ToolContext
 from agent.trace.models import MessageContent
 from agent.trace.models import MessageContent
 
 
+
+logger = logging.getLogger(__name__)
+
 # 凭据只能由运行环境注入;缺失时 FeishuClient 会 fail-closed。
 # 凭据只能由运行环境注入;缺失时 FeishuClient 会 fail-closed。
 FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "")
 FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "")
 FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "")
 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")
 UNREAD_SUMMARY_FILE = os.path.join(CHAT_HISTORY_DIR, "chat_summary.json")
 
 
 # ==================== 一、文件内使用的功能函数 ====================
 # ==================== 一、文件内使用的功能函数 ====================
@@ -30,7 +42,8 @@ def load_contacts() -> List[Dict[str, Any]]:
     try:
     try:
         with open(CONTACTS_FILE, "r", encoding="utf-8") as f:
         with open(CONTACTS_FILE, "r", encoding="utf-8") as f:
             return json.load(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 []
         return []
 
 
 
 
@@ -39,8 +52,8 @@ def save_contacts(contacts: List[Dict[str, Any]]):
     try:
     try:
         with open(CONTACTS_FILE, "w", encoding="utf-8") as f:
         with open(CONTACTS_FILE, "w", encoding="utf-8") as f:
             json.dump(contacts, f, ensure_ascii=False, indent=2)
             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]]:
 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:
 def get_chat_file_path(contact_name: str) -> str:
     _ensure_chat_history_dir()
     _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]]:
 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:
         try:
             with open(path, "r", encoding="utf-8") as f:
             with open(path, "r", encoding="utf-8") as f:
                 return json.load(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 []
     return []
     return []
 
 
@@ -121,8 +136,8 @@ def save_chat_history(contact_name: str, history: List[Dict[str, Any]]):
     try:
     try:
         with open(path, "w", encoding="utf-8") as f:
         with open(path, "w", encoding="utf-8") as f:
             json.dump(history, f, ensure_ascii=False, indent=2)
             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):
 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:
         try:
             with open(UNREAD_SUMMARY_FILE, "r", encoding="utf-8") as f:
             with open(UNREAD_SUMMARY_FILE, "r", encoding="utf-8") as f:
                 summary = json.load(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 = {}
             summary = {}
 
 
     if reset:
     if reset:
@@ -144,8 +164,12 @@ def update_unread_count(contact_name: str, increment: int = 1, reset: bool = Fal
     try:
     try:
         with open(UNREAD_SUMMARY_FILE, "w", encoding="utf-8") as f:
         with open(UNREAD_SUMMARY_FILE, "w", encoding="utf-8") as f:
             json.dump(summary, f, ensure_ascii=False, indent=2)
             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 工具 ====================
 # ==================== 三、@tool 工具 ====================
@@ -259,8 +283,8 @@ async def feishu_send_message_to_contact(
                             last_res = client.send_image(
                             last_res = client.send_image(
                                 to=receive_id, image=image_bytes
                                 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://"):
                     elif url.startswith("http://") or url.startswith("https://"):
                         # 处理网络 URL
                         # 处理网络 URL
                         try:
                         try:
@@ -270,8 +294,8 @@ async def feishu_send_message_to_contact(
                                 last_res = client.send_image(
                                 last_res = client.send_image(
                                     to=receive_id, image=img_resp.content
                                     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:
                     else:
                         # 处理本地文件路径
                         # 处理本地文件路径
                         try:
                         try:
@@ -281,9 +305,9 @@ async def feishu_send_message_to_contact(
                                     to=receive_id, image=local_path
                                     to=receive_id, image=local_path
                                 )
                                 )
                             else:
                             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):
         elif isinstance(content, dict):
             # 如果是单块格式也支持一下
             # 如果是单块格式也支持一下
             item_type = content.get("type")
             item_type = content.get("type")
@@ -324,8 +348,8 @@ async def feishu_send_message_to_contact(
                 save_chat_history(contact_name, history)
                 save_chat_history(contact_name, history)
                 # 机器人回复了,将该联系人的未读计数重置为 0
                 # 机器人回复了,将该联系人的未读计数重置为 0
                 update_unread_count(contact_name, reset=True)
                 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(
             return ToolResult(
                 title=f"消息已成功发送至 {contact_name}",
                 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}"},
                         "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": "[图片内容获取失败]"})
             blocks.append({"type": "text", "text": "[图片内容获取失败]"})
     elif msg_type == "post":
     elif msg_type == "post":
         blocks.append({"type": "text", "text": raw_content})
         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 typing import Dict, List, Any, Optional
 from collections import defaultdict
 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 (
 from agent.tools.builtin.feishu.chat import (
     FEISHU_APP_ID,
     FEISHU_APP_ID,
     FEISHU_APP_SECRET,
     FEISHU_APP_SECRET,
@@ -41,15 +42,22 @@ from agent.tools.builtin.feishu.chat import (
 )
 )
 from agent.llm.qwen import qwen_llm_call
 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")
 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")
 MODEL = os.getenv("FEISHU_AGENT_MODEL", "qwen3.5-397b-a17b")
 MAX_HISTORY = 50
 MAX_HISTORY = 50
 MAX_TOOL_ROUNDS = 10  # 工具调用最大循环次数
 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 助手,正在通过飞书和用户对话。
 DEFAULT_SYSTEM_PROMPT = """你是一个友好、有帮助的 AI 助手,正在通过飞书和用户对话。
 你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
 你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
@@ -337,7 +345,7 @@ def _tool_run_bash(command: str) -> str:
 def _tool_send_image(path: 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.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:
     if not _current_chat_id:
         return "错误:无法获取当前会话 ID"
         return "错误:无法获取当前会话 ID"
@@ -422,7 +430,7 @@ def _output_reader(proc: subprocess.Popen, pid: int):
             if len(_background_processes[pid]["output_lines"]) > 500:
             if len(_background_processes[pid]["output_lines"]) > 500:
                 _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
                 _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
     except Exception:
     except Exception:
-        pass
+        logger.debug("Feishu background stdout reader stopped", exc_info=True)
 
 
     # stderr 也读
     # stderr 也读
     try:
     try:
@@ -433,7 +441,7 @@ def _output_reader(proc: subprocess.Popen, pid: int):
             if len(_background_processes[pid]["output_lines"]) > 500:
             if len(_background_processes[pid]["output_lines"]) > 500:
                 _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
                 _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
     except Exception:
     except Exception:
-        pass
+        logger.debug("Feishu background stderr reader stopped", exc_info=True)
 
 
 
 
 def _tool_run_background(command: str, name: str = "") -> str:
 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["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
     env["PYTHONIOENCODING"] = "utf-8"
     env["PYTHONIOENCODING"] = "utf-8"
     env["PYTHONUNBUFFERED"] = "1"  # 强制 Python 无缓冲输出
     env["PYTHONUNBUFFERED"] = "1"  # 强制 Python 无缓冲输出
-    env["PYTHONIOENCODING"] = "utf-8"  # 强制 Python IO 编码为 utf-8
 
 
     try:
     try:
         proc = subprocess.Popen(
         proc = subprocess.Popen(
@@ -494,7 +501,7 @@ def _tool_stop_process(pid: int) -> str:
     try:
     try:
         proc.wait(timeout=5)
         proc.wait(timeout=5)
     except Exception:
     except Exception:
-        pass
+        logger.debug("Feishu background process did not exit cleanly", exc_info=True)
 
 
     _background_processes.pop(pid, None)
     _background_processes.pop(pid, None)
     return f"已停止进程: PID={pid} ({name})"
     return f"已停止进程: PID={pid} ({name})"
@@ -539,7 +546,7 @@ def _tool_send_input(pid: int, text: str) -> str:
 
 
     proc = info["proc"]
     proc = info["proc"]
     if proc.poll() is not None:
     if proc.poll() is not None:
-        return f"进程已退出,无法发送输入"
+        return "进程已退出,无法发送输入"
 
 
     try:
     try:
         # 使用控制文件方式(更可靠)
         # 使用控制文件方式(更可靠)
@@ -549,7 +556,6 @@ def _tool_send_input(pid: int, text: str) -> str:
         return f"已向 PID={pid} 发送控制指令: {text.strip()}"
         return f"已向 PID={pid} 发送控制指令: {text.strip()}"
     except Exception as e:
     except Exception as e:
         return f"发送输入失败: {e}"
         return f"发送输入失败: {e}"
-        return f"发送输入失败: {e}"
 
 
 
 
 class FeishuAgent:
 class FeishuAgent:
@@ -888,5 +894,9 @@ class FeishuAgent:
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
+    )
     agent = FeishuAgent()
     agent = FeishuAgent()
     agent.start()
     agent.start()

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

@@ -43,7 +43,6 @@ from lark_oapi.api.im.v1 import (
     ListMessageResponse,
     ListMessageResponse,
 )
 )
 
 
-logging.basicConfig(level=logging.INFO)
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -929,6 +928,7 @@ class FeishuClient:
 # ==================== 使用示例 ====================
 # ==================== 使用示例 ====================
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
     # 从环境变量获取配置
     # 从环境变量获取配置
     APP_ID = os.getenv("FEISHU_APP_ID")
     APP_ID = os.getenv("FEISHU_APP_ID")
     APP_SECRET = os.getenv("FEISHU_APP_SECRET")
     APP_SECRET = os.getenv("FEISHU_APP_SECRET")
@@ -987,7 +987,5 @@ if __name__ == "__main__":
             blocking=True,
             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:
     except KeyboardInterrupt:
         print("\n退出")
         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 pathlib import Path
 from typing import Optional, Generator
 from typing import Optional, Generator
-import difflib
 import re
 import re
 
 
 from agent.tools import tool, ToolResult, ToolContext
 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"])
 @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()
     first_line_find = find_lines[0].strip()
     last_line_find = find_lines[-1].strip()
     last_line_find = find_lines[-1].strip()
-    find_block_size = len(find_lines)
-
     # 收集所有候选位置(首尾行都匹配)
     # 收集所有候选位置(首尾行都匹配)
     candidates = []
     candidates = []
     for i in range(len(content_lines)):
     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:
     if len(candidates) == 1:
         start_line, end_line = candidates[0]
         start_line, end_line = candidates[0]
-        actual_block_size = end_line - start_line + 1
-
         similarity = _calculate_block_similarity(
         similarity = _calculate_block_similarity(
             content_lines[start_line:end_line + 1],
             content_lines[start_line:end_line + 1],
             find_lines
             find_lines
@@ -506,26 +502,3 @@ def multi_occurrence_replacer(content: str, find: str) -> Generator[str, None, N
             break
             break
         yield find
         yield find
         start_index = index + len(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)
 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(
 async def glob_files(
     pattern: str,
     pattern: str,
     path: Optional[str] = None,
     path: Optional[str] = None,
@@ -87,7 +92,7 @@ async def glob_files(
             output = "\n".join(file_paths)
             output = "\n".join(file_paths)
 
 
             if truncated:
             if truncated:
-                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+                output += "\n\n(结果已截断。考虑使用更具体的路径或模式。)"
 
 
         return ToolResult(
         return ToolResult(
             title=f"匹配: {pattern}",
             title=f"匹配: {pattern}",

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

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

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

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

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

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

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

@@ -11,9 +11,9 @@ Write Tool - 文件写入工具
 
 
 from pathlib import Path
 from pathlib import Path
 from typing import Optional
 from typing import Optional
-import difflib
 
 
 from agent.tools import tool, ToolResult, ToolContext
 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"])
 @tool(description="写入文件内容(创建新文件、覆盖现有文件或追加内容)", hidden_params=["context"], groups=["core"], capabilities=["write"])
@@ -117,22 +117,3 @@ async def write_file(
         },
         },
         long_term_memory=f"{operation}文件 {path.name}"
         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
 import json
 from pathlib import Path
 from pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Optional
 
 
 from agent.tools import tool, ToolResult, ToolContext
 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 asyncio
 import json
 import json
 import logging
 import logging
-import os
-import sys
 from typing import Optional
 from typing import Optional
 
 
+from agent.im_client import AgentNotifier, IMClient
 from agent.tools import tool, ToolResult, ToolContext
 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__)
 logger = logging.getLogger(__name__)
 
 
 # ── 全局状态 ──
 # ── 全局状态 ──

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

@@ -7,13 +7,11 @@
 import os
 import os
 import json
 import json
 import logging
 import logging
-import subprocess
 import uuid
 import uuid
 import httpx
 import httpx
-from dataclasses import dataclass
 from typing import List, Dict, Optional, Any
 from typing import List, Dict, Optional, Any
 from agent.tools import tool, ToolResult, ToolContext
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -21,76 +19,6 @@ logger = logging.getLogger(__name__)
 KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000").rstrip("/")
 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"])
 @tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_search(
 async def knowledge_search(
     query: str,
     query: str,
@@ -670,8 +598,7 @@ async def resource_save(
         async with httpx.AsyncClient(timeout=30.0) as client:
         async with httpx.AsyncClient(timeout=30.0) as client:
             response = await client.post(f"{KNOWHUB_API}/api/resource", json=payload)
             response = await client.post(f"{KNOWHUB_API}/api/resource", json=payload)
             response.raise_for_status()
             response.raise_for_status()
-            data = response.json()
-
+            response.json()  # Preserve response-shape validation without retaining unused data.
         return ToolResult(
         return ToolResult(
             title="✅ 资源已保存",
             title="✅ 资源已保存",
             output=f"资源 ID: {resource_id}\n类型: {content_type}\n标题: {title}",
             output=f"资源 ID: {resource_id}\n类型: {content_type}\n标题: {title}",
@@ -758,7 +685,8 @@ async def tool_search(
     """
     """
     try:
     try:
         params = {"q": query, "top_k": top_k}
         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:
         async with httpx.AsyncClient(timeout=30.0) as client:
             res = await client.get(f"{KNOWHUB_API}/api/tool/search", params=params)
             res = await client.get(f"{KNOWHUB_API}/api/tool/search", params=params)
             res.raise_for_status()
             res.raise_for_status()
@@ -778,7 +706,8 @@ async def tool_list(
     """列出工具列表 (Tool)"""
     """列出工具列表 (Tool)"""
     try:
     try:
         params = {"limit": limit, "offset": offset}
         params = {"limit": limit, "offset": offset}
-        if status: params["status"] = status
+        if status:
+            params["status"] = status
         async with httpx.AsyncClient(timeout=30.0) as client:
         async with httpx.AsyncClient(timeout=30.0) as client:
             res = await client.get(f"{KNOWHUB_API}/api/tool", params=params)
             res = await client.get(f"{KNOWHUB_API}/api/tool", params=params)
             res.raise_for_status()
             res.raise_for_status()
@@ -860,7 +789,8 @@ async def requirement_list(
     """列出需求列表 (Requirement)"""
     """列出需求列表 (Requirement)"""
     try:
     try:
         params = {"limit": limit, "offset": offset}
         params = {"limit": limit, "offset": offset}
-        if status: params["status"] = status
+        if status:
+            params["status"] = status
         async with httpx.AsyncClient(timeout=30.0) as client:
         async with httpx.AsyncClient(timeout=30.0) as client:
             res = await client.get(f"{KNOWHUB_API}/api/requirement", params=params)
             res = await client.get(f"{KNOWHUB_API}/api/requirement", params=params)
             res.raise_for_status()
             res.raise_for_status()

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

@@ -4,7 +4,7 @@
 
 
 import os
 import os
 import httpx
 import httpx
-from typing import List, Dict, Optional, Any
+from typing import Optional
 from agent.tools import tool, ToolResult
 from agent.tools import tool, ToolResult
 
 
 KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999").rstrip("/")
 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 os
-import subprocess
 from pathlib import Path
 from pathlib import Path
 from typing import Optional
 from typing import Optional
 
 
@@ -154,7 +153,7 @@ async def skill(
             output += setup_warning
             output += setup_warning
 
 
         if skill_obj.guidelines:
         if skill_obj.guidelines:
-            output += f"## Guidelines\n\n"
+            output += "## Guidelines\n\n"
             for i, guideline in enumerate(skill_obj.guidelines, 1):
             for i, guideline in enumerate(skill_obj.guidelines, 1):
                 output += f"{i}. {guideline}\n"
                 output += f"{i}. {guideline}\n"
             output += "\n"
             output += "\n"

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

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

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

@@ -485,16 +485,11 @@ async def toolhub_call(
             if isinstance(result, dict):
             if isinstance(result, dict):
                 # 收集所有图片(单张 image 字段 + images 列表字段)
                 # 收集所有图片(单张 image 字段 + images 列表字段)
                 raw_images = []
                 raw_images = []
-                has_single_image = False
-                has_images_list = False
-
                 if result.get("image") and isinstance(result["image"], str):
                 if result.get("image") and isinstance(result["image"], str):
                     raw_images.append(result["image"])
                     raw_images.append(result["image"])
-                    has_single_image = True
 
 
                 if result.get("images") and isinstance(result["images"], list):
                 if result.get("images") and isinstance(result["images"], list):
                     raw_images.extend(result["images"])
                     raw_images.extend(result["images"])
-                    has_images_list = True
 
 
                 if raw_images:
                 if raw_images:
                     images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
                     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:
 class ToolRegistry:
 	"""工具注册表"""
 	"""工具注册表"""
+	_REGISTRATION_FIELDS = (
+		"schema",
+		"url_patterns",
+		"hidden_params",
+		"inject_params",
+		"groups",
+		"capabilities",
+		"ui_metadata",
+	)
 
 
 	def __init__(self):
 	def __init__(self):
 		self._tools: Dict[str, Dict[str, Any]] = {}
 		self._tools: Dict[str, Dict[str, Any]] = {}
@@ -100,7 +109,7 @@ class ToolRegistry:
 				logger.error(f"Failed to generate schema for {func_name}: {e}")
 				logger.error(f"Failed to generate schema for {func_name}: {e}")
 				raise
 				raise
 
 
-		self._tools[func_name] = {
+		registration = {
 			"func": func,
 			"func": func,
 			"schema": schema,
 			"schema": schema,
 			"url_patterns": url_patterns,
 			"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()
 		self._stats[func_name] = ToolStats()
 
 
@@ -125,6 +190,45 @@ class ToolRegistry:
 			f"url_patterns={url_patterns or 'none'})"
 			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
 	@staticmethod
 	def _resolve_key_path(context: Dict[str, Any], key_path: str) -> Any:
 	def _resolve_key_path(context: Dict[str, Any], key_path: str) -> Any:
 		"""
 		"""
@@ -251,6 +355,7 @@ class ToolRegistry:
 			uid: 用户ID(自动注入)
 			uid: 用户ID(自动注入)
 			context: 额外上下文
 			context: 额外上下文
 			sensitive_data: 敏感数据字典(用于替换 <secret> 占位符)
 			sensitive_data: 敏感数据字典(用于替换 <secret> 占位符)
+			inject_values: 兼容注入值;仅在调用参数未提供时生效
 
 
 		Returns:
 		Returns:
 			JSON 字符串格式的结果
 			JSON 字符串格式的结果
@@ -288,6 +393,12 @@ class ToolRegistry:
 			if "context" in hidden_params and "context" in sig.parameters:
 			if "context" in hidden_params and "context" in sig.parameters:
 				kwargs["context"] = context
 				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)
 			inject_params = tool_info.get("inject_params", {})
 			inject_params = tool_info.get("inject_params", {})
 			for param_name, rule in inject_params.items():
 			for param_name, rule in inject_params.items():
@@ -569,9 +680,26 @@ def tool(
 			...
 			...
 	"""
 	"""
 	def decorator(func: Callable) -> Callable:
 	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
 		# 注册到全局 registry
 		_global_registry.register(
 		_global_registry.register(
 			func,
 			func,
+			schema=schema,
 			requires_confirmation=requires_confirmation,
 			requires_confirmation=requires_confirmation,
 			editable_params=editable_params,
 			editable_params=editable_params,
 			display=display,
 			display=display,

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

@@ -14,13 +14,15 @@
 import asyncio
 import asyncio
 import base64
 import base64
 import io
 import io
+import logging
 import math
 import math
-from pathlib import Path
 from typing import List, Optional, Sequence, Tuple
 from typing import List, Optional, Sequence, Tuple
 
 
 import httpx
 import httpx
 from PIL import Image, ImageDraw, ImageFont
 from PIL import Image, ImageDraw, ImageFont
 
 
+logger = logging.getLogger(__name__)
+
 
 
 # ── 网格拼图默认参数 ──
 # ── 网格拼图默认参数 ──
 DEFAULT_THUMB_SIZE = 250         # 每格缩略图边长
 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, title_size),
                 ImageFont.truetype(path, index_size),
                 ImageFont.truetype(path, index_size),
             )
             )
-        except Exception:
+        except OSError:
             continue
             continue
     default = ImageFont.load_default()
     default = ImageFont.load_default()
     return default, 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 = await client.get(url, timeout=15.0)
         resp.raise_for_status()
         resp.raise_for_status()
         return Image.open(io.BytesIO(resp.content)).convert("RGB")
         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
         return None
 
 
 
 
@@ -80,7 +83,8 @@ def _load_image_from_path(path: str) -> Optional[Image.Image]:
     """从本地路径加载图片,失败返回 None"""
     """从本地路径加载图片,失败返回 None"""
     try:
     try:
         return Image.open(path).convert("RGB")
         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
         return None
 
 
 
 

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

@@ -15,6 +15,7 @@ from .attachments import AttachmentRef
 from .protocols import TraceAttachmentStore, TraceStore
 from .protocols import TraceAttachmentStore, TraceStore
 from .store import FileSystemTraceStore
 from .store import FileSystemTraceStore
 from .trace_id import generate_trace_id, generate_sub_trace_id, parse_parent_trace_id
 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__ = [
 __all__ = [
     # Models
     # Models
@@ -34,4 +35,9 @@ __all__ = [
     "generate_trace_id",
     "generate_trace_id",
     "generate_sub_trace_id",
     "generate_sub_trace_id",
     "parse_parent_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 os
 import json
 import json
+import logging
 import httpx
 import httpx
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
 from typing import List, Optional, Dict, Any
@@ -14,6 +15,8 @@ from pydantic import BaseModel
 
 
 from .protocols import TraceStore
 from .protocols import TraceStore
 
 
+logger = logging.getLogger(__name__)
+
 
 
 router = APIRouter(prefix="/api/traces", tags=["traces"])
 router = APIRouter(prefix="/api/traces", tags=["traces"])
 
 
@@ -275,7 +278,11 @@ async def submit_knowledge_feedback(trace_id: str, req: KnowledgeFeedbackRequest
                 updated_count += 1
                 updated_count += 1
             except Exception as e:
             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}
     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:
                     if "task" in parsed and "content" in parsed:
                         raw = part
                         raw = part
                         break
                         break
-                except Exception:
+                except (json.JSONDecodeError, TypeError):
                     continue
                     continue
         extracted = json.loads(raw)
         extracted = json.loads(raw)
         task = extracted.get("task", "").strip()
         task = extracted.get("task", "").strip()

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

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

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

@@ -5,7 +5,7 @@ Goal 工具 - 计划管理
 """
 """
 
 
 import logging
 import logging
-from typing import Optional, List, TYPE_CHECKING
+from typing import Optional, TYPE_CHECKING
 
 
 from agent.tools import tool
 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):
     if knowledge_config and not getattr(knowledge_config, 'enable_injection', True):
-        logger.debug(f"[Knowledge Inject] 知识注入已禁用,跳过")
+        logger.debug("[Knowledge Inject] 知识注入已禁用,跳过")
         return None
         return None
 
 
     try:
     try:
@@ -92,12 +92,12 @@ async def inject_knowledge_for_goal(
                             "sources": [],
                             "sources": [],
                         }
                         }
                     )
                     )
-                    logger.info(f"[Knowledge Inject] 已记录 query 事件到 cognition_log")
+                    logger.info("[Knowledge Inject] 已记录 query 事件到 cognition_log")
 
 
             return f"📚 已注入 {knowledge_count} 条相关知识"
             return f"📚 已注入 {knowledge_count} 条相关知识"
         else:
         else:
             goal.knowledge = []
             goal.knowledge = []
-            logger.info(f"[Knowledge Inject] 未找到相关知识")
+            logger.info("[Knowledge Inject] 未找到相关知识")
             return None
             return None
 
 
     except Exception as e:
     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["is_active"] = time_since_activity < 30
                             trace_info["seconds_since_activity"] = int(time_since_activity)
                             trace_info["seconds_since_activity"] = int(time_since_activity)
                         trace_info["status"] = trace.status
                         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)
             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 .attachments import AttachmentRef
 from .models import Trace, Message
 from .models import Trace, Message
 from .goal_models import GoalTree, Goal, GoalStats
 from .goal_models import GoalTree, Goal, GoalStats
+from ._cognition_store import TraceCognitionStore
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -42,6 +43,7 @@ class FileSystemTraceStore:
     def __init__(self, base_path: str = ".trace"):
     def __init__(self, base_path: str = ".trace"):
         self.base_path = Path(base_path)
         self.base_path = Path(base_path)
         self.base_path.mkdir(exist_ok=True)
         self.base_path.mkdir(exist_ok=True)
+        self._cognition = TraceCognitionStore(self)
 
 
     def _get_trace_dir(self, trace_id: str) -> Path:
     def _get_trace_dir(self, trace_id: str) -> Path:
         """获取 trace 目录"""
         """获取 trace 目录"""
@@ -299,7 +301,8 @@ class FileSystemTraceStore:
                     data["completed_at"] = datetime.fromisoformat(data["completed_at"])
                     data["completed_at"] = datetime.fromisoformat(data["completed_at"])
 
 
                 traces.append(Trace.from_dict(data))
                 traces.append(Trace.from_dict(data))
-            except Exception:
+            except Exception as exc:
+                logger.warning("Skipping unreadable trace metadata %s: %s", meta_file, exc)
                 continue
                 continue
 
 
         # 排序(最新的在前)
         # 排序(最新的在前)
@@ -318,7 +321,8 @@ class FileSystemTraceStore:
         try:
         try:
             data = json.loads(goal_file.read_text(encoding="utf-8"))
             data = json.loads(goal_file.read_text(encoding="utf-8"))
             return GoalTree.from_dict(data)
             return GoalTree.from_dict(data)
-        except Exception:
+        except Exception as exc:
+            logger.warning("Failed to load GoalTree %s: %s", goal_file, exc)
             return None
             return None
 
 
     async def update_goal_tree(self, trace_id: str, tree: GoalTree) -> 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}
         event_data = {"goal": goal.to_dict(), "parent_id": goal.parent_id}
         await self.append_event(trace_id, "goal_added", event_data)
         await self.append_event(trace_id, "goal_added", event_data)
 
 
-        # 打印详细的 goal 信息
         desc_preview = (
         desc_preview = (
             goal.description[:80] + "..."
             goal.description[:80] + "..."
             if len(goal.description) > 80
             if len(goal.description) > 80
             else goal.description
             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:
         if goal.reason:
             reason_preview = (
             reason_preview = (
                 goal.reason[:60] + "..." if len(goal.reason) > 60 else goal.reason
                 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(
     async def update_goal(
         self,
         self,
@@ -397,8 +404,11 @@ class FileSystemTraceStore:
             "goal_updated",
             "goal_updated",
             {"goal_id": goal_id, "updates": updates, "affected_goals": affected_goals},
             {"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 完成时触发知识评估
         # Goal 完成时触发知识评估
@@ -589,8 +599,6 @@ class FileSystemTraceStore:
             goal.self_stats.total_tokens += message.tokens
             goal.self_stats.total_tokens += message.tokens
         if message.cost:
         if message.cost:
             goal.self_stats.total_cost += message.cost
             goal.self_stats.total_cost += message.cost
-        # TODO: 更新 preview(工具调用摘要)
-
         # 更新自身 cumulative_stats
         # 更新自身 cumulative_stats
         goal.cumulative_stats.message_count += 1
         goal.cumulative_stats.message_count += 1
         if message.tokens:
         if message.tokens:
@@ -670,8 +678,8 @@ class FileSystemTraceStore:
                 try:
                 try:
                     data = json.loads(message_file.read_text(encoding="utf-8"))
                     data = json.loads(message_file.read_text(encoding="utf-8"))
                     return Message.from_dict(data)
                     return Message.from_dict(data)
-                except Exception:
-                    pass
+                except Exception as exc:
+                    logger.warning("Skipping unreadable message %s: %s", message_file, exc)
 
 
         return None
         return None
 
 
@@ -691,7 +699,8 @@ class FileSystemTraceStore:
                 data = json.loads(message_file.read_text(encoding="utf-8"))
                 data = json.loads(message_file.read_text(encoding="utf-8"))
                 msg = Message.from_dict(data)
                 msg = Message.from_dict(data)
                 messages.append(msg)
                 messages.append(msg)
-            except Exception:
+            except Exception as exc:
+                logger.warning("Skipping unreadable message %s: %s", message_file, exc)
                 continue
                 continue
 
 
         # 按 sequence 排序
         # 按 sequence 排序
@@ -899,7 +908,12 @@ class FileSystemTraceStore:
                     event = json.loads(line.strip())
                     event = json.loads(line.strip())
                     if event.get("event_id", 0) > since_event_id:
                     if event.get("event_id", 0) > since_event_id:
                         events.append(event)
                         events.append(event)
-                except Exception:
+                except Exception as exc:
+                    logger.warning(
+                        "Skipping unreadable event in %s: %s",
+                        events_file,
+                        exc,
+                    )
                     continue
                     continue
 
 
         return events
         return events
@@ -934,86 +948,26 @@ class FileSystemTraceStore:
 
 
         return event_id
         return event_id
 
 
-    # ===== Cognition Log 管理 =====
+    # ===== Cognition Log compatibility facade =====
 
 
     def _get_cognition_log_file(self, trace_id: str) -> Path:
     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:
     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]:
     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]:
     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(
     async def append_cognition_event(
         self,
         self,
         trace_id: str,
         trace_id: str,
         event: Dict[str, Any],
         event: Dict[str, Any],
     ) -> None:
     ) -> 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(
     async def append_knowledge_entry(
         self,
         self,
@@ -1024,20 +978,13 @@ class FileSystemTraceStore:
         task: str,
         task: str,
         content: str,
         content: str,
     ) -> None:
     ) -> 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(
     async def update_knowledge_evaluation(
@@ -1047,130 +994,29 @@ class FileSystemTraceStore:
         eval_result: Dict[str, Any],
         eval_result: Dict[str, Any],
         trigger_event: str,
         trigger_event: str,
     ) -> None:
     ) -> 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(
     async def get_pending_knowledge_entries(
-        self, trace_id: str
+        self,
+        trace_id: str,
     ) -> List[Dict[str, Any]]:
     ) -> 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(
     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:
     ) -> 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 shutil
 import tempfile
 import tempfile
 import zipfile
 import zipfile
-from typing import List, Dict, Any
+from typing import List, Dict
 from fastapi import APIRouter, UploadFile, File, HTTPException
 from fastapi import APIRouter, UploadFile, File, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
@@ -180,7 +180,7 @@ async def upload_traces(file: UploadFile = File(...)):
         elif imported and failed:
         elif imported and failed:
             message = f"Imported {len(imported)} trace(s), {len(failed)} failed"
             message = f"Imported {len(imported)} trace(s), {len(failed)} failed"
         elif not imported and failed:
         elif not imported and failed:
-            message = f"Failed to import all traces"
+            message = "Failed to import all traces"
         else:
         else:
             message = "No valid traces found in the zip file"
             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 = [
     "browser-use>=0.11.0",
     "browser-use>=0.11.0",
     "langchain_core>=0.3.0",
     "langchain_core>=0.3.0",
+    "PyMySQL>=1.1.0",
+    "DBUtils>=3.1.0",
 ]
 ]
 server = [
 server = [
     "fastapi>=0.115.0",
     "fastapi>=0.115.0",
@@ -36,6 +38,8 @@ feishu = [
 all = [
 all = [
     "browser-use>=0.11.0",
     "browser-use>=0.11.0",
     "langchain_core>=0.3.0",
     "langchain_core>=0.3.0",
+    "PyMySQL>=1.1.0",
+    "DBUtils>=3.1.0",
     "fastapi>=0.115.0",
     "fastapi>=0.115.0",
     "uvicorn[standard]>=0.32.0",
     "uvicorn[standard]>=0.32.0",
     "websockets>=13.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 = (
 _FEISHU_FILES = (
     "chat.py",
     "chat.py",
-    "chat_test.py",
     "feishu_client.py",
     "feishu_client.py",
 )
 )
 _CREDENTIAL_NAMES = {"FEISHU_APP_ID", "FEISHU_APP_SECRET"}
 _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"

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác