"""
Klynx Agent - LangGraph StateGraph
实现 OODA 循环的核心图结构
"""

import hashlib
import json
import os
import platform
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph

from klynx.agent.tools.browser import BrowserManager
from klynx.agent.tools.interactive_exec import InteractiveExecManager
from klynx.agent.mcp_manager import MCPManager
from klynx.agent.tools.terminal import TerminalManager
from klynx.agent.tools import ToolRegistry, XMLParser, get_json_schemas
from klynx.agent.tools.dispatch import ToolDispatchMixin
from klynx.agent.tools.tui import TUIManager
from klynx.agent.tools.web_search import WebSearchTool

from .backend import AgentBackend, LocalAgentBackend, resolve_runtime_paths
from .hooks import AgentHook, AgentHookContext, HookManager, RuntimeTruthHook
from .nodes import NodesMixin
from .prompt_builder import PromptBuilderMixin
from .state import AgentState
from .store import AgentStore, InMemoryAgentStore


class KlynxAgent(PromptBuilderMixin, NodesMixin, ToolDispatchMixin):
    """Klynx Agent main class."""

    DEFAULT_SKILLS_ROOT = os.path.join(
        os.path.expanduser(os.environ.get("KLYNX_HOME", "~/.klynx")),
        "skills",
    )
    DEFAULT_BASIC_SKILLS = ("skill-creator", "skill-installer")

    SYSTEM_TOOLS = {
        "update_task_state": "更新 overall_goal/current_task/task_plan/current_step_id/completed_steps。仅在总目标修正或推进到下一步时调用。",
        "write_todos": "维护 todo 列表（pending/in_progress/completed）。复杂任务拆解时使用。",
        "read_tool_artifact": "分页读取大工具输出：read_tool_artifact(artifact_id, offset=0, length=2000)。",
        "run_subtask": "执行轻量批处理动作：run_subtask(title, actions=[{tool, params}, ...])。",
        "attempt_completion": "兼容完成信号工具。当前主循环支持自然收敛，通常不需要显式调用。",
    }

    CORE_TOOLS = {
        "read_file": "读取文件局部内容并精读，支持行范围或分页。不要把它当作整仓通读工具。",
        "apply_patch": "使用结构化 patch 进行最小修改。优先用于局部编辑、多文件修改、新增/删除/移动文件。",
        "write_to_file": "将内容写入文件（覆盖或新建）。仅在整文件重写或新建文件时使用。",
        "execute_command": "执行系统命令。本地代码搜索、构建、测试、git 查询默认都优先走这个工具；代码搜索优先使用 rg --files 和 rg -n。",
        "list_directory": "列出目录树结构，支持指定递归深度。",
    }

    EXTENDED_FS_TOOLS = {
        "replace_in_file": "兼容性局部替换工具。仅在 apply_patch 不适合时作为备选。",
        "create_directory": "创建目录（包括父目录）。",
        "preview_file": "预览文件前 N 行内容。",
        "search_in_files": "结构化辅助搜索工具，底层优先使用 rg。适合需要 hit_id -> read_file 联动时使用；不是默认主搜索路径。",
    }

    INTERACTIVE_TOOLS = {
        "create_terminal": "创建终端会话。仅用于后台批量执行非交互式命令。",
        "run_in_terminal": "在终端执行命令。",
        "read_terminal": "读取终端输出。",
        "wait_terminal_until": "等待终端命令完成或等待输出匹配 pattern。",
        "read_terminal_since_last": "读取自上次检查以来的新增终端输出。",
        "run_and_wait": "执行命令并等待完成或命中 pattern。",
        "exec_command": "启动交互式终端会话。可用 PTY/pipe 运行长生命周期前台程序，首轮只等待短窗口并返回输出或 session_id。",
        "write_stdin": "向交互式终端会话继续写入 stdin，或用 chars='' 轮询新输出。",
        "close_exec_session": "关闭交互式终端会话并释放资源。",
        "check_syntax": "检查代码语法。",
        "open_tui": "启动 TUI 应用。",
        "read_tui": "读取 TUI 屏幕快照。",
        "read_tui_diff": "读取自上次观察以来的 TUI 差异。",
        "read_tui_region": "读取 TUI 指定行范围。",
        "find_text_in_tui": "在 TUI 屏幕中查找文本锚点。",
        "send_keys": "向 TUI 发送按键。",
        "send_keys_and_read": "发送按键并立即读取差异结果。",
        "wait_tui_until": "等待 TUI 出现文本或屏幕发生变化。",
        "close_tui": "关闭 TUI 会话。",
        "activate_tui_mode": "激活 TUI 交互模式：加载精简版 TUI 指南到系统提示。",
        "launch_interactive_session": "一键启动交互式终端会话（推荐）：底层等价于 exec_command(tty=true)，只有需要屏幕语义时再进入 TUI。",
    }

    NETWORK_AND_EXTRA_TOOLS = {
        "web_search": "联网搜索。需要最新信息、技术文档、新闻或事实核查时必须使用。",
        "browser_open": "打开浏览器并访问 URL。",
        "browser_view": "获取页面内容，不传 selector 则返回页面摘要。",
        "browser_act": "操作页面元素，action 支持 click/type/press/hover。",
        "browser_scroll": "滚动页面。",
        "browser_screenshot": "截图并保存。",
        "browser_console_logs": "获取浏览器控制台日志。",
    }

    SKILL_TOOLS = {
        "load_skill": "加载已安装技能包的 SKILL.md 内容到上下文。只有需要遵循某个 skill 的工作流时才调用。",
    }

    TOOL_GROUPS = {
        "system": SYSTEM_TOOLS,
        "core": CORE_TOOLS,
        "extended_fs": EXTENDED_FS_TOOLS,
        "interactive": INTERACTIVE_TOOLS,
        "network_and_extra": NETWORK_AND_EXTRA_TOOLS,
        "skills": SKILL_TOOLS,
    }

    CORE_TOOLS.update(
        {
            "execute_command": "执行短命令、一次性搜索、构建、测试和校验。不要用于 REPL、shell、长生命周期前台程序或 --mode tui / textual / curses；Windows 下优先传 workdir，不要写 cd /d。",
        }
    )
    INTERACTIVE_TOOLS.update(
        {
            "read_terminal": "读取 legacy named terminal 的输出。name 必须来自 create_terminal，不要传 exec_xxx session_id。",
            "wait_terminal_until": "等待 legacy named terminal 中的命令完成或命中 pattern。name 必须来自 create_terminal。",
            "read_terminal_since_last": "读取 legacy named terminal 自上次检查以来的新增输出。name 必须来自 create_terminal。",
            "run_and_wait": "在 legacy named terminal 中执行命令并等待完成或命中 pattern。name 必须来自 create_terminal。",
            "exec_command": "启动交互式终端会话。适合 REPL、shell、长生命周期前台程序、--mode tui/textual/curses；返回 exec_xxx session_id，后续只能用 write_stdin / close_exec_session。",
            "write_stdin": "向交互式终端会话继续写入 stdin，或用 chars='' 轮询新输出。session_id 来自 exec_command / launch_interactive_session。",
            "close_exec_session": "关闭交互式终端会话并释放资源。session_id 来自 exec_command / launch_interactive_session。",
            "open_tui": "启动全屏 TUI 应用，自动激活 TUI guide，并返回首屏摘要。",
            "read_tui": "读取 TUI 屏幕快照；首个完整读取会尽量保留布局。",
            "read_tui_diff": "读取自上次观察以来的 TUI 差异，返回 before/after 片段和变化摘要。",
            "read_tui_region": "读取 TUI 指定行范围，适合局部观察。",
            "find_text_in_tui": "在 TUI 屏幕中查找文本锚点，并返回命中上下文。",
            "send_keys": "向 TUI 发送按键，并返回 changed_rows 与 after excerpt。",
            "send_keys_and_read": "发送按键并立即读取结果，优先于手动 send_keys + read_tui。",
            "wait_tui_until": "等待 TUI 出现文本或屏幕变化，即使按 hash_change 命中也会返回变化区域摘录。",
            "activate_tui_mode": "显式激活 TUI 指南；open_tui 成功后也会自动启用。",
            "launch_interactive_session": "一键启动交互式终端会话（推荐），底层等价于 exec_command(tty=true)；只有需要屏幕语义时再切换到 TUI。",
        }
    )

    TOOL_GROUP_LABELS = {
        "system": "系统工具",
        "core": "核心工具",
        "extended_fs": "辅助文件工具",
        "interactive": "交互工具",
        "network_and_extra": "网络与浏览器工具",
        "skills": "技能工具",
    }

    BASE_TOOLS: Dict[str, str] = {}
    for _tool_group in TOOL_GROUPS.values():
        BASE_TOOLS.update(_tool_group)


    def __init__(
        self,
        working_dir: str = ".",
        model=None,
        max_iterations: Optional[int] = None,
        memory_dir: str = "",
        load_project_docs: bool = True,
        os_name: str = "windows",
        browser_headless: bool = False,
        tool_call_mode: str = "native",
        tool_protocol_mode: Optional[str] = None,
        skills: Optional[List[Any]] = None,
        skills_root: str = "",
        checkpointer: Optional[Any] = None,
        tool_virtual_root: str = "",
        allow_shell_commands: bool = True,
        max_tools_per_step: int = 20,
        max_reads_per_file_per_step: int = 6,
        max_retry_per_tool_per_step: int = 2,
        tui_stall_threshold: int = 3,
        full_tui_echo: bool = False,
        backend: Optional[AgentBackend] = None,
        store: Optional[AgentStore] = None,
        hooks: Optional[List[AgentHook]] = None,
    ):
        """
        初始化 Klynx Agent
        
        Args:
            working_dir: 工作目录
            model: LangChain 模型实例（需设置 max_context_tokens 属性）
            max_iterations: 最大迭代次数。None/<=0 表示不设硬上限（默认）。
            memory_dir: Agent 记忆目录路径（.klynx/.rules/.memory 的存储位置），为空则不加载记忆
            load_project_docs: 是否递归加载工作目录下的 KLYNX.md 项目文档，默认 True
            os_name: 操作系统名称，用于给 Agent 提供环境提示 (例如: windows, linux, macos)
            browser_headless: 浏览器工具是否运行在无头模式，默认为 False (显示界面)
            tool_call_mode: 兼容旧参数。支持 "native" / "xml"。
            tool_protocol_mode: 工具协议模式，支持 "native" / "xml_fallback"。若设置则优先于 tool_call_mode。
            skills: Agent 启动时可安装的技能配置。支持：
                    - ["skill-name", "path/to/skill"]
                    - [("name", "path", "description")]
                    - [{"name": "...", "path": "...", "description": "..."}]
            skills_root: 技能根目录，支持传入技能名称时在该目录下解析
            checkpointer: 可选 LangGraph checkpointer。为空时默认使用 MemorySaver。
            tool_virtual_root: 工具访问的虚拟根目录（为空则使用 working_dir）。
            allow_shell_commands: 是否允许 execute_command 执行命令。
            max_tools_per_step: 复杂任务中每个步骤的最大工具调用次数。
            max_reads_per_file_per_step: 复杂任务中每个步骤对同一文件最大读取次数。
            max_retry_per_tool_per_step: 复杂任务中每个步骤同一工具最大失败重试次数。
            tui_stall_threshold: TUI 连续无变化统计阈值（仅用于状态记录，不注入提示）。
            full_tui_echo: 是否在工具输出中保留完整 TUI 回显（默认 False，仅保留摘要+artifact）。
        """
        self.backend: AgentBackend = backend or LocalAgentBackend()
        resolved_paths = resolve_runtime_paths(
            self.backend,
            working_dir=working_dir,
            memory_dir=memory_dir,
            skills_root=skills_root,
            tool_virtual_root=tool_virtual_root,
        )
        self.working_dir = resolved_paths["working_dir"]
        self.model = model
        # None or <=0 means unlimited iterations.
        if max_iterations is None:
            self.max_iterations = None
        else:
            try:
                parsed_max_iterations = int(max_iterations)
                self.max_iterations = parsed_max_iterations if parsed_max_iterations > 0 else None
            except Exception:
                self.max_iterations = None
        self.memory_dir = resolved_paths["memory_dir"]
        self.load_project_docs = load_project_docs
        self.os_name = os_name
        requested_mode = str(tool_protocol_mode or tool_call_mode or "native").strip().lower()
        if requested_mode == "xml":
            requested_mode = "xml_fallback"
        if requested_mode not in {"native", "xml_fallback"}:
            requested_mode = "native"
        self.tool_protocol_mode = requested_mode
        self.tool_call_mode = "xml" if requested_mode == "xml_fallback" else "native"
        self.store: AgentStore = store or InMemoryAgentStore()
        runtime_hooks = list(hooks or [])
        runtime_hooks.append(RuntimeTruthHook())
        self.hook_manager = HookManager(runtime_hooks)
        self.max_tools_per_step = max(1, int(max_tools_per_step or 1))
        self.max_reads_per_file_per_step = max(1, int(max_reads_per_file_per_step or 1))
        self.max_retry_per_tool_per_step = max(1, int(max_retry_per_tool_per_step or 1))
        self.tui_stall_threshold = max(0, int(tui_stall_threshold or 0))
        self.full_tui_echo = bool(full_tui_echo)
        
        # 自动回退：若模型明显不支持 native tool calling，则切换到 xml_fallback。
        if self.model and hasattr(self.model, "model"):
            model_name_lower = self.model.model.lower()
            if (
                "reasoner" in model_name_lower
                or "o1" in model_name_lower
                or "deepseek-chat" in model_name_lower
            ):
                self.tool_protocol_mode = "xml_fallback"
                self.tool_call_mode = "xml"
        
        # 终端管理器
        self.interactive_exec_manager = InteractiveExecManager(self.working_dir)
        self.terminal_manager = TerminalManager(
            self.working_dir,
            interactive_exec_manager=self.interactive_exec_manager,
        )
        # TUI 管理器
        self.tui_manager = TUIManager(
            self.working_dir,
            interactive_exec_manager=self.interactive_exec_manager,
        )
        # 联网搜索工具
        self.web_search_tool = WebSearchTool()
        # 浏览器管理工具
        self.browser_manager = BrowserManager(headless=browser_headless)
        # 从模型对象读取上下文窗口大小，默认 128k
        self.max_context_tokens = getattr(model, 'max_context_tokens', 128000)
        
        # MCP (Model Context Protocol) 管理器
        self.mcp_manager = MCPManager()
        
        # 流式输出回调函数
        self.streaming_callback = None
        
        # 初始化组件
        self.xml_parser = XMLParser()
        
        # 工具系统：已加载的工具（名称 -> 说明）和外部工具函数（名称 -> callable）
        self.tools: Dict[str, str] = {}
        self.external_tool_funcs: Dict[str, callable] = {}
        self.active_tool_groups: List[str] = []
        self._default_active_tool_groups: List[str] = ["system", "core"]

        # 预加载的工具提示词缓存
        self._tool_prompts_cache: str = ""
        
        # Native Tool Calling（兼容旧称 Function Calling）用的 JSON Schema 缓存
        self._json_schemas: list = []

        # 技能系统：注册信息和提示词缓存
        resolved_skills_root = resolved_paths["skills_root"]
        if resolved_skills_root:
            self.skills_root = resolved_skills_root
        else:
            self.skills_root = os.path.abspath(os.path.expanduser(self.DEFAULT_SKILLS_ROOT))
        self.builtin_skills_root = str(
            (Path(__file__).resolve().parent.parent / "skills" / "system").resolve()
        )
        self.basic_skills_enabled = True
        self.skill_registry: Dict[str, Dict[str, str]] = {}
        self.skill_registry_cache_by_cwd: Dict[str, Dict[str, Dict[str, str]]] = {}
        self.skill_registry_digest: str = ""
        self._skills_prompt_cache: str = ""
        self._skill_body_cache: Dict[str, str] = {}
        
        # 回滚目标配置（存储 checkpoint config，供 stream 使用）
        self._rollback_config = None
        
        # 事件缓冲区（流式输出用）
        self._event_buffer: List[Dict[str, Any]] = []
        
        # 设置工具的工作目录
        ToolRegistry.set_working_dir(self.working_dir)
        ToolRegistry.configure_security(
            virtual_root=resolved_paths["tool_virtual_root"],
            allow_shell_commands=allow_shell_commands,
        )

        # 预加载内置技能（可通过 basic_skills("off") 关闭）
        self._load_builtin_skills()

        # 预加载技能目录（可选）
        if skills:
            if isinstance(skills, str):
                self.add_skills(skills)
            else:
                self.add_skills(*skills)

        # 默认只启用 system + core；interactive/network/extended_fs 按需显式开启。
        self._load_default_tool_groups()

        # 构建图
        self.workflow = self._build_graph()
        self.memory = checkpointer if checkpointer is not None else MemorySaver()
        self.app = self.workflow.compile(checkpointer=self.memory)


    def _load_default_tool_groups(self):
        """默认激活最小必要工具面。"""
        self.add_tools(*[f"group:{name}" for name in self._default_active_tool_groups])
        return self

    def _normalize_tool_group_name(self, group_name: str) -> str:
        normalized = str(group_name or "").strip().lower()
        return normalized.replace("-", "_")

    def _iter_group_tool_names(self, group_name: str) -> List[str]:
        normalized = self._normalize_tool_group_name(group_name)
        group = self.TOOL_GROUPS.get(normalized, {})
        return list(group.keys())

    def _update_active_tool_groups(self):
        active_groups: List[str] = []
        active_names = set(self.tools.keys())
        for group_name, group_tools in self.TOOL_GROUPS.items():
            if any(name in active_names for name in group_tools.keys()):
                active_groups.append(group_name)
        self.active_tool_groups = active_groups

    def _infer_skill_scope(self, skill_dir: Path, source: str) -> str:
        source_norm = str(source or "").strip().lower()
        if source_norm in {"builtin", "system"}:
            return "system"
        try:
            resolved_dir = skill_dir.resolve()
        except Exception:
            resolved_dir = skill_dir

        repo_root = (Path(self.working_dir) / ".klynx" / "skills").resolve()
        user_root = Path(self.skills_root).resolve()
        try:
            resolved_dir.relative_to(repo_root)
            return "repo"
        except Exception:
            pass
        try:
            resolved_dir.relative_to(user_root)
            return "user"
        except Exception:
            pass
        if source_norm == "memory":
            return "repo"
        return "extra"

    def _scope_sort_key(self, scope: str) -> Tuple[int, str]:
        scope_norm = str(scope or "").strip().lower()
        order = {"repo": 0, "extra": 1, "user": 2, "system": 3}
        return (order.get(scope_norm, 9), scope_norm)

    def _refresh_skill_registry_cache(self):
        cwd_key = os.path.abspath(self.working_dir)
        ordered_items = sorted(
            self.skill_registry.items(),
            key=lambda item: (self._scope_sort_key(item[1].get("scope", "")), str(item[0]).lower()),
        )
        self.skill_registry = {name: meta for name, meta in ordered_items}
        self.skill_registry_cache_by_cwd[cwd_key] = {
            name: dict(meta) for name, meta in self.skill_registry.items()
        }
        if ordered_items:
            serialized = json.dumps(
                [
                    {
                        "name": name,
                        "scope": meta.get("scope", ""),
                        "path": meta.get("skill_md_path", ""),
                    }
                    for name, meta in ordered_items
                ],
                ensure_ascii=False,
                sort_keys=True,
            )
            self.skill_registry_digest = hashlib.sha1(serialized.encode("utf-8")).hexdigest()[:16]
        else:
            self.skill_registry_digest = ""

    def _refresh_tool_prompts(self):
        """刷新工具提示词缓存 & Native Tool Calling（兼容旧称 Function Calling）Schema 缓存"""
        if not self.tools:
            self._tool_prompts_cache = ""
            self._json_schemas = []
            return
        
        # 始终刷新 Native Tool Calling Schema 缓存（即使当前模式是 xml_fallback）
        external_descs = {name: desc for name, desc in self.tools.items() if name in self.external_tool_funcs}
        self._json_schemas = get_json_schemas(
            list(self.tools.keys()),
            external_tools=external_descs,
            external_tool_funcs=self.external_tool_funcs,
        )

        self._update_active_tool_groups()

        grouped: Dict[str, List[str]] = {
            self.TOOL_GROUP_LABELS[group_name]: []
            for group_name in self.active_tool_groups
            if group_name in self.TOOL_GROUP_LABELS
        }
        ungrouped: List[str] = []
        for tool_name in sorted(self.tools.keys()):
            placed = False
            for group_name in self.active_tool_groups:
                names = set(self.TOOL_GROUPS.get(group_name, {}).keys())
                category = self.TOOL_GROUP_LABELS.get(group_name, group_name)
                if tool_name in names:
                    grouped.setdefault(category, []).append(tool_name)
                    placed = True
                    break
            if not placed:
                ungrouped.append(tool_name)

        lines: List[str] = []
        lines.append("\n<tool_usage_guide>")
        lines.append("  <description>默认先执行最小动作而不是长分析。按需调用 active tools 推进任务，主路径优先 execute_command(rg) -> read_file -> apply_patch -> execute_command。</description>")
        lines.append(f"  <protocol_mode>{self.tool_protocol_mode}</protocol_mode>")
        lines.append("  <notes>native 与 xml_fallback 互斥，不要在同一回合混用两种协议。</notes>")
        if self.active_tool_groups:
            lines.append(
                f"  <active_groups>{', '.join(self._escape_xml(name) for name in self.active_tool_groups)}</active_groups>"
            )
        lines.append("  <tool_categories>")
        for category, tool_names in grouped.items():
            if not tool_names:
                continue
            lines.append(f'    <category name="{category}">')
            for name in tool_names:
                lines.append(f'      <tool name="{name}">{self.tools.get(name, "")}</tool>')
            lines.append("    </category>")
        if ungrouped:
            lines.append('    <category name="其他工具">')
            for name in ungrouped:
                lines.append(f'      <tool name="{name}">{self.tools.get(name, "")}</tool>')
            lines.append("    </category>")
        lines.append("  </tool_categories>")
        lines.append("</tool_usage_guide>")
        self._tool_prompts_cache = "\n".join(lines)

    def _find_skill_md_path(self, skill_dir: Path) -> Optional[Path]:
        """在技能目录中查找 SKILL.md 文件（大小写不敏感）。"""
        if not skill_dir.exists() or not skill_dir.is_dir():
            return None

        direct = skill_dir / "SKILL.md"
        if direct.exists() and direct.is_file():
            return direct

        for child in skill_dir.iterdir():
            if child.is_file() and child.name.lower() == "skill.md":
                return child
        return None

    def _extract_skill_frontmatter(self, content: str) -> Dict[str, str]:
        """从 SKILL.md frontmatter 提取 name 和 description。"""
        text = (content or "").lstrip("\ufeff")
        lines = text.splitlines()
        if not lines or lines[0].strip() != "---":
            return {}

        end_idx = None
        for i in range(1, len(lines)):
            if lines[i].strip() == "---":
                end_idx = i
                break
        if end_idx is None:
            return {}

        frontmatter = "\n".join(lines[1:end_idx])
        name_match = re.search(r"(?m)^name:\s*(.+?)\s*$", frontmatter)
        desc_match = re.search(r"(?m)^description:\s*(.+?)\s*$", frontmatter)

        parsed: Dict[str, str] = {}
        if name_match:
            parsed["name"] = name_match.group(1).strip().strip("'\"")
        if desc_match:
            parsed["description"] = desc_match.group(1).strip().strip("'\"")
        return parsed

    def _resolve_skill_dir(self, skill_ref: str) -> Path:
        """将技能引用解析为目录路径。支持名称、目录路径或 SKILL.md 文件路径。"""
        if not isinstance(skill_ref, str) or not skill_ref.strip():
            raise ValueError("技能引用不能为空")

        raw_ref = skill_ref.strip()
        path_ref = Path(raw_ref).expanduser()
        candidate_dirs = []

        if path_ref.is_absolute():
            candidate_dirs.append(path_ref)
        else:
            candidate_dirs.append(Path(self.working_dir) / path_ref)
            candidate_dirs.append(Path(self.skills_root) / path_ref)

        for candidate in candidate_dirs:
            if candidate.is_file() and candidate.name.lower() == "skill.md":
                return candidate.parent.resolve()
            if candidate.is_dir() and self._find_skill_md_path(candidate):
                return candidate.resolve()

        # 回退：允许以技能名匹配 skills_root 下的任意子目录（例如 .system/skill-creator）
        skills_root_path = Path(self.skills_root)
        if skills_root_path.exists() and skills_root_path.is_dir():
            for found in skills_root_path.rglob("*"):
                if not found.is_dir() or found.name != raw_ref:
                    continue
                if self._find_skill_md_path(found):
                    return found.resolve()

        # 回退：允许以内置技能名匹配 package 内置技能目录
        builtin_root_path = Path(self.builtin_skills_root)
        if builtin_root_path.exists() and builtin_root_path.is_dir():
            for found in builtin_root_path.rglob("*"):
                if not found.is_dir() or found.name != raw_ref:
                    continue
                if self._find_skill_md_path(found):
                    return found.resolve()

        raise FileNotFoundError(f"未找到技能目录或 SKILL.md: {skill_ref}")

    def _iter_skill_specs(self, *skills):
        """
        将 add_skills 的多种入参规范化为统一描述结构。

        支持：
        1) "skill-name" / "path/to/skill"
        2) ("name", "path") / ("name", "path", "description")
        3) {"name": "...", "path": "...", "description": "...", "source": "..."}
        4) add_skills("name", "path", "description")  # 兼容用户习惯写法
        """
        if len(skills) == 1 and isinstance(skills[0], (list, tuple, set)):
            skills = tuple(skills[0])

        if not skills:
            raise ValueError("add_skills 需要至少一个技能引用")

        # 兼容三参数调用：add_skills(name, path, description)
        if (
            len(skills) in (2, 3)
            and all(isinstance(item, str) for item in skills)
            and (
                os.path.exists(os.path.expanduser(skills[1]))
                or any(ch in skills[1] for ch in ("/", "\\", ":"))
            )
        ):
            maybe_name = skills[0].strip()
            maybe_path = skills[1].strip()
            maybe_desc = skills[2].strip() if len(skills) == 3 else ""
            return [
                {
                    "name": maybe_name,
                    "path": maybe_path,
                    "description": maybe_desc,
                    "source": "external",
                }
            ]

        normalized = []
        for item in skills:
            if isinstance(item, str):
                normalized.append(
                    {
                        "name": "",
                        "path": item,
                        "description": "",
                        "source": "external",
                    }
                )
                continue

            if isinstance(item, (tuple, list)):
                if len(item) not in (2, 3):
                    raise ValueError(f"无效技能元组: {item}")
                if not all(isinstance(x, str) for x in item):
                    raise ValueError(f"技能元组必须全部为字符串: {item}")
                normalized.append(
                    {
                        "name": item[0].strip(),
                        "path": item[1].strip(),
                        "description": item[2].strip() if len(item) == 3 else "",
                        "source": "external",
                    }
                )
                continue

            if isinstance(item, dict):
                raw_name = str(item.get("name", "") or "").strip()
                raw_path = str(item.get("path", "") or item.get("skill", "") or "").strip()
                if not raw_path:
                    raise ValueError(f"技能配置缺少 path: {item}")
                raw_desc = str(item.get("description", "") or "").strip()
                raw_source = str(item.get("source", "external") or "external").strip()
                normalized.append(
                    {
                        "name": raw_name,
                        "path": raw_path,
                        "description": raw_desc,
                        "source": raw_source,
                    }
                )
                continue

            raise ValueError(f"无效技能引用: {item}")

        return normalized

    def _discover_skill_dirs(self, root_dir: Path) -> List[Path]:
        """从根目录递归发现包含 SKILL.md 的技能目录。"""
        if not root_dir.exists() or not root_dir.is_dir():
            return []

        found_dirs = set()
        for md_name in ("SKILL.md", "skill.md"):
            for md_path in root_dir.rglob(md_name):
                if md_path.is_file():
                    found_dirs.add(md_path.parent.resolve())
        return sorted(found_dirs)

    def _sync_load_skill_tool(self):
        """按技能可用性自动维护 load_skill 工具。"""
        if self.skill_registry:
            if "load_skill" not in self.tools:
                self.tools["load_skill"] = self.BASE_TOOLS["load_skill"]
        else:
            self.tools.pop("load_skill", None)
        self._update_active_tool_groups()
        self._refresh_tool_prompts()

    def _load_builtin_skills(self):
        """加载内置技能（skill-creator / skill-installer）。"""
        if not self.basic_skills_enabled:
            return self

        specs = []
        for skill_name in self.DEFAULT_BASIC_SKILLS:
            skill_dir = Path(self.builtin_skills_root) / skill_name
            if skill_dir.exists() and skill_dir.is_dir():
                specs.append(
                    {
                        "name": skill_name,
                        "path": str(skill_dir),
                        "description": "",
                        "source": "builtin",
                    }
                )
            else:
                self._emit("warning", f"[Skills] 内置技能缺失: {skill_name} ({skill_dir})")

        if specs:
            self.add_skills(specs)
        return self

    def _load_memory_skills(self):
        """自动加载 memory_dir/.klynx/skills 下的技能目录。"""
        if not self.memory_dir:
            return self

        skills_root = Path(self.memory_dir) / ".klynx" / "skills"
        if not skills_root.exists() or not skills_root.is_dir():
            return self

        existing_md_paths = {
            str(item.get("skill_md_path", ""))
            for item in self.skill_registry.values()
            if item.get("skill_md_path")
        }
        specs = []
        for skill_dir in self._discover_skill_dirs(skills_root):
            skill_md = self._find_skill_md_path(skill_dir)
            if not skill_md:
                continue
            resolved_md = str(skill_md.resolve())
            if resolved_md in existing_md_paths:
                continue
            specs.append(
                {
                    "name": "",
                    "path": str(skill_dir),
                    "description": "",
                    "source": "memory",
                }
            )

        if specs:
            self.add_skills(specs)
            self._emit("info", f"[Skills] 已从 .klynx/skills 自动加载 {len(specs)} 个技能")
        return self

    def _refresh_skills_prompt(self):
        """刷新 skills 目录提示词缓存。"""
        if not self.skill_registry:
            self._skills_prompt_cache = ""
            return

        lines = []
        lines.append("<skills_registry>")
        lines.append("  <description>默认只展示可用 skills 的元信息。只有显式选择 skill 后，才会把对应 SKILL.md 正文注入当前 turn。</description>")
        lines.append("  <rules>")
        lines.append("    <rule>仅能使用下方已安装的技能名称，不要虚构技能。</rule>")
        lines.append("    <rule>只有在明确需要某个 skill 工作流时，才调用 load_skill。</rule>")
        lines.append("    <rule>加载后优先遵循 SKILL.md 中的步骤、脚本和 references 指引。</rule>")
        lines.append("  </rules>")
        lines.append("  <available_skills>")
        for name in sorted(self.skill_registry.keys()):
            item = self.skill_registry[name]
            skill_dir = item.get("skill_dir", "")
            skill_md = item.get("skill_md_path", "")
            source = item.get("source", "external")
            scope = item.get("scope", source)
            desc = item.get("description", "") or "（无描述）"
            lines.append(
                f'    <skill name="{self._escape_xml(name)}" dir="{self._escape_xml(skill_dir)}" '
                f'skill_md="{self._escape_xml(skill_md)}" source="{self._escape_xml(source)}" '
                f'scope="{self._escape_xml(scope)}">{self._escape_xml(desc)}</skill>'
            )
        lines.append("  </available_skills>")
        lines.append("</skills_registry>")
        self._skills_prompt_cache = "\n".join(lines)

    def add_skills(self, *skills):
        """
        安装技能目录到当前 Agent 实例。

        支持传入：
        1) 技能目录路径
        2) SKILL.md 文件路径
        3) 技能名称（会在 skills_root 下解析）
        4) (name, path) / (name, path, description)
        5) add_skills(name, path, description) 兼容写法
        """
        specs = self._iter_skill_specs(*skills)
        installed_names = []

        for spec in specs:
            skill_ref = spec.get("path", "")
            alias_name = spec.get("name", "").strip()
            alias_desc = spec.get("description", "").strip()
            source = spec.get("source", "external")

            skill_dir = self._resolve_skill_dir(skill_ref)
            skill_md = self._find_skill_md_path(skill_dir)
            if skill_md is None:
                raise FileNotFoundError(f"目录中未找到 SKILL.md: {skill_dir}")

            try:
                with open(skill_md, "r", encoding="utf-8", errors="ignore") as f:
                    content = f.read()
            except Exception as e:
                raise RuntimeError(f"读取技能文件失败: {skill_md} ({e})") from e

            frontmatter = self._extract_skill_frontmatter(content)
            skill_name = (alias_name or frontmatter.get("name") or skill_dir.name).strip()
            description = (alias_desc or frontmatter.get("description") or "").strip()
            scope = self._infer_skill_scope(skill_dir, source)

            existing = self.skill_registry.get(skill_name)
            if existing and existing.get("skill_md_path") != str(skill_md.resolve()):
                existing_scope = str(existing.get("scope", "") or "").strip()
                if self._scope_sort_key(scope) < self._scope_sort_key(existing_scope):
                    self._emit(
                        "info",
                        f"[Skills] 同名技能按 scope 覆盖: {skill_name} {existing_scope} -> {scope}",
                    )
                else:
                    self._emit(
                        "info",
                        f"[Skills] 跳过低优先级同名技能: {skill_name} ({scope})",
                    )
                    continue

            self.skill_registry[skill_name] = {
                "name": skill_name,
                "description": description,
                "skill_dir": str(skill_dir),
                "skill_md_path": str(skill_md.resolve()),
                "source": source,
                "scope": scope,
            }
            installed_names.append(skill_name)
            self._emit("info", f"[Skills] 已安装技能: {skill_name} ({skill_md}) [{scope}]")

        self._refresh_skill_registry_cache()
        self._sync_load_skill_tool()
        self._refresh_skills_prompt()
        if installed_names:
            self._emit("info", f"[Skills] 共安装 {len(installed_names)} 个技能")
        return self

    def basic_skills(self, mode: str = "on"):
        """
        开关内置基础技能（skill-creator / skill-installer）。

        Args:
            mode: "on" 或 "off"
        """
        normalized = str(mode or "").strip().lower()
        if normalized in ("on", "true", "1", "enable", "enabled"):
            self.basic_skills_enabled = True
            self._load_builtin_skills()
            return self

        if normalized in ("off", "false", "0", "disable", "disabled"):
            self.basic_skills_enabled = False
            removed = []
            for name, meta in list(self.skill_registry.items()):
                if meta.get("source") == "builtin":
                    self.skill_registry.pop(name, None)
                    self._skill_body_cache.pop(name, None)
                    removed.append(name)
            self._refresh_skill_registry_cache()
            self._sync_load_skill_tool()
            self._refresh_skills_prompt()
            if removed:
                self._emit("info", f"[Skills] 已禁用内置技能: {', '.join(sorted(removed))}")
            return self

        raise ValueError("basic_skills 仅支持 'on' 或 'off'")

    def get_skill_markdown(self, skill_name: str) -> Dict[str, Any]:
        """按技能名读取 SKILL.md 内容。"""
        if not isinstance(skill_name, str) or not skill_name.strip():
            available = ", ".join(sorted(self.skill_registry.keys())) or "(无)"
            return {
                "ok": False,
                "error": f"技能名为空。可用技能: {available}",
            }

        normalized = skill_name.strip()
        match_name = None
        if normalized in self.skill_registry:
            match_name = normalized
        else:
            for name in self.skill_registry.keys():
                if name.lower() == normalized.lower():
                    match_name = name
                    break

        if not match_name:
            available = ", ".join(sorted(self.skill_registry.keys())) or "(无)"
            return {
                "ok": False,
                "error": f"未安装技能: {normalized}。可用技能: {available}",
            }

        meta = self.skill_registry[match_name]
        if match_name in self._skill_body_cache:
            content = self._skill_body_cache[match_name]
        else:
            try:
                with open(meta["skill_md_path"], "r", encoding="utf-8", errors="ignore") as f:
                    content = f.read().strip()
                self._skill_body_cache[match_name] = content
            except Exception as e:
                return {
                    "ok": False,
                    "error": f"读取 SKILL.md 失败: {e}",
                }

        return {
            "ok": True,
            "name": match_name,
            "description": meta.get("description", ""),
            "skill_dir": meta.get("skill_dir", ""),
            "skill_md_path": meta.get("skill_md_path", ""),
            "content": content,
        }

    def add_tools(self, *args):
        """
        加载工具到 Agent
        
        支持三种调用方式：
        1. agent.add_tools("all")                        - 显式加载所有基础工具组
        2. agent.add_tools("group:core")                - 加载指定工具组
        3. agent.add_tools("read_file", "write_to_file") - 加载指定基础工具
        4. agent.add_tools(my_func, "工具说明")          - 加载外部函数作为工具
        
        可多次调用以组合加载：
            agent.add_tools("group:core", "group:interactive")
            agent.add_tools(my_analyzer, "分析代码结构并返回报告")
        """
        if not args:
            raise ValueError("add_tools 需要至少一个参数")
        
        # 模式1: add_tools("all") - 显式加载所有基础工具组
        if len(args) == 1 and args[0] == "all":
            self.tools.update(self.BASE_TOOLS)
            self.active_tool_groups = list(self.TOOL_GROUPS.keys())
            self._sync_load_skill_tool()
            self._emit("info", f"[工具] 已加载全部 {len(self.BASE_TOOLS)} 个基础工具")
            self._refresh_tool_prompts()
            return self
        
        # 模式3: add_tools(callable, "说明") - 加载外部函数
        if len(args) == 2 and callable(args[0]) and isinstance(args[1], str):
            func, description = args
            tool_name = func.__name__
            self.tools[tool_name] = description
            self.external_tool_funcs[tool_name] = func
            self._emit("info", f"[工具] 已加载外部工具: {tool_name} - {description}")
            self._update_active_tool_groups()
            self._refresh_tool_prompts()
            return self
        
        # 模式2/3: group:* 或基础工具名
        loaded_groups: List[str] = []
        loaded_names: List[str] = []
        for name in args:
            if not isinstance(name, str):
                raise ValueError(f"无效参数: {name}，期望工具名称字符串或 (callable, str)")
            if name.startswith("group:"):
                group_name = self._normalize_tool_group_name(name.split(":", 1)[1])
                if group_name not in self.TOOL_GROUPS:
                    raise ValueError(
                        f"未知工具组: {group_name}，可用工具组: {', '.join(sorted(self.TOOL_GROUPS.keys()))}"
                    )
                for tool_name in self._iter_group_tool_names(group_name):
                    self.tools[tool_name] = self.BASE_TOOLS[tool_name]
                loaded_groups.append(group_name)
                continue
            if name not in self.BASE_TOOLS:
                raise ValueError(f"未知基础工具: {name}，可用工具: {', '.join(self.BASE_TOOLS.keys())}")
            self.tools[name] = self.BASE_TOOLS[name]
            loaded_names.append(name)
        self._update_active_tool_groups()
        self._sync_load_skill_tool()
        loaded_parts = []
        if loaded_groups:
            loaded_parts.append(f"工具组 {', '.join(loaded_groups)}")
        if loaded_names:
            loaded_parts.append(f"工具 {', '.join(loaded_names)}")
        summary = "；".join(loaded_parts) if loaded_parts else ", ".join(args)
        self._emit("info", f"[工具] 已加载 {summary}")
        self._refresh_tool_prompts()
        return self


    def add_mcp(self, mcp_json_path: str):
        """
        加载外部 MCP (Model Context Protocol) Server，
        并将它们暴露出的 Tools 挂载到 Agent 中。
        """
        self._emit("info", f"[MCP] 加载 MCP 配置文件: {mcp_json_path}")
        mcp_tools = self.mcp_manager.load_from_json(mcp_json_path)
        if mcp_tools:
            # MCP tools are returned as LangChain StructuredTools
            for tool in mcp_tools:
                self.add_tools(tool, tool.description)
            self._emit("info", f"[MCP] 成功挂载 {len(mcp_tools)} 个 MCP 工具")
        else:
            self._emit("warning", f"[MCP] 未从 {mcp_json_path} 加载到任何工具")
        return self


    def _build_graph(self) -> StateGraph:
        """Graph orchestration is defined by concrete agent classes in agents.py."""
        raise NotImplementedError("_build_graph must be implemented in libs/klynx/klynx/agent/agents.py")


    def _emit(self, event_type: str, content: str, **kwargs):
        """将事件存入缓冲区（替代 print）"""
        self._event_buffer.append({"type": event_type, "content": content, **kwargs})

    def add_hook(self, hook: AgentHook):
        """Register one runtime hook."""
        self.hook_manager.add_hook(hook)
        return self

    def set_hooks(self, hooks: Optional[List[AgentHook]]):
        """Replace all runtime hooks."""
        self.hook_manager.set_hooks(hooks)
        return self

    def clear_hooks(self):
        """Remove all runtime hooks."""
        self.hook_manager.clear()
        return self

    def _build_hook_context(self, state: Dict[str, Any], iteration: int, stage: str) -> AgentHookContext:
        return AgentHookContext(
            state=dict(state),
            iteration=iteration,
            thread_id=str(state.get("thread_id", "") or ""),
            working_dir=self.working_dir,
            metadata={"stage": stage},
        )

    def _run_before_prompt_hooks(self, state: Dict[str, Any], iteration: int, messages: List[Any]) -> Dict[str, Any]:
        if not self.hook_manager.hooks:
            return {"messages": messages, "state": {}}
        context = self._build_hook_context(state, iteration, stage="before_prompt")
        try:
            return self.hook_manager.run_before_prompt(context, messages)
        except Exception as exc:
            self._emit("warning", f"[Hook] before_prompt failed: {exc}")
            return {"messages": messages, "state": {}}

    def _run_after_model_hooks(self, state: Dict[str, Any], iteration: int, model_output: Dict[str, Any]) -> Dict[str, Any]:
        if not self.hook_manager.hooks:
            return model_output
        context = self._build_hook_context(state, iteration, stage="after_model")
        try:
            return self.hook_manager.run_after_model(context, model_output)
        except Exception as exc:
            self._emit("warning", f"[Hook] after_model failed: {exc}")
            return model_output

    def _run_after_tools_hooks(
        self,
        state: Dict[str, Any],
        iteration: int,
        tool_result: Dict[str, Any],
        executed_tools: List[Dict[str, Any]],
    ) -> Dict[str, Any]:
        if not self.hook_manager.hooks:
            return tool_result
        context = self._build_hook_context(state, iteration, stage="after_tools")
        try:
            return self.hook_manager.run_after_tools(context, tool_result, executed_tools)
        except Exception as exc:
            self._emit("warning", f"[Hook] after_tools failed: {exc}")
            return tool_result


    def ask(self, message: str, system_prompt: str = None, thread_id: str = "default"):
        """Ask flow is defined by concrete agent classes in agents.py."""
        raise NotImplementedError("ask must be implemented in libs/klynx/klynx/agent/agents.py")


    def invoke(
        self,
        task: str,
        thread_id: str = "default",
        thinking_context: bool = False,
        system_prompt_append: str = "",
    ):
        """Invoke flow is defined by concrete agent classes in agents.py."""
        raise NotImplementedError("invoke must be implemented in libs/klynx/klynx/agent/agents.py")


    def compact_context(self, thread_id: str = "default") -> tuple:
        """
        手动触发上下文压缩
        
        Returns:
            (status_message: str, summary_text: str or None)
        """
        config = {"configurable": {"thread_id": thread_id}}
        current_state = self.app.get_state(config)
        
        if not current_state or not current_state.values:
            return ("无有效上下文", None)
            
        state = current_state.values
        messages = state.get("messages", [])
        
        if not messages:
            return ("消息历史为空，无需压缩", None)
            
        # 强制执行总结
        try:
            self._emit("info", "[手动压缩] 开始压缩上下文...")
            result = self._summarize_context(state)
            
            if result:
                # 更新状态
                self.app.update_state(config, result)
                summary = result.get("context_summary", "")
                return (f"上下文压缩完成。摘要长度: {len(summary)} 字符", summary)
            else:
                return ("上下文压缩失败或无需压缩", None)
        except Exception as e:
            return (f"压缩过程出错: {e}", None)


    def get_context(self, thread_id: str = "default") -> dict:
        """
        获取指定会话的当前完整状态上下文
        
        封装了向 LangGraph MemorySaver 查询特定 thread_id 最新状态的逻辑，
        方便用户直接调用来获取对话历史、Token用量等数据。
        
        Args:
            thread_id: 会话线程 ID
            
        Returns:
            包含当前状态所有键值对的字典。如果会话不存在，返回空字典。
            常用键包括: "messages", "overall_goal", "total_tokens", "iteration_count" 等。
        """
        config = {"configurable": {"thread_id": thread_id}}
        current_state = self.app.get_state(config)
        return current_state.values if current_state else {}


    def get_history(self, thread_id: str = "default", limit: int = 20) -> list:
        """
        获取操作流历史记录
        
        从检查点历史中提取Agent的操作流（工具调用、用户输入），
        以便用户了解Agent做了什么并选择回滚目标。
        
        Args:
            thread_id: 会话线程 ID
            limit: 返回的最大历史记录数
            
        Returns:
            操作流列表，每项包含 index, checkpoint_id, action_summary 等信息
        """
        config = {"configurable": {"thread_id": thread_id}}
        
        history = []
        prev_msg_count = 0
        
        try:
            snapshots = list(self.app.get_state_history(config))
            
            for idx, state_snapshot in enumerate(snapshots):
                if idx >= limit:
                    break
                
                values = state_snapshot.values
                messages = values.get("messages", [])
                checkpoint_id = state_snapshot.config["configurable"].get("checkpoint_id", "")
                next_nodes = list(state_snapshot.next) if state_snapshot.next else []
                node_name = next_nodes[0] if next_nodes else "(结束)"
                
                # 提取本步骤新增的操作摘要
                action_summary = self._extract_action_summary(messages, prev_msg_count)
                prev_msg_count = len(messages)
                
                history.append({
                    "index": idx,
                    "checkpoint_id": checkpoint_id,
                    "node": node_name,
                    "message_count": len(messages),
                    "iteration": values.get("iteration_count", 0),
                    "action": action_summary,
                    "task_completed": values.get("task_completed", False),
                    "progress": values.get("progress_summary", "")  
                })
        except Exception as e:
            self._emit("error", f"[错误] 获取历史记录失败: {e}")
        
        # 反转顺序：从旧到新显示（get_state_history 返回最新在前）
        history.reverse()
        # 重新编号
        for i, item in enumerate(history):
            item["display_index"] = i
        
        return history


    def _extract_action_summary(self, messages: list, prev_count: int) -> str:
        """
        从新增的消息中提取操作摘要
        
        Args:
            messages: 当前所有消息
            prev_count: 上一个检查点的消息数
            
        Returns:
            操作摘要字符串
        """
        if not messages:
            return "初始化"
        
        # 获取新增的消息
        new_messages = messages[prev_count:] if prev_count < len(messages) else messages[-1:]
        
        actions = []
        for msg in new_messages:
            content = getattr(msg, 'content', str(msg))
            
            if isinstance(msg, HumanMessage):
                # 检查是否是工具结果
                if '<tool_result' in content:
                    import re
                    tool_match = re.search(r'tool="(\w+)"', content)
                    if tool_match:
                        tool_name = tool_match.group(1)
                        # 提取关键信息
                        if 'success' in content.lower():
                            actions.append(f"✓ {tool_name}")
                        elif 'error' in content.lower():
                            actions.append(f"✗ {tool_name}")
                        else:
                            actions.append(f"→ {tool_name}")
                elif content.startswith('[---'):
                    actions.append("─── 新一轮对话 ───")
                else:
                    # 用户输入
                    snippet = content[:60] + "..." if len(content) > 60 else content
                    actions.append(f"用户: {snippet}")
            elif isinstance(msg, AIMessage):
                # 从AI消息中提取工具调用
                import re
                tool_tags = [
                    'read_file', 'apply_patch', 'write_to_file', 'execute_command', 'search_in_files',
                    'replace_in_file', 'list_directory', 'create_directory',
                    'load_skill', 'write_todos', 'update_task_state'
                ]
                found_tools = []
                for tag in tool_tags:
                    if f'<{tag}' in content or f'<{tag}>' in content:
                        found_tools.append(tag)
                
                if found_tools:
                    actions.append(f"Agent调用: {', '.join(found_tools)}")
                elif '<write_todos>' in content:
                    actions.append("更新Todo")
        
        return " | ".join(actions) if actions else "状态更新"


    def rollback(self, thread_id: str = "default", target_index: int = None) -> bool:
        """
        回滚到指定的检查点
        
        通过存储目标检查点的 config，在下次 stream 调用时恢复到该状态。
        不使用 update_state（会创建新检查点导致重复）。
        
        Args:
            thread_id: 会话线程 ID
            target_index: 目标检查点在 get_state_history 中的索引
            
        Returns:
            是否成功设置回滚
        """
        config = {"configurable": {"thread_id": thread_id}}
        
        try:
            snapshots = list(self.app.get_state_history(config))
            
            if not snapshots:
                self._emit("info", "[回滚] 暂无历史记录")
                return False
            
            if target_index is None:
                # 默认回退1步
                target_index = 1
            
            if target_index >= len(snapshots):
                self._emit("error", f"[回滚] 索引 {target_index} 超出历史范围 (共 {len(snapshots)} 条)")
                return False
            
            target_state = snapshots[target_index]
            target_values = target_state.values
            
            # 存储回滚配置，供下次 stream 使用
            self._rollback_config = target_state.config
            
            # 打印回滚信息
            msgs = target_values.get("messages", [])
            progress = target_values.get("progress_summary", "")
            
            self._emit("info", f"[回滚] 已设置回滚到检查点 (index={target_index})")
            self._emit("info", f"  消息数: {len(msgs)}")
            self._emit("info", f"  迭代: {target_values.get('iteration_count', 0)}")
            if progress:
                # 显示最后几行进度
                progress_lines = progress.strip().split('\n')
                for line in progress_lines[-3:]:
                    self._emit("info", f"  {line}")
            
            return True
            
        except Exception as e:
            self._emit("error", f"[回滚] 回滚失败: {e}")
            import traceback
            traceback.print_exc()
            return False


    def run_terminal_agent_stream(
        self,
        task: str,
        thread_id: str,
        system_prompt_append: str = "",
    ) -> dict:
        """Run invoke() and print streaming events in terminal."""
        from klynx.agent.package import run_terminal_agent_stream
        return run_terminal_agent_stream(
            self,
            task,
            thread_id,
            system_prompt_append=system_prompt_append,
        )


    def run_terminal_ask_stream(self, message: str, system_prompt: str = None, thread_id: str = "default") -> str:
        """运行 agent.ask()，流式打印回答，返回完整回答文本"""
        from klynx.agent.package import run_terminal_ask_stream
        return run_terminal_ask_stream(self, message, system_prompt, thread_id)



