"""
core/context_builder.py - 上下文构建器
======================================
为 LLM 系统提示词构建结构化的 XML <context> 块。

<context> 包含以下段落:
  <whomi>            - Agent 身份信息（名称、描述）
  <automemory>       - 自动记忆检索结果（Top 10 相关记忆，由 <remember> 产生）
  <recall_memory>    - 主动召回记忆（上一轮 <recall> 触发的 Top 5 记忆）
  <knowledge>        - 知识库 RAG 检索结果（根据 get_knowledge 关键词搜索）
  <resentdialog>     - 近期对话历史（截断至 ~20K 字符）
  <userprint>        - 用户键盘输入文本（语音输入时为空）
  <usersays>         - 用户语音转文本输入（键盘输入时为空）
  <task_plan>        - 当前任务计划（Markdown 格式，上轮已完成时为空）
  <tools>            - 可用工具列表（名称、描述、参数格式）
  <skill_prompts>    - SKILL.md 格式技能的完整指令（Prompt 类型技能注入）

使用示例:
    builder = ContextBuilder(memory_manager=mm, skill_registry=sr)
    context_xml = builder.build_context(
        agent_name="助手",
        agent_description="通用AI助手",
        session_id="sess_1",
        conversation_history=[Message(role="user", content="你好")],
        user_typed_text="你好",
        user_voice_text="",
        task_plan="",
    )
    # context_xml 即为完整的 <context>...</context> XML 字符串
"""
from __future__ import annotations

import html
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from core.logger import get_logger

if TYPE_CHECKING:
    from memory.manager import MemoryManager
    from aiskills.registry import SkillRegistry
    from core.llm import Message

logger = get_logger("myagent.context_builder")


# ── 知识库 RAG 索引缓存（模块级，避免每次 LLM 调用重建） ──
_rag_cache: dict = {}  # {abs_kb_dir: {"rag": KnowledgeRAG, "mtime": str}}


def _compute_dir_mtime(dir_path: str) -> str:
    """计算目录下所有支持文件的修改时间摘要（用于脏检测）

    [v1.15.7] 修复: 使用 os.walk 递归扫描子目录（如 auto_knowledge/），
    之前仅扫描顶层目录，导致子目录新增文件无法被脏检测发现，
    造成 RAG 缓存使用过期索引。
    """
    import os
    _KB_EXTS = {".md", ".txt", ".json", ".csv", ".py", ".js", ".html"}
    mtimes = []
    try:
        for root, dirs, files in os.walk(dir_path):
            for f in sorted(files):
                ext = os.path.splitext(f)[1].lower()
                if ext in _KB_EXTS:
                    fp = os.path.join(root, f)
                    rel = os.path.relpath(fp, dir_path)
                    mtimes.append(f"{rel}:{os.path.getmtime(fp)}")
    except OSError:
        pass
    return "|".join(sorted(mtimes))


# 默认知识库目录名（相对于 data_dir）
_DEFAULT_KB_RELATIVE_PATH = "knowledge"


