# 中文注释：
# 文件：echobot/agent/loop.py
# 说明：智能体核心逻辑，包括主循环、上下文、记忆与任务队列。

"""Agent Loop - 核心处理引擎
=================================================

Agent Loop 是 echobot 的核心处理引擎，负责：

1. 消息接收：
   - 从消息总线接收来自各渠道的入站消息
   - 支持正常消息和系统消息（子任务通知）

2. 上下文构建：
   - 加载对话历史
   - 构建系统提示词
   - 应用记忆和技能

3. LLM 调用：
   - 发送消息给 LLM
   - 处理工具调用响应

4. 工具执行：
   - 执行 Agent 请求的工具调用
   - 将结果返回给 LLM

5. 响应发送：
   - 将响应发送到消息总线
   - 由对应渠道发送给用户

主要类：
- AgentLoop: 核心处理引擎

使用示例：
    agent = AgentLoop(bus, provider, workspace)
    await agent.run()  # 启动无限循环
"""

import asyncio
import json
import os
from pathlib import Path
from typing import Any

from loguru import logger

from echobot.agent.context import ContextBuilder
from echobot.agent.hooks import HookManager
from echobot.bus.events import InboundMessage, OutboundMessage
from echobot.bus.queue import MessageBus
from echobot.agent.memory_auto import MemoryAutoWriter
from echobot.agent.tools.cron import CreateCronJobTool, ListCronJobsTool, RemoveCronJobTool
from echobot.agent.tools.dir_manager import DirManagerTool
from echobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from echobot.agent.tools.message import MessageTool
from echobot.agent.tools.registry import ToolRegistry
from echobot.agent.tools.shell import ExecTool
from echobot.agent.tools.skill_script import RunSkillScriptTool
from echobot.agent.tools.spawn import SpawnTool
from echobot.agent.tools.web import WebFetchTool, WebSearchTool
from echobot.agent.tools.mcp import MCPTool
from echobot.agent.mcp.client import MCPClient
from echobot.agent.security import SecurityManager
from echobot.agent.subagent import SubagentManager
from echobot.providers.base import LLMProvider
from echobot.roles import RoleManager
from echobot.session.manager import SessionManager