GraphKlynxAgent = KlynxAgent


def create_agent(working_dir: str = ".", model=None, max_iterations: Optional[int] = None,
                 memory_dir: str = "", load_project_docs: bool = True,
                 os_name: str = platform.system(), browser_headless: bool = False,
                 append_system_prompt: str = "", skills: Optional[List[Any]] = None,
                 tool_protocol_mode: Optional[str] = None, tool_call_mode: str = "native",
                 skills_root: str = "", checkpointer: Optional[Any] = None,
                 tool_virtual_root: str = "", allow_shell_commands: bool = True,
                 max_tools_per_step: int = 20,
                 max_reads_per_file_per_step: int = 6,
                 max_retry_per_tool_per_step: int = 2,
                 tui_stall_threshold: int = 3,
                 full_tui_echo: bool = False,
                 backend: Optional[AgentBackend] = None,
                 store: Optional[AgentStore] = None,
                 hooks: Optional[List[AgentHook]] = None) -> KlynxAgent:
    """Create default Klynx agent instance."""
    from .agents import KlynxAgent as DefaultKlynxAgent

    return DefaultKlynxAgent(
        working_dir=working_dir,
        model=model,
        max_iterations=max_iterations,
        memory_dir=memory_dir,
        load_project_docs=load_project_docs,
        os_name=os_name,
        browser_headless=browser_headless,
        append_system_prompt=append_system_prompt,
        skills=skills,
        tool_protocol_mode=tool_protocol_mode,
        tool_call_mode=tool_call_mode,
        skills_root=skills_root,
        checkpointer=checkpointer,
        tool_virtual_root=tool_virtual_root,
        allow_shell_commands=allow_shell_commands,
        max_tools_per_step=max_tools_per_step,
        max_reads_per_file_per_step=max_reads_per_file_per_step,
        max_retry_per_tool_per_step=max_retry_per_tool_per_step,
        tui_stall_threshold=tui_stall_threshold,
        full_tui_echo=full_tui_echo,
        backend=backend,
        store=store,
        hooks=hooks,
    )