class ContextBuilder:
    """
    上下文构建器 —— 将 Agent 运行时状态组装为 <context> XML。

    该 XML 块会注入到 LLM 系统提示词中，为模型提供完整的上下文感知能力。

    上下文中的记忆分为两层:
      - <automemory>: 自动记忆 —— 由大模型 <remember> 产生并持久化的记忆，
        根据用户当前输入自动搜索 top10 相关记忆供参考。
      - <recall_memory>: 主动召回记忆 —— 大模型在上一轮通过 <recall> 标签
        指定要召回的记忆，系统根据关键字+时间搜索 top5 注入。

    Args:
        memory_manager: 记忆管理器实例（可选，传入后可检索相关记忆）
        skill_registry: 技能注册表实例（可选，传入后可列出可用工具）
        knowledge_base_dir: 知识库根目录路径（可选，传入后可通过 RAG 检索相关知识）
        max_dialog_chars: 对话历史最大字符数（默认 20000）
    """

    def __init__(
        self,
        memory_manager: Optional["MemoryManager"] = None,
        skill_registry: Optional["SkillRegistry"] = None,
        knowledge_base_dir: Optional[str] = None,
        max_dialog_chars: int = 20000,
        context_window: int = 200000,
        max_message_chars: int = 10000,
    ) -> None:
        self.memory_manager = memory_manager
        self.skill_registry = skill_registry
        self.knowledge_base_dir = knowledge_base_dir
        self.max_dialog_chars = max_dialog_chars
        self.context_window = context_window
        self.max_message_chars = max_message_chars
        # Agent 专属知识库目录（由调用方动态设置，优先于组织知识库）
        self.agent_knowledge_dir: Optional[str] = None

    # =========================================================================
    # 公共接口
    # =========================================================================

    def build_context(
        self,
        agent_name: str,
        agent_description: str,
        session_id: str,
        conversation_history: List["Message"],
        user_typed_text: str,
        user_voice_text: str,
        task_plan: str,
        agent_override_prompt: Optional[str] = None,
        get_knowledge: str = "",
        recall: str = "",
        memory_context_prompt: str = "",
        agent_path: Optional[str] = None,
    ) -> str:
        """
        构建完整的 <context> XML 字符串。

        Args:
            agent_name: Agent 名称（如 "通用助手"）
            agent_description: Agent 描述/人设
            session_id: 当前会话 ID（用于记忆检索）
            conversation_history: 对话历史消息列表
            user_typed_text: 用户键盘输入文本（语音输入时为空）
            user_voice_text: 用户语音转文本（键盘输入时为空）
            task_plan: 当前任务计划（Markdown 格式，无任务时为空）
            agent_override_prompt: 可选的 Agent 身份覆盖提示词
            get_knowledge: 上一轮 LLM 输出的 <get_knowledge> 内容，
                用于 RAG 搜索知识库。为空时使用用户消息作为查询。
            recall: 上一轮 LLM 输出的 <recall> 内容，
                用于定向检索长期记忆。为空时仅用用户消息搜索。
            agent_path: Agent 的数字 aid（如 "1", "2"），用于定位独立工作目录。

        Returns:
            完整的 <context>...</context> XML 字符串
        """
        # 确定记忆搜索的查询文本（使用最新用户消息）
        query = user_typed_text or user_voice_text or ""
        if not query and conversation_history:
            # 回溯最近一条用户消息作为查询
            for msg in reversed(conversation_history):
                if msg.role == "user" and msg.content.strip():
                    query = msg.content.strip()
                    break

        # 确定知识库搜索的查询文本
        # 优先使用 get_knowledge（LLM 指定的检索关键词），否则使用用户消息
        kb_query = get_knowledge.strip() if get_knowledge else query

        # ── [v1.28.1] 按缓存特性分两组：静态段落（可缓存） vs 动态段落（每次变化） ──
        # 静态段落：同 session 内基本不变，适合 prompt caching
        static_sections: List[str] = [
            self._build_whomi(agent_name, agent_description, agent_override_prompt, agent_path=agent_path),
            self._build_tools(self.skill_registry),
            self._build_skill_prompts(self.skill_registry),
            self._build_runtime_env(),
        ]
        # 动态段落：每轮 LLM 调用都可能不同
        # [FIX-失忆] 将 conversation_history 真正用于构建 <resentdialog> 段落
        # 之前 conversation_history 参数被完全忽略，_build_recent_dialog 从未被调用
        _recent_dialog_xml = ""
        if conversation_history:
            _recent_dialog_xml = self._build_recent_dialog(
                conversation_history=conversation_history,
                max_chars=int(self.context_window * 0.12),  # 约 12% 窗口用于对话
                session_id=session_id,
            )
        dynamic_sections: List[str] = [
            self._build_datetime(),
            self._build_memory(query, session_id, recall, memory_context_prompt),
            self._build_knowledge(kb_query),
            # 轻量近期对话兜底：最近几轮对话摘要，补充 automemory 搜索的盲区
            self._build_recent_summary(session_id),
        ]
        # [FIX-失忆] 插入完整的近期对话段落（如果有的话）
        if _recent_dialog_xml:
            dynamic_sections.append(_recent_dialog_xml)
        dynamic_sections.extend([
            self._build_user_input(user_typed_text, user_voice_text),
            self._build_task_plan(task_plan),
            self._build_exec_warnings(),
        ])

        # 合并为完整 context XML（保持向后兼容）
        all_sections = static_sections + dynamic_sections
        context_body = "\n".join(all_sections)
        context_xml = f"<context>\n{context_body}\n</context>"

        # ── Token 预算检查与自动裁剪 ──
        context_xml = self._enforce_token_budget(context_xml)

        # 构建静态/动态 XML 片段（供 prompt caching 使用）
        static_xml = "<context>\n" + "\n".join(static_sections)
        dynamic_xml = "\n".join(dynamic_sections) + "\n</context>"

        logger.debug(
            f"上下文已构建 (session={session_id}, 对话条数={len(conversation_history)}, "
            f"context长度={len(context_xml)}, static={len(static_xml)}, dynamic={len(dynamic_xml)})"
        )
        return context_xml, static_xml, dynamic_xml

    # =========================================================================
    # 各段落构建方法
    # =========================================================================

    def _build_datetime(self) -> str:
        """
        构建 <datetime> 段落 —— 当前日期时间（精确到秒）。
        让 LLM 知道当前时间，以便给出与时间相关的回答。
        使用配置的时区，而非系统时区。
        """
        from core.utils import get_config_tz
        from datetime import datetime
        tz = get_config_tz()
        now = datetime.now(tz)
        weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
        date_str = now.strftime("%Y年%m月%d日")
        time_str = now.strftime("%H:%M:%S")
        weekday = weekdays[now.weekday()]
        return (
            f"<datetime>\n"
            f"当前时间: {date_str} {weekday} {time_str}\n"
            f"时区: {tz}\n"
            f"</datetime>"
        )

    def _build_whomi(
        self,
        agent_name: str,
        agent_description: str,
        agent_override_prompt: Optional[str] = None,
        agent_path: Optional[str] = None,
    ) -> str:
        """
        构建 <whomi> 段落 —— Agent 身份信息。

        包含名称、描述，以及可选的身份覆盖提示词。

        Args:
            agent_name: Agent 名称
            agent_description: Agent 描述
            agent_override_prompt: 可选的覆盖提示词
            agent_path: Agent 的数字 aid（用于定位独立工作目录）

        Returns:
            <whomi> XML 段落字符串
        """
        safe_name = _xml_escape(agent_name)
        safe_desc = _xml_escape(agent_description)

        parts = [
            f"<whomi>",
            f"名称: {safe_name}",
            f"描述: {safe_desc}",
        ]

        # [v1.20.8] [v1.23.52] 注入工作目录信息，每个 Agent 使用独立工作目录
        work_dir = self._get_workspace_dir(agent_path=agent_path)
        if work_dir:
            parts.append(f"工作目录: {_xml_escape(str(work_dir))}")
            parts.append(f"文件保存说明: 通过 file_write 或代码生成的文件请保存到工作目录下的 userfiles 子目录。发送文件给用户请使用 file_send 工具。")

        if agent_override_prompt:
            parts.append(f"附加指令: {_xml_escape(agent_override_prompt)}")

        parts.append("</whomi>")
        return "\n".join(parts)

    def _get_workspace_dir(self, agent_path: Optional[str] = None) -> Optional[str]:
        """[v1.20.8] [v1.23.52] 获取工作目录路径

        所有 Agent 均使用基于数字 aid 的独立工作目录：
          ~/.myagent/data/agents/{aid}/workspace/
        无 agent_path 时返回全局工作目录：
          ~/.myagent/data/workspace/
        独立目录下自动创建 userfiles 子目录。
        """
        try:
            from config import ConfigManager
            import os
            cm = ConfigManager()
            if agent_path:
                # 使用 Agent 独立工作目录（基于数字 aid）
                wd = cm.data_dir / "agents" / agent_path / "workspace"
                wd.mkdir(parents=True, exist_ok=True)
                userfiles = wd / "userfiles"
                userfiles.mkdir(parents=True, exist_ok=True)
                return str(wd)
            else:
                # 无 agent_path 时使用全局工作目录
                wd = cm.data_dir / "workspace"
                wd.mkdir(parents=True, exist_ok=True)
                return str(wd)
        except Exception:
            return None

    def _build_memory(self, query: str, session_id: str, recall: str = "", memory_context_prompt: str = "") -> str:
        """
        构建 <automemory> 和 <recall_memory> 段落 —— 双层记忆检索结果。

        <automemory>: 根据用户当前输入自动搜索 top10 相关记忆。
            这些记忆来自大模型通过 <remember> 标签持久化的内容（包含时间信息）。
            搜索范围: 全局记忆(global) + 当前会话的 remember 类记忆。

        <recall_memory>: 上一轮 LLM 输出的 <recall> 内容触发的主动召回。
            根据关键字和时间点搜索 top5 历史记忆，注入到本轮上下文中。
            如果上一轮未输出 <recall>，则此段为空。

        Args:
            query: 搜索查询文本（通常为最新用户消息）
            session_id: 会话 ID
            recall: LLM 上一轮输出的 <recall> 内容（关键字+时间描述）
            memory_context_prompt: MemoryAgent 预加载的用户偏好/错误模式（直接注入）

        Returns:
            <automemory> + <recall_memory> XML 段落字符串
        """
        if not self.memory_manager:
            return "<automemory>\n(记忆系统未启用)\n</automemory>\n<recall_memory>\n(记忆系统未启用)\n</recall_memory>"

        # ═══════════════════════════════════════════
        # Part 1: <automemory> — 自动记忆检索
        # ═══════════════════════════════════════════
        search_query = query.strip()
        auto_lines: List[str] = []

        if search_query:
            try:
                # 搜索全局记忆中由 remember 产生的内容
                global_results = self.memory_manager.search(
                    query=search_query,
                    session_id="",  # 跨会话搜索全局记忆
                    category="global",
                    limit=10,
                    mode="hybrid",
                )
                # 搜索当前会话中 conversation_insight 类记忆
                session_results = self.memory_manager.search(
                    query=search_query,
                    session_id=session_id,
                    category="session",
                    limit=5,
                    mode="hybrid",
                )
                # 合并去重（全局优先，会话补充）
                seen_ids = set()
                combined = []
                for entry in global_results + session_results:
                    if entry.id not in seen_ids:
                        seen_ids.add(entry.id)
                        combined.append(entry)

                if combined:
                    auto_lines.append("<automemory>")
                    # 注入 MemoryAgent 预加载的用户偏好/错误模式
                    if memory_context_prompt and memory_context_prompt.strip():
                        auto_lines.append(_xml_escape(memory_context_prompt.strip()))
                    for i, entry in enumerate(combined[:10], 1):
                        content = entry.content.strip()
                        if content:
                            auto_lines.append(f"{i}. {_xml_escape(content)}")
                    auto_lines.append("</automemory>")
            except Exception as e:
                logger.warning(f"automemory 搜索失败: {e}")

        if not auto_lines:
            auto_lines = ["<automemory>", "(无相关自动记忆)", "</automemory>"]

        # ═══════════════════════════════════════════
        # Part 2: <recall_memory> — 主动召回记忆
        # ═══════════════════════════════════════════
        recall_lines: List[str] = []
        recall_text = recall.strip() if recall else ""

        if recall_text:
            try:
                # 解析 recall 中的关键字和时间描述
                # 格式可能是: "关键字1 关键字2" 或 "关于XX的记忆 2025年1月" 等
                recall_results = self.memory_manager.search(
                    query=recall_text,
                    session_id="",  # 跨会话搜索
                    limit=5,
                    mode="hybrid",
                )
                if recall_results:
                    recall_lines.append("<recall_memory>")
                    for i, entry in enumerate(recall_results[:5], 1):
                        content = entry.content.strip()
                        if content:
                            recall_lines.append(f"{i}. {_xml_escape(content)}")
                    recall_lines.append("</recall_memory>")
            except Exception as e:
                logger.warning(f"recall_memory 搜索失败: {e}")

        if not recall_lines:
            recall_lines = ["<recall_memory>", "(无主动召回记忆)", "</recall_memory>"]

        return "\n".join(auto_lines) + "\n" + "\n".join(recall_lines)

    def _build_knowledge(self, query: str) -> str:
        """
        构建 <knowledge> 段落 —— 知识库 RAG 检索结果。

        [v1.15.7] 用户隔离: 当 agent_knowledge_dir 已设置时，仅搜索当前
        Agent 专属知识库，不再回退到组织知识库（避免搜索到其他用户的文件）。
        仅在 agent_knowledge_dir 未设置时才搜索组织知识库（兼容无专属知识库的 Agent）。

        Args:
            query: 搜索查询文本（通常为 <get_knowledge> 内容或用户消息）

        Returns:
            <knowledge> XML 段落字符串
        """
        # Agent 专属知识库已设置 → 仅搜索当前用户/Agent 的知识
        if self.agent_knowledge_dir:
            agent_result = self._search_knowledge_dir(self.agent_knowledge_dir, query, top_k=5)
            if agent_result:
                return agent_result
            # [v1.15.7] 不再回退到组织知识库，避免搜索到其他用户的文件
            return "<knowledge>\n(未找到相关知识)\n</knowledge>"

        # 未设置 Agent 专属知识库 → 搜索组织知识库（兼容模式）
        if self.knowledge_base_dir:
            org_result = self._search_knowledge_dir(self.knowledge_base_dir, query, top_k=5)
            if org_result:
                return org_result
            return "<knowledge>\n(未找到相关知识)\n</knowledge>"

        return "<knowledge>\n(知识库未配置)\n</knowledge>"

    def _search_knowledge_dir(self, kb_dir: str, query: str, top_k: int = 5) -> str:
        """在指定知识库目录中执行 RAG 搜索并格式化结果

        使用模块级缓存 + 文件修改时间脏检测，避免每次 LLM 调用都重建索引。
        """
        import os as _os

        if not query.strip():
            return ""

        if not kb_dir or not _os.path.isdir(kb_dir):
            return ""

        try:
            from knowledge.rag import KnowledgeRAG

            # ── 缓存键: 目录绝对路径 ──
            abs_kb = _os.path.abspath(kb_dir)
            cache = _rag_cache.get(abs_kb)
            need_rebuild = True

            if cache is not None:
                # 脏检测: 比较上次记录的文件修改时间摘要
                current_mtime = _compute_dir_mtime(abs_kb)
                if current_mtime == cache["mtime"]:
                    need_rebuild = False
                else:
                    logger.debug(f"知识库目录变更检测到 ({abs_kb})，重建索引")

            if need_rebuild:
                rag = KnowledgeRAG(kb_dir=kb_dir)
                rag.build_index()
                _rag_cache[abs_kb] = {
                    "rag": rag,
                    "mtime": _compute_dir_mtime(abs_kb),
                }
                rebuild_tag = "重新" if cache else ""
                logger.debug(f"知识库索引已{rebuild_tag}构建: {rag.total_chunks} 块 ({abs_kb})")
            else:
                rag = cache["rag"]

            if rag.total_chunks == 0:
                return ""

            results = rag.search(query, top_k=top_k)

            if not results:
                return ""

            lines: List[str] = ["<knowledge>"]
            for i, chunk in enumerate(results, 1):
                content = chunk.content.strip()
                if content:
                    source = chunk.file_name or "unknown"
                    score_str = f"(相似度: {chunk.score:.3f})" if chunk.score else ""
                    lines.append(f"{i}. [{source}] {score_str}")
                    lines.append(_xml_escape(content))
                    lines.append("")

            lines.append("</knowledge>")
            return "\n".join(lines)

        except Exception as e:
            logger.warning(f"知识库 RAG 检索失败 ({kb_dir}): {e}")
            return ""

    def _build_recent_summary(self, session_id: str) -> str:
        """
        构建 <recent_summary> 段落 —— 最近几轮对话的轻量摘要。

        作为 automemory 搜索的兜底机制，确保 LLM 至少能看到最近几轮
        对话的基本脉络。

        [FIX-失忆] 增加条数和字符限制：
        - 条数: 6 → 12（约 6 轮 user+assistant）
        - 单条截断: 200 → 500 字
        - 总字符: 1500 → 6000
        这样即使对话历史作为消息轮次注入失败，recent_summary 也能提供
        更充足的上下文信息。
        """
        if not self.memory_manager or not session_id:
            return ""

        try:
            from core.utils import truncate_str
            # 只取 user 和 assistant 角色，排除内部审计条目
            entries = self.memory_manager.get_conversation(
                session_id=session_id,
                limit=12,
                include_roles=["user", "assistant"],
            )
            if not entries:
                return ""

            role_labels = {"user": "用户", "assistant": "助手"}
            lines = []
            total_chars = 0
            for entry in reversed(entries):  # 最新的在前
                content = (entry.content or "").strip()
                if not content:
                    continue
                label = role_labels.get(entry.role, entry.role)
                # 截断过长内容（500字，足够保留核心语义）
                truncated = truncate_str(content, 500)
                lines.append(f"{label}: {_xml_escape(truncated)}")
                total_chars += len(truncated) + 10
                if total_chars > 6000:
                    break

            if not lines:
                return ""

            return "<recent_summary>\n" + "\n".join(lines) + "\n</recent_summary>"
        except Exception as e:
            logger.debug(f"recent_summary 构建失败: {e}")
            return ""

    def _build_recent_dialog(
        self,
        conversation_history: List["Message"],
        max_chars: int,
        session_id: str = "",
    ) -> str:
        """
        构建 <resentdialog> 段落 —— 近期对话历史。

        [FIX-失忆] 之前此方法从未被 build_context() 调用，
        conversation_history 参数完全被忽略，导致 LLM 在 context XML 中
        也看不到对话历史。现在正式启用，并过滤掉内部审计条目。

        将对话格式化为带角色标签的文本。当历史过长时，将较早的消息
        压缩为摘要，保留近期消息完整呈现，总字符数不超过 max_chars。

        Args:
            conversation_history: 对话历史消息列表
            max_chars: 最大字符数限制
            session_id: 会话 ID（用于 MemoryManager 摘要）

        Returns:
            <resentdialog> XML 段落字符串
        """
        if not conversation_history:
            return ""

        # 角色到中文标签的映射
        role_labels: Dict[str, str] = {
            "user": "用户",
            "assistant": "助手",
        }

        # 过滤空消息并格式化，仅保留 user/assistant（排除内部条目）
        _EXCLUDED_KEYS = {'llm_output', 'llm_input', 'tool_result_raw', 'conversation_insight', 'reasoning'}
        filtered_msgs: List[tuple] = []
        for msg in conversation_history:
            role = getattr(msg, "role", "user")
            content = getattr(msg, "content", "")
            # 仅保留 user 和 assistant 消息
            if role not in ("user", "assistant"):
                continue
            if not content or not content.strip():
                continue
            # 排除内部审计条目
            msg_meta = getattr(msg, "metadata", None)
            if isinstance(msg_meta, dict):
                _key = msg_meta.get("key", "")
                if _key in _EXCLUDED_KEYS:
                    continue
            label = role_labels.get(role, role)
            # 从 metadata 中提取时间（DB加载时已附带）
            msg_time = ""
            if isinstance(msg_meta, dict):
                msg_time = msg_meta.get("time", "")
            # 截断过长单条消息（避免一条消息占用整个预算）
            if len(content) > self.max_message_chars:
                content = content[:self.max_message_chars] + "\n... [内容已截断]"
            filtered_msgs.append((label, content.strip(), msg_time))

        if not filtered_msgs:
            return ""

        # 当消息超过阈值时，将旧消息压缩为摘要
        SUMMARY_THRESHOLD = 30  # 超过30条时启用摘要
        RECENT_KEEP = 15        # 保留最近15条完整消息
        SUMMARY_BUDGET = 3000   # 摘要最大字符数

        prefix_text = ""
        recent_msgs = filtered_msgs

        if len(filtered_msgs) > SUMMARY_THRESHOLD:
            old_msgs = filtered_msgs[:-RECENT_KEEP]
            recent_msgs = filtered_msgs[-RECENT_KEEP:]
            prefix_text = self._build_dialog_summary(old_msgs, SUMMARY_BUDGET)

        # 格式化近期消息
        formatted_lines: List[str] = []
        if prefix_text:
            formatted_lines.append(prefix_text)
            formatted_lines.append("")  # 空行分隔

        for label, content, msg_time in recent_msgs:
            # 临时合并时间信息到内容中给 LLM 参考
            if msg_time:
                formatted_lines.append(f"[{label}] [{msg_time}] {_xml_escape(content)}")
            else:
                formatted_lines.append(f"[{label}] {_xml_escape(content)}")

        dialog_text = "\n".join(formatted_lines)

        # Token 预算裁剪：超预算时从最早的消息开始移除
        if len(dialog_text) > max_chars:
            # 保留摘要前缀，裁剪近期消息部分
            if prefix_text:
                # 先尝试只裁剪近期消息
                recent_budget = max_chars - len(prefix_text) - 100
                if recent_budget < 500:
                    # 摘要本身太长，整体裁剪
                    dialog_text = dialog_text[-max_chars:]
                else:
                    recent_part = "\n".join(formatted_lines[len(formatted_lines) - len(recent_msgs):])
                    if len(recent_part) > recent_budget:
                        recent_part = self._trim_messages_from_start(recent_part, recent_budget)
                    dialog_text = prefix_text + "\n\n" + recent_part
            else:
                dialog_text = self._trim_messages_from_start(dialog_text, max_chars)
            dialog_text = "(... 前面的对话已被裁剪 ...)\n" + dialog_text

        return f"<resentdialog>\n{dialog_text}\n</resentdialog>"

    def _build_dialog_summary(self, old_msgs: List[tuple], max_chars: int) -> str:
        """
        将旧消息列表压缩为摘要文本。

        策略: 提取每条消息的第一行作为要点，保留关键信息的同时大幅压缩篇幅。
        如果有 MemoryManager，也可以调用 LLM 生成摘要（未来扩展）。

        Args:
            old_msgs: (label, content) 元组列表
            max_chars: 摘要最大字符数

        Returns:
            摘要文本字符串
        """
        if not old_msgs:
            return ""

        summary_parts: List[str] = ["[历史对话摘要]"]
        for item in old_msgs:
            label = item[0]
            content = item[1]
            # 提取第一行或前100字符作为要点
            first_line = content.split("\n")[0].strip()
            if len(first_line) > 100:
                first_line = first_line[:100] + "..."
            summary_parts.append(f"- [{label}] {first_line}")

        summary_text = "\n".join(summary_parts)

        # 摘要本身也要限制长度
        if len(summary_text) > max_chars:
            summary_text = summary_text[:max_chars] + "\n... (更多历史已省略)"

        return summary_text

    def _trim_messages_from_start(self, text: str, max_chars: int) -> str:
        """
        从文本开头裁剪消息，保留尾部（最新消息优先）。

        按行（即按消息）为单位裁剪，避免在消息中间截断。

        Args:
            text: 格式化后的对话文本
            max_chars: 最大保留字符数

        Returns:
            裁剪后的文本
        """
        if len(text) <= max_chars:
            return text

        lines = text.split("\n")
        result_lines: List[str] = []
        total = 0

        # 从后往前添加行，保留最新的消息
        for line in reversed(lines):
            if total + len(line) + 1 > max_chars:
                break
            result_lines.append(line)
            total += len(line) + 1

        result_lines.reverse()
        return "\n".join(result_lines)

    def _build_user_input(
        self,
        user_typed_text: str,
        user_voice_text: str,
    ) -> str:
        """
        构建 <userprint> 和 <usersays> 段落 —— 用户输入。

        userprint: 用户键盘输入（语音输入时为空）
        usersays: 用户语音转文本（键盘输入时为空）

        Args:
            user_typed_text: 用户键盘输入文本
            user_voice_text: 用户语音转文本

        Returns:
            <userprint> 和 <usersays> XML 段落字符串
        """
        # 语音输入时：userprint 为空，usersays 存原始语音文本
        # 键盘输入时：userprint 存文本，usersays 为空
        # 两者互斥
        if user_voice_text and user_voice_text.strip():
            safe_typed = ""
            safe_voice = _xml_escape(user_voice_text.strip())
        else:
            safe_typed = _xml_escape(user_typed_text.strip()) if user_typed_text else ""
            safe_voice = ""

        lines = [
            f"<userprint>",
            f"{safe_typed}",
            f"</userprint>",
            f"<usersays>",
            f"{safe_voice}",
            f"</usersays>",
        ]
        return "\n".join(lines)

    def _build_task_plan(self, task_plan: str) -> str:
        """
        构建 <task_plan> 段落 —— 当前任务计划。

        任务计划以 Markdown 格式呈现，上轮任务已完成时为空。

        Args:
            task_plan: 任务计划文本（Markdown 格式）

        Returns:
            <task_plan> XML 段落字符串
        """
        if not task_plan or not task_plan.strip():
            return "<task_plan>\n(无当前任务计划)\n</task_plan>"

        safe_plan = _xml_escape(task_plan.strip())
        return f"<task_plan>\n{safe_plan}\n</task_plan>"

    def _build_tools(
        self,
        skill_registry: Optional["SkillRegistry"],
    ) -> str:
        """
        构建 <tools> 段落 —— 可用工具列表。

        列出每个工具的名称、简要描述和参数格式。
        若 skill_registry 为 None 或无可用工具，输出 "(无可用工具)"。

        Args:
            skill_registry: 技能注册表实例

        Returns:
            <tools> XML 段落字符串
        """
        if not skill_registry:
            return "<tools>\n(无可用工具)\n</tools>"

        try:
            schemas = skill_registry.get_all_schemas()
        except Exception as e:
            logger.warning(f"获取工具列表失败: {e}")
            return "<tools>\n(无可用工具)\n</tools>"

        if not schemas:
            return "<tools>\n(无可用工具)\n</tools>"

        lines: List[str] = ["<tools>"]
        for schema in schemas:
            func_info = schema.get("function", {})
            tool_name = func_info.get("name", "unknown")
            tool_desc = func_info.get("description", "")
            params = func_info.get("parameters", {})
            properties = params.get("properties", {})
            required = params.get("required", [])

            safe_name = _xml_escape(tool_name)
            safe_desc = _xml_escape(tool_desc)

            # 构建参数格式字符串
            param_parts: List[str] = []
            for param_name, param_info in properties.items():
                p_type = param_info.get("type", "string")
                p_desc = param_info.get("description", "")
                p_required = param_name in required
                req_mark = " (必填)" if p_required else " (可选)"

                param_str = f"{param_name}: {p_type}{req_mark}"
                if p_desc:
                    param_str += f" - {_xml_escape(p_desc)}"
                param_parts.append(f"    {param_str}")

            lines.append(f"- {safe_name}: {safe_desc}")
            if param_parts:
                lines.append("  参数:")
                lines.extend(param_parts)

        lines.append("</tools>")
        return "\n".join(lines)

    def _build_skill_prompts(
        self,
        skill_registry: Optional["SkillRegistry"],
    ) -> str:
        """
        [v1.22.0] 不再全量注入 skill_prompts。

        SKILL.md 内容已通过 _sync_skill_guides_to_knowledge() 写入
        {kb_dir}/_skill_guides/ 目录，由 RAG 索引。
        LLM 需要专业技能指令时，通过 <get_knowledge> 按需检索。
        返回空字符串以节省大量 token。
        """
        return ""

    def _build_runtime_env(self) -> str:
        """[v1.26.0] 构建运行环境信息段落 — 操作系统、Shell、可用工具链。"""
        import os
        import platform
        import subprocess
        import shutil

        lines = ["<runtime_env>"]

        # 操作系统
        system = platform.system()
        release = platform.release()
        machine = platform.machine()
        lines.append(f"操作系统: {system} {release} ({machine})")

        # Shell 信息
        shell_name = os.environ.get("SHELL", "")
        if not shell_name and system == "Windows":
            shell_name = os.environ.get("COMSPEC", "cmd.exe")
        if shell_name:
            lines.append(f"默认Shell: {shell_name}")

        # 常用工具检测
        useful_tools = []
        for tool in ["python3", "python", "pip3", "pip", "node", "npm", "npx",
                      "git", "curl", "wget", "bash", "zsh", "sh",
                      "docker", "docker-compose", "ffmpeg", "jq", "tar", "zip", "unzip",
                      "grep", "sed", "awk", "find", "cat", "head", "tail", "wc"]:
            path = shutil.which(tool)
            if path:
                useful_tools.append(tool)
        lines.append(f"可用命令: {', '.join(useful_tools)}")

        # Python 版本
        try:
            result = subprocess.run(
                ["python3", "--version"], capture_output=True, text=True, timeout=5
            )
            if result.returncode == 0:
                lines.append(f"Python: {result.stdout.strip()}")
        except Exception:
            pass

        # Node 版本
        node_path = shutil.which("node")
        if node_path:
            try:
                result = subprocess.run(
                    ["node", "--version"], capture_output=True, text=True, timeout=5
                )
                if result.returncode == 0:
                    lines.append(f"Node.js: {result.stdout.strip()}")
            except Exception:
                pass

        lines.append("</runtime_env>")
        return "\n".join(lines)

    def _build_exec_warnings(self) -> str:
        """[v1.23.44] 构建执行环境警告段落，提醒 Agent 避免常见错误。"""
        import os
        # 检测当前是否运行在 myagent 安装目录下
        _myagent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
        _is_in_myagent = (
            os.path.isfile(os.path.join(_myagent_dir, "main.py"))
            and (
                os.path.isfile(os.path.join(_myagent_dir, "start.sh"))
                or os.path.isfile(os.path.join(_myagent_dir, "start.js"))
            )
        )

        if not _is_in_myagent:
            return ""

        lines = [
            "<exec_warnings>",
            "【执行环境提醒】你当前运行在 myagent 项目内部。请注意以下限制:",
            "- 禁止执行: python main.py / python3 main.py (这会启动 myagent 服务本身，导致冲突)",
            "- 禁止执行: bash start.sh / ./start.sh (同上，启动脚本)",
            "- 禁止执行: myagent-ai start / myagent-ai run (重复启动服务)",
            "- 如需测试 myagent 模块，请直接 import: python -c 'from agents.main_agent import MainAgent; ...'",
            "- 文件操作请使用原生命令: cat, head, tail, grep, find 等",
            "</exec_warnings>",
        ]
        return "\n".join(lines)

    # =========================================================================
    # Token 预算管理
    # =========================================================================

    def _enforce_token_budget(self, context_xml: str, budget_ratio: float = 0.75) -> str:
        """
        Token 预算检查与自动裁剪。

        估算 context_xml 的 token 数，如果超过 budget_ratio * context_window，
        按优先级裁剪（先裁剪 <knowledge>、<recall_memory>、<automemory>，
        再裁剪 <resentdialog> 历史部分）。

        Args:
            context_xml: 完整的 <context> XML 字符串
            budget_ratio: 上下文窗口使用比例上限（默认 75%，为系统提示和输出预留空间）

        Returns:
            裁剪后的 context_xml
        """
        if not context_xml:
            return context_xml

        # 粗略估算 token: 中文约 1.3 token/字，英文约 0.35 token/字
        def _est_tok(text: str) -> int:
            if not text:
                return 0
            cn = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
            other = len(text) - cn
            return int(cn * 1.3 + other * 0.35)

        # 使用配置的 context_window（由 init_context_builder 注入）
        window = self.context_window

        budget = int(window * budget_ratio)
        estimated = _est_tok(context_xml)

        if estimated <= budget:
            return context_xml

        logger.warning(
            f"上下文 token 估算 ({estimated}) 超出预算 ({budget} = {budget_ratio}*{window}), "
            f"启动自动裁剪 (原始长度={len(context_xml)} 字符)"
        )

        import re

        def _remove_section(xml: str, tag: str) -> str:
            pattern = rf'<{tag}>[\s\S]*?</{tag}>'
            replacement = f'<{tag}>\n(因 token 预算不足已裁剪)\n</{tag}>'
            return re.sub(pattern, replacement, xml, count=1, flags=re.DOTALL)

        # 按优先级从低到高裁剪
        # [FIX-失忆] 调整裁剪优先级：
        # - resentdialog（对话历史）优先级提高，仅在 automemory 之后才裁剪
        # - recent_summary 最先裁剪（因为对话历史消息轮次已包含同等信息）
        # - knowledge/task_plan 中等优先级
        # - automemory/resentdialog 最优先保留（这是防失忆的核心）
        for tag in ['recent_summary', 'skill_prompts', 'task_plan', 'knowledge', 'recall_memory', 'resentdialog', 'automemory']:
            if estimated <= budget:
                break
            if f'<{tag}>' in context_xml:
                context_xml = _remove_section(context_xml, tag)
                estimated = _est_tok(context_xml)
                logger.debug(f"裁剪 <{tag}> 后 token 估算: {estimated}")

        # 如果还超预算，截断 <resentdialog> 内容
        if estimated > budget:
            pattern = r'<resentdialog>\n([\s\S]*?)\n</resentdialog>'
            match = re.search(pattern, context_xml)
            if match:
                dialog_text = match.group(1)
                target_chars = int(budget / 1.3)
                if len(dialog_text) > target_chars:
                    truncated = dialog_text[-target_chars:]
                    truncated = "(... 历史已因 token 预算不足裁剪 ...)\n" + truncated
                    context_xml = (
                        context_xml[:match.start(1)] + truncated + context_xml[match.end(1):]
                    )
                    estimated = _est_tok(context_xml)
                    logger.debug(f"截断对话历史后 token 估算: {estimated}")

        if estimated > budget:
            logger.warning(f"上下文裁剪后仍超出预算 (token={estimated}/{budget})")

        return context_xml


# =============================================================================
# 工具函数
# =============================================================================

def _xml_escape(text: str) -> str:
    """
    XML 特殊字符转义。

    将 &, <, >, ", ' 替换为对应的 XML 实体，防止注入和格式破坏。

    Args:
        text: 需要转义的原始文本

    Returns:
        转义后的安全文本
    """
    if not text:
        return ""
    # html.escape 默认转义 &, <, >，并可通过 quote=True 额外转义 " 和 '
    return html.escape(text, quote=True)