class AgentLoop:
    """
    Agent 核心处理引擎 - echobot 的大脑

    AgentLoop 协调整个消息处理流程：
    1. 从消息总线接收消息
    2. 构建上下文（历史、记忆、技能）
    3. 调用 LLM 并处理工具调用
    4. 发送响应

    主要特性：
    - 支持多轮对话和工具调用循环
    - 自动管理对话会话
    - 支持子任务（Subagent）处理
    - 内置默认工具集

    使用示例：
        agent = AgentLoop(
            bus=bus,
            provider=provider,
            workspace=Path("~/.echobot/workspace"),
            model="anthropic/claude-sonnet-4-5"
        )
        await agent.run()
    """

    def _create_workspace_templates(self, role_name: str = "default") -> None:
        """Create default workspace template files if they don't exist."""
        # 确保 workspace 目录存在
        self.workspace.mkdir(parents=True, exist_ok=True)

        role_descriptions = {
            "coder": "编程助手，擅长代码编写和调试",
            "reviewer": "代码审查者，擅长代码审查和优化",
            "tester": "测试工程师，擅长测试用例编写",
            "analyzer": "数据分析师",
            "default": "通用助手",
            "text_assistant": "文本助手",
            "excel_assistant": "Excel 助手",
            "data_analyst": "数据分析师",
        }
        role_description = role_descriptions.get(role_name, "通用助手")

        templates = {
            "SOUL.md": f"""# Soul - 我是谁

我是 echobot 的 {role_name} 角色。

## 身份

- 角色：{role_name}
- 用途：{role_description}

## 性格

- 乐于助人
- 准确严谨
- 友好亲切

## 价值观

- 用户隐私优先
- 行动透明
- 持续学习
""",
            "AGENTS.md": f"""# Agent Instructions - 怎么干活

你是 echobot 的 {role_name}，一个专业的 {role_name}。

## 工作原则

1. 在执行操作前先解释要做什么
2. 请求不明确时主动询问
3. 善于使用工具完成任务
4. 重要信息记录到记忆文件

## 技能

- 文件读写与编辑
- 执行 Shell 命令
- 网络搜索与获取
- 代码编写与调试
""",
            "USER.md": """# User - 用户信息

用户信息将记录在这里。

## 用户偏好

- 交流风格：
- 时区：
- 语言：

## 重要备注

（用户的重要信息）
""",
        }

        for filename, content in templates.items():
            file_path = self.workspace / filename
            if not file_path.exists():
                file_path.write_text(content)
                logger.debug(f"Created {filename} in workspace")

        # 创建 memory 目录和 MEMORY.md
        memory_dir = self.workspace / "memory"
        memory_dir.mkdir(exist_ok=True)
        memory_file = memory_dir / "MEMORY.md"
        if not memory_file.exists():
            memory_file.write_text("""# Long-term Memory - 长期记忆

这里存储跨会话的重要信息。

## 用户信息

（关于用户的重要事实）

## 偏好设置

（用户偏好学习）

## 重要备注

（需要记住的重要事项）
""")

    def __init__(
        self,
        bus: MessageBus,
        provider: LLMProvider,
        workspace: Path,
        instance: str | None = None,
        model: str | None = None,
        max_iterations: int = 20,
        brave_api_key: str | None = None,
        role_name: str | None = None,
        default_role: str | None = None,
        context_compression_enabled: bool = True,
        context_history_max_messages: int = 40,
        context_keep_recent_messages: int = 16,
        context_summary_max_chars: int = 2000,
        approval_ttl_seconds: int = 15 * 60,
        approval_required_tools: list[str] | None = None,
        denied_exec_patterns: list[str] | None = None,
        webhook_secret: str | None = None,
        mcp_servers: dict | None = None,
    ):
        """
        Initialize Agent Loop.

        参数:
            bus: 消息总线
            provider: LLM 提供者
            workspace: 工作区路径
            instance: 实例名称（用于隔离会话存储路径）
            model: 模型名称
            max_iterations: 最大迭代次数
            brave_api_key: Brave API 密钥
            role_name: 手动指定的角色名称（优先级最高）
            default_role: 默认角色名称（当 role_name 为 None 时使用）
            context_compression_enabled: 是否启用历史滚动摘要压缩
            context_history_max_messages: 压缩前允许的历史消息数量
            context_keep_recent_messages: 压缩后保留的近期消息数量
            context_summary_max_chars: 历史摘要最大字符数
            approval_ttl_seconds: 人工审批请求有效期（秒）
            approval_required_tools: 需要人工审批的工具名称列表（None=使用默认策略）
            denied_exec_patterns: exec 直接拒绝的命令正则列表（None=使用默认策略）
            webhook_secret: 入站 webhook 验签密钥（未传则读取环境变量）
        """
        self.bus = bus
        self.provider = provider
        self.workspace = workspace
        self.instance = instance
        self.model = model or provider.get_default_model()
        self.max_iterations = max_iterations
        self.brave_api_key = brave_api_key
        self._cron_service: Any | None = None
        self.context_compression_enabled = context_compression_enabled
        self.context_history_max_messages = max(6, context_history_max_messages)
        self.context_keep_recent_messages = max(2, context_keep_recent_messages)
        self.context_summary_max_chars = max(500, context_summary_max_chars)

        # 任务执行状态
        self._task_lock = asyncio.Lock()
        self._current_task: str | None = None
        self._task_start_time: float | None = None

        # 初始化组件
        self.context = ContextBuilder(workspace)
        self.memory_auto = MemoryAutoWriter(self.context.memory)
        # 会话按实例隔离，避免不同实例/网关混写到同一 sessions 目录。
        self.sessions = SessionManager(workspace, instance=instance)
        self.tools = ToolRegistry()

        # 初始化 MCP 客户端
        self._mcp_client: MCPClient | None = None
        if mcp_servers:
            from echobot.config.schema import MCPConfig, MCPServerConfig

            # Convert dict to MCPConfig
            servers = {}
            for name, config_dict in mcp_servers.items():
                servers[name] = MCPServerConfig(**config_dict)
            self._mcp_client = MCPClient(servers)
        # 中文注释：
        # `approval_required_tools` 允许显式传空列表 []，表示“无工具要求审批”。
        # 因此这里只在参数为 None 时保留 None；其余情况都规范化为 set 再下传。
        normalized_approval_tools: set[str] | None = None
        if approval_required_tools is not None:
            normalized_approval_tools = {
                str(name).strip() for name in approval_required_tools if str(name).strip()
            }

        self.security = SecurityManager(
            workspace=workspace,
            approval_ttl_seconds=approval_ttl_seconds,
            approval_required_tools=normalized_approval_tools,
            denied_exec_patterns=denied_exec_patterns,
        )
        self.hooks = HookManager(
            workspace=workspace,
            inbound_secret=webhook_secret or os.environ.get("ECHOBOT_WEBHOOK_SECRET", ""),
        )

        # 初始化角色管理器
        self.role_manager = RoleManager()

        # 标记是否为手动指定的角色（手动指定的角色不会被自动切换）
        self._manual_role: str | None = role_name

        # 确定要使用的角色
        # 优先级：手动指定的 role_name > 配置中的 default_role > "default"
        effective_role = role_name or default_role or "default"
        if effective_role:
            self.role_manager.switch_role(effective_role)

        # 创建工作区模板文件
        self._create_workspace_templates(effective_role)

        # 初始化子任务管理器
        self.subagents = SubagentManager(
            provider=provider,
            workspace=workspace,
            bus=bus,
            model=self.model,
            brave_api_key=brave_api_key,
        )

        # 运行状态标志
        self._running = False

        # 根据角色注册工具
        asyncio.run(self._register_role_tools())

    def _get_cron_store_path(self) -> Path:
        """Resolve cron store path for current instance."""
        if self.instance:
            return Path.home() / ".echobot" / "instances" / self.instance / "cron" / "jobs.json"
        return Path.home() / ".echobot" / "cron" / "jobs.json"

    def set_cron_service(self, cron_service: Any | None) -> None:
        """
        Inject running cron service.

        This allows cron tools to operate on the live scheduler instead of
        creating disconnected service instances.
        """
        self._cron_service = cron_service

    # =========================================================================
    # Security / Hooks public methods
    # =========================================================================

    def list_pending_approvals(self) -> list[dict[str, Any]]:
        """List pending tool execution approvals."""
        return self.security.list_pending_approvals()

    def approve_pending_request(self, request_id: str, approver: str | None = None) -> bool:
        """Approve one pending security request."""
        return self.security.approve(request_id=request_id, approver=approver or "user")

    def reject_pending_request(self, request_id: str, approver: str | None = None) -> bool:
        """Reject one pending security request."""
        return self.security.reject(request_id=request_id, approver=approver or "user")

    def list_webhook_subscriptions(self) -> list[dict[str, Any]]:
        """List outbound webhook subscriptions."""
        return self.hooks.webhooks.list_subscriptions()

    def add_webhook_subscription(
        self,
        url: str,
        events: list[str] | None = None,
        secret: str = "",
        headers: dict[str, str] | None = None,
        max_retries: int = 2,
        enabled: bool = True,
    ) -> dict[str, Any]:
        """Add an outbound webhook subscription."""
        return self.hooks.webhooks.add_subscription(
            url=url,
            events=events,
            secret=secret,
            headers=headers,
            max_retries=max_retries,
            enabled=enabled,
        )

    def remove_webhook_subscription(self, subscription_id: str) -> bool:
        """Remove outbound webhook subscription."""
        return self.hooks.webhooks.remove_subscription(subscription_id)

    def verify_inbound_webhook(self, headers: dict[str, str], body: bytes) -> tuple[bool, str]:
        """Verify inbound webhook signature and replay protection."""
        return self.hooks.verify_inbound(headers=headers, body=body)

    def set_inbound_webhook_secret(self, secret: str) -> None:
        """Update inbound webhook secret at runtime."""
        self.hooks.set_inbound_secret(secret)

    @property
    def is_processing(self) -> bool:
        """检查是否正在处理任务"""
        return self._current_task is not None

    @property
    def current_task_info(self) -> dict | None:
        """获取当前任务信息"""
        if self._current_task and self._task_start_time:
            import time

            elapsed = time.time() - self._task_start_time
            return {"task": self._current_task, "elapsed_seconds": round(elapsed, 1)}
        return None

    async def _register_role_tools(self) -> None:
        """
        根据当前角色配置注册工具

        每个角色可以配置：
        - tools.default: 基础工具（所有角色可用）
        - tools.exclusive: 专属工具（仅当前角色可用）

        Returns:
            None
        """
        # 连接 MCP 服务器（如果尚未连接）
        if self._mcp_client and not self._mcp_client.is_connected:
            await self._mcp_client.connect_all()

        # 清空现有工具
        self.tools = ToolRegistry()

        # 获取角色配置的工具列表
        role_tools = self.role_manager.get_tools()
        default_tools = role_tools.get("default", [])
        exclusive_tools = role_tools.get("exclusive", [])

        logger.debug(
            f"Registering tools for role: default={default_tools}, exclusive={exclusive_tools}"
        )

        # 注册文件工具
        if "read" in default_tools or "read" in exclusive_tools:
            # 所有文件工具都绑定到当前工作区根目录，防止越界访问。
            self.tools.register(ReadFileTool(root_dir=self.workspace))
        if "write" in default_tools or "write" in exclusive_tools:
            self.tools.register(WriteFileTool(root_dir=self.workspace))
        if "edit" in default_tools or "edit" in exclusive_tools:
            self.tools.register(EditFileTool(root_dir=self.workspace))
        if "list_dir" in default_tools or "list_dir" in exclusive_tools:
            self.tools.register(ListDirTool(root_dir=self.workspace))

        # 注册 Shell 工具
        if "exec" in default_tools or "exec" in exclusive_tools:
            # 默认允许访问 home 目录，可通过 ECHOBOT_EXEC_ALLOWED_DIRS 环境变量添加更多目录
            import os
            from pathlib import Path
            
            exec_allowed = os.environ.get("ECHOBOT_EXEC_ALLOWED_DIRS", "")
            extra_dirs = [Path(d.strip()) for d in exec_allowed.split(",") if d.strip()]
            
            # 优先用 home 目录作为 root
            root_dir = Path.home()
            self.tools.register(ExecTool(
                working_dir=str(self.workspace),
                root_dir=root_dir,
                extra_allowed_dirs=extra_dirs if extra_dirs else None
            ))

        # 注册技能脚本工具
        # 说明：该工具用于运行 skills/<name>/scripts 下的脚本，提升技能自动化能力。
        if "skill_script" in default_tools or "skill_script" in exclusive_tools:
            self.tools.register(RunSkillScriptTool(workspace=self.workspace))

        # 注册 Web 工具
        if "web_search" in default_tools or "web_search" in exclusive_tools:
            self.tools.register(WebSearchTool(api_key=self.brave_api_key))
        if "web_fetch" in default_tools or "web_fetch" in exclusive_tools:
            self.tools.register(WebFetchTool())

        # 注册目录管理工具（运行时添加/删除允许的目录）
        if "dir_manager" in default_tools or "dir_manager" in exclusive_tools:
            self.tools.register(DirManagerTool())

        # 注册消息工具（始终需要）
        message_tool = MessageTool(send_callback=self.bus.publish_outbound)
        self.tools.register(message_tool)

        # 注册子任务工具
        if "spawn" in default_tools or "spawn" in exclusive_tools:
            spawn_tool = SpawnTool(manager=self.subagents)
            self.tools.register(spawn_tool)

        # 注册 Cron 工具（提醒/定时任务）
        # 设计约束：定时提醒属于系统级能力，需跨角色稳定可用，避免因角色切换导致“无法定时”。
        cron_store_path = self._get_cron_store_path()
        cron_service_getter = lambda: self._cron_service
        self.tools.register(
            CreateCronJobTool(
                store_path=cron_store_path,
                service_getter=cron_service_getter,
            )
        )
        self.tools.register(
            ListCronJobsTool(
                store_path=cron_store_path,
                service_getter=cron_service_getter,
            )
        )
        self.tools.register(
            RemoveCronJobTool(
                store_path=cron_store_path,
                service_getter=cron_service_getter,
            )
        )

        # 注册 MCP 工具
        if self._mcp_client and self._mcp_client.is_connected:
            mcp_tools = self._mcp_client.get_all_tools()
            for tool_def in mcp_tools:
                self.tools.register(MCPTool(self._mcp_client, tool_def))
            logger.info(f"Registered {len(mcp_tools)} MCP tools")

        logger.debug(f"Registered {len(self.tools._tools)} tools for role")

    def _register_default_tools(self) -> None:
        """
        注册默认工具集（不再使用，统一使用 _register_role_tools）

        默认工具集包括：
        1. 文件工具：read_file、write_file、edit_file、list_dir
        2. Shell 工具：exec（执行 Shell 命令）
        3. Web 工具：web_search、web_fetch
        4. 消息工具：message（发送消息给用户）
        5. 子任务工具：spawn（创建后台子任务）

        Returns:
            None
        """
        # 文件工具
        self.tools.register(ReadFileTool(root_dir=self.workspace))
        self.tools.register(WriteFileTool(root_dir=self.workspace))
        self.tools.register(EditFileTool(root_dir=self.workspace))
        self.tools.register(ListDirTool(root_dir=self.workspace))

        # Shell 工具 - 默认访问 home 目录
        import os
        from pathlib import Path
        
        exec_allowed = os.environ.get("ECHOBOT_EXEC_ALLOWED_DIRS", "")
        extra_dirs = [Path(d.strip()) for d in exec_allowed.split(",") if d.strip()]
        
        root_dir = Path.home()
        self.tools.register(ExecTool(
            working_dir=str(self.workspace),
            root_dir=root_dir,
            extra_allowed_dirs=extra_dirs if extra_dirs else None
        ))
        self.tools.register(RunSkillScriptTool(workspace=self.workspace))

        # Web 工具
        self.tools.register(WebSearchTool(api_key=self.brave_api_key))
        self.tools.register(WebFetchTool())

        # 消息工具
        message_tool = MessageTool(send_callback=self.bus.publish_outbound)
        self.tools.register(message_tool)

        # 子任务工具
        spawn_tool = SpawnTool(manager=self.subagents)
        self.tools.register(spawn_tool)

    def _get_context_history(self, session_key: str) -> list[dict[str, Any]]:
        """
        获取用于上下文构建的历史消息。

        设计说明：
        - 开启压缩时，会在历史前插入摘要消息；
        - 关闭压缩时，行为与旧版本一致（仅最近 N 条原文）。
        """
        session = self.sessions.get_or_create(session_key)
        return session.get_history(
            max_messages=self.context_history_max_messages,
            include_summary=self.context_compression_enabled,
        )

    def _compress_session_if_needed(self, session_key: str) -> None:
        """
        在会话较长时执行滚动压缩。

        压缩结果会写回 `session.metadata["history_summary"]`，
        后续构建 prompt 时自动注入，达到“保留关键信息 + 控制 token”的目的。
        """
        if not self.context_compression_enabled:
            return

        session = self.sessions.get_or_create(session_key)
        changed = session.compress_history(
            max_messages=self.context_history_max_messages,
            keep_recent_messages=self.context_keep_recent_messages,
            summary_max_chars=self.context_summary_max_chars,
        )
        if changed:
            logger.debug(f"Compressed session history: {session_key}")

    async def _execute_tool_with_security(
        self,
        *,
        session_key: str,
        tool_name: str,
        tool_args: dict[str, Any],
        tool_call_id: str,
    ) -> str:
        """
        带安全门控执行工具调用。

        执行流程：
        1. 策略判定（allow/approve/deny）
        2. deny -> 直接返回阻断信息
        3. approve -> 返回审批提示，不执行工具
        4. allow -> 触发 before/after_tool hooks 并执行
        """
        gate = self.security.check_tool_execution(
            session_key=session_key,
            tool_name=tool_name,
            params=tool_args,
        )
        if gate.action == "deny":
            # 直接阻断，不进入工具执行环节。
            return f"Security blocked '{tool_name}': {gate.message}"

        if gate.action == "approve":
            # 将 request_id 回传给模型/用户，等待外部审批后重试。
            return (
                f"Approval required for '{tool_name}'. "
                f"request_id={gate.request_id}. "
                "Use approval API to approve/reject, then ask me to retry."
            )

        # 放行后，先发 before_tool 事件，便于外部观测/审计。
        await self.hooks.emit(
            "before_tool",
            {
                "session_key": session_key,
                "tool_name": tool_name,
                "tool_call_id": tool_call_id,
                "tool_args": tool_args,
            },
        )
        result = await self.tools.execute(tool_name, tool_args)
        # 执行完成后发 after_tool 事件（只带预览，避免外部通道过载）。
        await self.hooks.emit(
            "after_tool",
            {
                "session_key": session_key,
                "tool_name": tool_name,
                "tool_call_id": tool_call_id,
                "result_preview": result[:500],
            },
        )
        self.security.audit.log(
            "tool.execution.completed",
            session_key=session_key,
            tool_name=tool_name,
            request_id=gate.request_id,
            tool_call_id=tool_call_id,
        )
        return result

    async def run(self) -> None:
        """
        启动 Agent 循环

        这是一个无限循环，持续：
        1. 从消息总线等待入站消息
        2. 处理消息
        3. 发送响应

        可通过 stop() 方法优雅停止

        Returns:
            None

        使用示例：
            agent = AgentLoop(bus, provider, workspace)
            await agent.run()  # 会阻塞直到 stop()
        """
        self._running = True
        logger.info("Agent loop started")

        while self._running:
            try:
                # 等待下一条消息（超时 1 秒以检查停止标志）
                msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)

                # 检查是否正在处理任务
                if self._current_task:
                    task_info = self.current_task_info
                    if task_info:
                        busy_message = (
                            f"已接受到任务，正在处理「{task_info['task']}」"
                            f"（已运行 {task_info['elapsed_seconds']} 秒）"
                            f"\n\n请稍后，我处理完当前任务后会立即响应您。"
                        )
                    else:
                        busy_message = "已接受到任务，正在处理，请稍后..."

                    await self.bus.publish_outbound(
                        OutboundMessage(
                            channel=msg.channel,
                            chat_id=msg.chat_id,
                            content=busy_message,
                        )
                    )
                    continue

                # 处理消息
                try:
                    # 所有入口统一走串行执行，避免并发请求同时修改会话/角色状态。
                    response = await self._process_message_serial(msg)
                    if response:
                        await self.bus.publish_outbound(response)
                except Exception as e:
                    import traceback
                    logger.error(f"Error processing message: {e}")
                    logger.error(f"Traceback: {traceback.format_exc()}")
                    # 发送错误响应
                    await self.bus.publish_outbound(
                        OutboundMessage(
                            channel=msg.channel,
                            chat_id=msg.chat_id,
                            content=f"Sorry, I encountered an error: {str(e)}",
                        )
                    )
            except asyncio.TimeoutError:
                # 超时后继续循环，检查停止标志
                continue

    def stop(self) -> None:
        """
        停止 Agent 循环

        设置停止标志，下一轮循环时会退出

        Returns:
            None
        """
        self._running = False
        logger.info("Agent loop stopping")

    # =========================================================================
    # 角色相关方法
    # =========================================================================

    def switch_role(self, role_name: str) -> bool:
        """
        切换当前角色

        Args:
            role_name: 角色名称（如 "default", "coder", "tester"）

        Returns:
            是否切换成功
        """
        success = self.role_manager.switch_role(role_name)
        if success:
            # 切换成功后重新注册工具
            asyncio.run(self._register_role_tools())
            logger.info(
                f"Switched to role '{role_name}' and registered {len(self.tools._tools)} tools"
            )
        return success

    def get_current_role(self) -> str:
        """
        获取当前角色名称

        Returns:
            当前角色名称
        """
        return self.role_manager.get_current_role().name

    def get_role_info(self, role_name: str) -> dict | None:
        """
        获取指定角色信息

        Args:
            role_name: 角色名称

        Returns:
            角色信息字典，不存在则返回 None
        """
        return self.role_manager.get_role_info(role_name)

    def list_roles(self) -> list[dict]:
        """
        列出所有可用角色

        Returns:
            角色信息列表
        """
        return self.role_manager.list_roles()

    async def _process_message_serial(self, msg: InboundMessage) -> OutboundMessage | None:
        """
        串行处理消息，避免并发竞态。

        背景：
        - Gateway 既可能通过消息总线投递消息，也可能通过 WebUI 直接调用 `process_direct`。
        - 如果并发进入 `_process_message`，会同时读写会话、角色和当前任务状态，存在状态串扰风险。

        策略：
        - 统一使用 `asyncio.Lock` 串行化处理入口。
        - 只在入口加锁，核心处理逻辑仍保持在 `_process_message` 内，便于维护。
        """
        async with self._task_lock:
            return await self._process_message(msg)

    async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
        """
        处理单条入站消息

        处理流程：
        1. 识别消息类型（普通消息或系统消息）
        2. 获取或创建会话
        3. 更新工具上下文
        4. 构建消息列表
        5. 执行 Agent 循环（LLM 调用 + 工具执行）
        6. 保存会话
        7. 返回响应消息

        Args:
            msg: 入站消息对象，包含 channel、sender_id、chat_id、content 等

        Returns:
            OutboundMessage: 响应消息，如果不需要响应则返回 None

        异常：
            可能抛出异常，由上层捕获并返回错误消息
        """
        # 处理系统消息（子任务通知）
        # chat_id 包含原始的 "channel:chat_id" 用于路由回原始位置
        if msg.channel == "system":
            return await self._process_system_message(msg)

        logger.info(f"Processing message from {msg.channel}:{msg.sender_id}")
        logger.debug(f"Message session_key: {msg.session_key}")
        # Hook: 收到新消息（用于外部监控、埋点、审计流转）。
        await self.hooks.emit(
            "message.received",
            {
                "session_key": msg.session_key,
                "channel": msg.channel,
                "sender_id": msg.sender_id,
                "chat_id": msg.chat_id,
            },
        )

        # 提取任务描述（用于状态显示）
        task_preview = msg.content[:30] + "..." if len(msg.content) > 30 else msg.content

        # 设置任务状态
        import time

        self._current_task = task_preview
        self._task_start_time = time.time()
        logger.info(f"Task started: {task_preview}")

        try:
            # 获取或创建会话
            session = self.sessions.get_or_create(msg.session_key)

            # 打印当前角色状态
            logger.debug(f"Agent manual_role: {self._manual_role}")
            logger.debug(f"RoleManager current_role: {self.role_manager.get_current_role().name}")

            # 自动选择角色（仅当没有手动指定角色时）
            if not self._manual_role:
                # 自动选角前先记录旧角色，后续用于判断是否需要刷新工具注册表。
                previous_role = self.role_manager.get_current_role().name
                self.role_manager.auto_select_role(msg.content)
                current_role = self.role_manager.get_current_role().name

                # 关键修复：
                # 自动切换角色后，必须立即重建工具集，
                # 否则会出现"提示词角色已切换，但可调用工具仍是旧角色"的不一致问题。
                if current_role != previous_role:
                    logger.info(f"Role auto-switched: {previous_role} -> {current_role}")
                    await self._register_role_tools()
            else:
                logger.debug(f"Using manual role: {self._manual_role}")

            # 获取当前角色的技能内容
            role_content = self.role_manager.get_skill_content()
            logger.debug(f"Role content length: {len(role_content) if role_content else 0}")
            logger.debug(f"Current role: {self.role_manager.get_current_role().name}")

            # 更新工具上下文
            message_tool = self.tools.get("message")
            if isinstance(message_tool, MessageTool):
                message_tool.set_context(msg.channel, msg.chat_id)

            spawn_tool = self.tools.get("spawn")
            if isinstance(spawn_tool, SpawnTool):
                spawn_tool.set_context(msg.channel, msg.chat_id)

            cron_tool = self.tools.get("create_cron_job")
            if isinstance(cron_tool, CreateCronJobTool):
                cron_tool.set_context(msg.channel, msg.chat_id)

            # 构建消息列表（包含角色内容）
            # 获取当前角色的技能名称列表
            role_skill_names = self.role_manager.get_skill_names()

            # 检查是否有 MCP 图片理解工具
            mcp_client = None
            for tool in self.tools._tools.values():
                if tool.name == "understand_image" and hasattr(tool, "_mcp_client"):
                    mcp_client = tool._mcp_client
                    break
            has_image_tool = mcp_client is not None

            # 如果有图片，直接使用 MiniMax API 分析图片
            image_description = None
            if msg.media:
                try:
                    import aiohttp
                    import base64

                    image_path = msg.media[0]
                    logger.info(f"[IMG] Analyzing image: {image_path}")

                    # 读取图片并转为 base64
                    with open(image_path, "rb") as f:
                        image_b64 = base64.b64encode(f.read()).decode()

                    # 调用 MiniMax vision API - 使用正确的端点
                    api_key = "sk-cp-321-8CaOdWwNvtlQqqZr445RoXcOv9wIE-s7JPHTjt2UiipmE5TRECdQJ2yU0XPSvXWDan-wTFSgO7CYOmaiOre3kr3c3nOD_fjnc6F6DVJsHywohIVynJI"
                    api_base = "https://api.minimaxi.com/v1"

                    async with aiohttp.ClientSession() as http_session:
                        payload = {
                            "model": "abab6.5s-chat",
                            "messages": [{
                                "role": "user",
                                "content": [
                                    {
                                        "type": "image_url",
                                        "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
                                    },
                                    {
                                        "type": "text",
                                        "text": "请详细描述这张图片的所有内容，包括文字、界面元素等"
                                    }
                                ]
                            }],
                            "max_tokens": 1024
                        }
                        headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}

                        async with http_session.post(f"{api_base}/text/chatcompletion_v2", json=payload, headers=headers) as resp:
                            if resp.status == 200:
                                result = await resp.json()
                                choices = result.get("choices", [])
                                if choices:
                                    image_description = choices[0].get("message", {}).get("content", "")
                                    logger.info(f"[IMG] Image description: {image_description[:200]}...")
                                else:
                                    logger.error(f"[IMG] No choices in response: {result}")
                            else:
                                error_text = await resp.text()
                                logger.error(f"[IMG] API error: {resp.status}, {error_text}")
                    msg.media = []  # 清空 media
                except Exception as e:
                    import traceback
                    logger.error(f"[IMG] Failed to analyze image: {e}")
                    logger.error(f"[IMG] Traceback: {traceback.format_exc()}")

            # 如果自动获取了图片描述，将其添加到用户消息中
            if image_description:
                enhanced_content = f"{msg.content}\n\n[图片分析结果]: {image_description}"
            else:
                enhanced_content = msg.content

            messages = self.context.build_messages(
                history=self._get_context_history(msg.session_key),
                current_message=enhanced_content,
                media=msg.media if msg.media else None,
                role_content=role_content,
                role_skill_names=role_skill_names,
                has_image_tool=False,  # 已手动处理，不再需要提示
            )

            # Agent 循环
            iteration = 0
            final_content = None

            while iteration < self.max_iterations:
                iteration += 1

                # 调用 LLM
                # Hook: LLM 调用前事件，便于记录上下文规模、模型等元信息。
                await self.hooks.emit(
                    "before_llm",
                    {
                        "session_key": msg.session_key,
                        "iteration": iteration,
                        "messages_count": len(messages),
                        "model": self.model,
                    },
                )
                # 调试：检查工具定义
                tool_defs = self.tools.get_definitions()
                response = await self.provider.chat(
                    messages=messages, tools=tool_defs, model=self.model
                )
                # Hook: LLM 调用后事件，输出 usage/finish_reason 等。
                await self.hooks.emit(
                    "after_llm",
                    {
                        "session_key": msg.session_key,
                        "iteration": iteration,
                        "finish_reason": response.finish_reason,
                        "usage": response.usage,
                        "has_tool_calls": response.has_tool_calls,
                    },
                )

                # 处理工具调用
                if response.has_tool_calls:
                    # 添加包含工具调用的助手消息
                    tool_call_dicts = [
                        {
                            "id": tc.id,
                            "type": "function",
                            "function": {
                                "name": tc.name,
                                "arguments": json.dumps(tc.arguments),  # 必须为 JSON 字符串
                            },
                        }
                        for tc in response.tool_calls
                    ]
                    messages = self.context.add_assistant_message(
                        messages, response.content, tool_call_dicts
                    )

                    # 执行工具
                    for tool_call in response.tool_calls:
                        args_str = json.dumps(tool_call.arguments)
                        logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
                        result = await self._execute_tool_with_security(
                            session_key=msg.session_key,
                            tool_name=tool_call.name,
                            tool_args=tool_call.arguments,
                            tool_call_id=tool_call.id,
                        )
                        messages = self.context.add_tool_result(
                            messages, tool_call.id, tool_call.name, result
                        )
                else:
                    # 没有工具调用，处理完成
                    final_content = response.content
                    break

            if final_content is None:
                final_content = "I've completed processing but have no response to give."

            # 自动提取用户偏好并写入长期记忆（失败不影响主流程）。
            try:
                remembered = self.memory_auto.remember_from_message(msg.content)
                if remembered:
                    logger.info(f"Auto-saved {len(remembered)} preference note(s) to memory")
            except Exception as e:
                logger.warning(f"Failed to auto-save preference memory: {e}")

            # 保存会话
            session.add_message("user", msg.content)
            session.add_message("assistant", final_content)
            self._compress_session_if_needed(msg.session_key)
            self.sessions.save(session)
            # Hook: 响应已生成并写入会话。
            await self.hooks.emit(
                "message.responded",
                {
                    "session_key": msg.session_key,
                    "channel": msg.channel,
                    "chat_id": msg.chat_id,
                    "response_preview": final_content[:500],
                },
            )

            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=final_content)

        finally:
            # 无论成功还是失败，都清除任务状态
            self._current_task = None
            self._task_start_time = None
            logger.info(f"Task completed: {task_preview}")

    async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
        """
        处理系统消息（如子任务通知）

        系统消息是子任务完成后的通知消息。
        chat_id 字段包含 "original_channel:original_chat_id" 格式的原始目标，
        用于将响应路由回正确的位置。

        Args:
            msg: 系统消息，包含子任务完成信息

        Returns:
            OutboundMessage: 响应消息
        """
        logger.info(f"Processing system message from {msg.sender_id}")
        # Hook: 系统消息（例如子任务回传）进入主链路。
        await self.hooks.emit(
            "system_message.received",
            {"sender_id": msg.sender_id, "chat_id": msg.chat_id},
        )

        # 解析原始目标（格式: "channel:chat_id"）
        if ":" in msg.chat_id:
            parts = msg.chat_id.split(":", 1)
            origin_channel = parts[0]
            origin_chat_id = parts[1]
        else:
            # 默认值
            origin_channel = "cli"
            origin_chat_id = msg.chat_id

        # 使用原始会话获取上下文
        session_key = f"{origin_channel}:{origin_chat_id}"
        session = self.sessions.get_or_create(session_key)

        # 更新工具上下文
        message_tool = self.tools.get("message")
        if isinstance(message_tool, MessageTool):
            message_tool.set_context(origin_channel, origin_chat_id)

        spawn_tool = self.tools.get("spawn")
        if isinstance(spawn_tool, SpawnTool):
            spawn_tool.set_context(origin_channel, origin_chat_id)

        cron_tool = self.tools.get("create_cron_job")
        if isinstance(cron_tool, CreateCronJobTool):
            cron_tool.set_context(origin_channel, origin_chat_id)

        # 构建消息列表
        messages = self.context.build_messages(
            history=self._get_context_history(session_key), current_message=msg.content
        )

        # Agent 循环（处理系统消息时限制迭代次数）
        iteration = 0
        final_content = None

        while iteration < self.max_iterations:
            iteration += 1

            await self.hooks.emit(
                "before_llm",
                {
                    "session_key": session_key,
                    "iteration": iteration,
                    "messages_count": len(messages),
                    "model": self.model,
                },
            )
            response = await self.provider.chat(
                messages=messages, tools=self.tools.get_definitions(), model=self.model
            )
            await self.hooks.emit(
                "after_llm",
                {
                    "session_key": session_key,
                    "iteration": iteration,
                    "finish_reason": response.finish_reason,
                    "usage": response.usage,
                    "has_tool_calls": response.has_tool_calls,
                },
            )

            if response.has_tool_calls:
                tool_call_dicts = [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
                    }
                    for tc in response.tool_calls
                ]
                messages = self.context.add_assistant_message(
                    messages, response.content, tool_call_dicts
                )

                for tool_call in response.tool_calls:
                    args_str = json.dumps(tool_call.arguments)
                    logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
                    result = await self._execute_tool_with_security(
                        session_key=session_key,
                        tool_name=tool_call.name,
                        tool_args=tool_call.arguments,
                        tool_call_id=tool_call.id,
                    )
                    messages = self.context.add_tool_result(
                        messages, tool_call.id, tool_call.name, result
                    )
            else:
                final_content = response.content
                break

        if final_content is None:
            final_content = "Background task completed."

        # 保存会话
        session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
        session.add_message("assistant", final_content)
        self._compress_session_if_needed(session_key)
        self.sessions.save(session)
        # Hook: 系统消息处理完成。
        await self.hooks.emit(
            "system_message.responded",
            {"session_key": session_key, "response_preview": final_content[:500]},
        )

        return OutboundMessage(
            channel=origin_channel, chat_id=origin_chat_id, content=final_content
        )

    async def process_direct(
        self,
        content: str,
        session_key: str | None = None,
        role_name: str | None = None,
    ) -> str:
        """
        直接处理消息（用于 CLI 使用）

        不通过消息总线，直接处理消息并返回响应。
        用于命令行交互模式。

        Args:
            content: 消息内容
            session_key: 会话标识符，默认为 None（每次新建会话）
            role_name: 可选，单次请求强制使用的角色名。处理完成后会恢复原角色。

        Returns:
            str: Agent 的响应文本

        使用示例：
            response = await agent.process_direct("Hello!", "cli:session1")
        """
        # 使用唯一会话ID，避免角色混淆
        import uuid

        # 关键修复：
        # 允许 `session_key` 直接传入 "channel:chat_id" 形式，
        # 使 direct 模式下的会话键与真实渠道语义一致。
        #
        # 兼容策略：
        # 1. 传入 "xxx:yyy" -> channel="xxx", chat_id="yyy"
        # 2. 仅传入 "abc"  -> channel="cli", chat_id="abc"
        # 3. 未传 session   -> channel="cli", chat_id=随机 ID
        direct_channel = "cli"
        unique_chat_id = session_key or f"direct_{uuid.uuid4().hex[:8]}"
        if session_key and ":" in session_key:
            channel_candidate, chat_candidate = session_key.split(":", 1)
            # 防御性处理：如果 channel 为空，回落为 cli，避免生成非法 session key。
            direct_channel = channel_candidate or "cli"
            unique_chat_id = chat_candidate or f"direct_{uuid.uuid4().hex[:8]}"

        msg = InboundMessage(
            channel=direct_channel, sender_id="user", chat_id=unique_chat_id, content=content
        )

        # 默认路径：不指定角色时沿用既有行为。
        if not role_name:
            response = await self._process_message_serial(msg)
            return response.content if response else ""

        # 角色覆盖路径：
        # 在同一把串行锁内完成“切角 -> 处理 -> 恢复”，避免并发请求相互污染。
        async with self._task_lock:
            previous_role = self.get_current_role()
            previous_manual_role = self._manual_role

            switched = self.role_manager.switch_role(role_name)
            if not switched:
                raise ValueError(f"Role not found: {role_name}")

            self._manual_role = role_name
            await self._register_role_tools()

            try:
                response = await self._process_message(msg)
                return response.content if response else ""
            finally:
                restore_role = previous_manual_role or previous_role
                self.role_manager.switch_role(restore_role)
                self._manual_role = previous_manual_role
                await self._register_role_tools()
