"""Maisaka 非 CLI 运行时。"""

from collections import deque
from datetime import datetime
from math import ceil
from typing import Any, Literal, Optional, Sequence

import asyncio
import json
import time

from rich.console import Group, RenderableType
from rich.panel import Panel
from rich.pretty import Pretty
from rich.text import Text

from src.cli.console import console
from src.chat.heart_flow.heartFC_utils import CycleDetail
from src.chat.message_receive.chat_manager import BotChatSession, chat_manager
from src.chat.message_receive.message import SessionMessage
from src.chat.utils.utils import is_mentioned_bot_in_message
from src.common.data_models.mai_message_data_model import GroupInfo, UserInfo
from src.common.logger import get_logger
from src.common.utils.utils_config import ChatConfigUtils, ExpressionConfigUtils
from src.config.config import global_config
from src.core.tooling import ToolRegistry, ToolSpec
from src.learners.expression_learner import ExpressionLearner
from src.learners.jargon_miner import JargonMiner
from src.llm_models.payload_content.resp_format import RespFormat
from src.llm_models.payload_content.tool_option import ToolDefinitionInput
from src.mcp_module import MCPManager
from src.mcp_module.host_llm_bridge import MCPHostLLMBridge
from src.mcp_module.provider import MCPToolProvider
from src.plugin_runtime.tool_provider import PluginToolProvider
from src.plugin_runtime.hook_payloads import deserialize_prompt_messages

from .chat_loop_service import ChatResponse, MaisakaChatLoopService
from .context_messages import (
    AssistantMessage,
    LLMContextMessage,
    ReferenceMessage,
    ReferenceMessageType,
    ToolResultMessage,
)
from .display.display_utils import build_tool_call_summary_lines, format_token_count
from .display.prompt_cli_renderer import PromptCLIVisualizer
from .display.stage_status_board import remove_stage_status, update_stage_status
from .history_utils import drop_leading_orphan_tool_results
from .monitor_events import emit_session_start
from .reasoning_engine import MaisakaReasoningEngine
from .reply_effect import ReplyEffectTracker
from .reply_effect.image_utils import extract_visual_attachments_from_sequence
from .reply_effect.quote_utils import extract_quote_target_ids, message_id_from_context_message
from .tool_provider import MaisakaBuiltinToolProvider

logger = get_logger("maisaka_runtime")

MAX_INTERNAL_ROUNDS = 6


class MaisakaHeartFlowChatting:
    """会话级别的 Maisaka 运行时。"""

    _STATE_RUNNING: Literal["running"] = "running"
    _STATE_WAIT: Literal["wait"] = "wait"
    _STATE_STOP: Literal["stop"] = "stop"

    def __init__(self, session_id: str):
        self.session_id = session_id
        chat_stream = chat_manager.get_session_by_session_id(session_id)
        if chat_stream is None:
            raise ValueError(f"未找到会话 {session_id} 对应的 Maisaka 运行时")
        self.chat_stream: BotChatSession = chat_stream

        session_name = chat_manager.get_session_name(session_id) or session_id
        self.session_name = session_name
        self.log_prefix = f"[{session_name}]"
        self._chat_loop_service = MaisakaChatLoopService(
            session_id=session_id,
            is_group_chat=self.chat_stream.is_group_session,
        )
        self._chat_history: list[LLMContextMessage] = []
        self.history_loop: list[CycleDetail] = []

        # Keep all original messages for batching and later learning.
        self.message_cache: list[SessionMessage] = []
        self._last_processed_index = 0
        self._internal_turn_queue: asyncio.Queue[Literal["message", "timeout"]] = asyncio.Queue()

        self._mcp_manager: Optional[MCPManager] = None
        self._mcp_host_bridge: Optional[MCPHostLLMBridge] = None
        self._current_cycle_detail: Optional[CycleDetail] = None
        self._source_messages_by_id: dict[str, SessionMessage] = {}
        self._running = False
        self._cycle_counter = 0
        self._internal_loop_task: Optional[asyncio.Task] = None
        self._message_turn_scheduled = False
        self._deferred_message_turn_task: Optional[asyncio.Task[None]] = None
        self._message_debounce_seconds = 1.0
        self._message_debounce_required = False
        self._message_received_at_by_id: dict[str, float] = {}
        self._last_message_received_at = 0.0
        self._talk_frequency_adjust = 1.0
        self._reply_latency_measurement_started_at: Optional[float] = None
        self._recent_reply_latencies: deque[tuple[float, float]] = deque()
        self._wait_timeout_task: Optional[asyncio.Task[None]] = None
        self._max_internal_rounds = MAX_INTERNAL_ROUNDS
        configured_context_size = (
            global_config.chat.max_context_size
            if self.chat_stream.is_group_session
            else global_config.chat.max_private_context_size
        )
        self._max_context_size = max(1, int(configured_context_size))
        self._agent_state: Literal["running", "wait", "stop"] = self._STATE_STOP
        self._pending_wait_tool_call_id: Optional[str] = None
        self._force_next_timing_continue = False
        self._force_next_timing_message_id = ""
        self._force_next_timing_reason = ""
        self._planner_interrupt_flag: Optional[asyncio.Event] = None
        self._planner_interrupt_requested = False
        self._planner_interrupt_consecutive_count = 0
        self._current_action_tool_names: set[str] = set()
        self.discovered_tool_names: set[str] = set()
        self.deferred_tool_specs_by_name: dict[str, ToolSpec] = {}
        self._planner_interrupt_max_consecutive_count = max(
            0,
            int(global_config.chat.planner_interrupt_max_consecutive_count),
        )

        expr_use, expr_learn, jargon_learn = ExpressionConfigUtils.get_expression_config_for_chat(session_id)
        self._enable_expression_use = expr_use
        self._enable_expression_learning = expr_learn
        self._enable_jargon_learning = jargon_learn
        self._min_extraction_interval = 30
        self._last_expression_extraction_time = 0.0
        self._expression_learner = ExpressionLearner(session_id)
        self._jargon_miner = JargonMiner(session_id, session_name=session_name)

        self._reasoning_engine = MaisakaReasoningEngine(self)
        self._monitor_session_start_task: Optional[asyncio.Task[None]] = None
        self._tool_registry = ToolRegistry()
        self._reply_effect_tracker = ReplyEffectTracker(
            session_id=self.session_id,
            session_name=self.session_name,
            chat_stream=self.chat_stream,
            judge_runner=self._run_reply_effect_judge,
        )
        self._register_tool_providers()
        self._emit_monitor_session_start()

    def _emit_monitor_session_start(self) -> None:
        """向 WebUI 监控面板同步当前会话的展示标识。"""

        try:
            self._monitor_session_start_task = asyncio.create_task(
                emit_session_start(
                    session_id=self.session_id,
                    session_name=self.session_name,
                    is_group_chat=self.chat_stream.is_group_session,
                    group_id=self.chat_stream.group_id,
                    user_id=self.chat_stream.user_id,
                    platform=self.chat_stream.platform,
                )
            )
        except RuntimeError:
            logger.debug("MaiSaka 监控会话开始事件未发送：当前没有运行中的事件循环")

    @staticmethod
    def _is_reply_effect_tracking_enabled() -> bool:
        """判断是否启用回复效果评分追踪。"""

        return bool(global_config.debug.enable_reply_effect_tracking)

    def _update_stage_status(self, stage: str, detail: str = "", *, round_text: str = "") -> None:
        """更新当前会话的阶段状态。"""

        update_stage_status(
            session_id=self.session_id,
            session_name=self.session_name,
            stage=stage,
            detail=detail,
            round_text=round_text,
            agent_state=self._agent_state,
        )

    async def start(self) -> None:
        """启动运行时主循环。"""
        if self._running:
            self._ensure_background_tasks_running()
            return

        if global_config.mcp.enable:
            await self._init_mcp()

        self._running = True
        self._ensure_background_tasks_running()
        self._schedule_message_turn()
        self._update_stage_status("空闲", "等待消息触发")
        logger.info(f"{self.log_prefix} Maisaka 运行时已启动")

    async def stop(self) -> None:
        """停止运行时主循环。"""
        if not self._running:
            return

        self._running = False
        self._message_turn_scheduled = False
        self._message_debounce_required = False
        self._cancel_deferred_message_turn_task()
        self._cancel_wait_timeout_task()
        while not self._internal_turn_queue.empty():
            _ = self._internal_turn_queue.get_nowait()

        if self._internal_loop_task is not None:
            self._internal_loop_task.cancel()
            try:
                await self._internal_loop_task
            except asyncio.CancelledError:
                pass
            finally:
                self._internal_loop_task = None

        if self._is_reply_effect_tracking_enabled():
            await self._reply_effect_tracker.finalize_all("runtime_stop")
        await self._tool_registry.close()
        self._mcp_manager = None
        self._mcp_host_bridge = None
        remove_stage_status(self.session_id)

        logger.info(f"{self.log_prefix} Maisaka 运行时已停止")

    def adjust_talk_frequency(self, frequency: float) -> None:
        """调整当前会话的回复频率倍率。"""
        self._talk_frequency_adjust = max(0.01, float(frequency))
        self._schedule_message_turn()

    def append_sent_message_to_chat_history(
        self,
        message: SessionMessage,
        *,
        source_kind: str = "guided_reply",
    ) -> bool:
        """将一条已发送成功的消息同步到 Maisaka 内部历史。"""

        try:
            from .context_messages import SessionBackedMessage
            from .history_utils import build_prefixed_message_sequence, build_session_message_visible_text
            from .planner_message_utils import build_planner_prefix

            user_info = message.message_info.user_info
            speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id
            planner_prefix = build_planner_prefix(
                timestamp=message.timestamp,
                user_name=speaker_name,
                group_card=user_info.user_cardname or "",
                message_id=message.message_id,
                include_message_id=not message.is_notify and bool(message.message_id),
            )
            history_message = SessionBackedMessage.from_session_message(
                message,
                raw_message=build_prefixed_message_sequence(message.raw_message, planner_prefix),
                visible_text=build_session_message_visible_text(
                    message,
                    include_reply_components=source_kind != "guided_reply",
                ),
                source_kind=source_kind,
            )
            self._chat_history.append(history_message)
            return True
        except Exception as exc:
            logger.warning(
                f"{self.log_prefix} 同步已发送消息到 Maisaka 历史失败: "
                f"message_id={message.message_id} error={exc}"
            )
            return False

    async def register_message(self, message: SessionMessage) -> None:
        """缓存一条新消息并唤醒主循环。"""
        if self._running:
            self._ensure_background_tasks_running()
        received_at = time.time()
        self._last_message_received_at = received_at
        self._update_message_trigger_state(message)
        self.message_cache.append(message)
        self._message_received_at_by_id[message.message_id] = received_at
        self._source_messages_by_id[message.message_id] = message
        if self._is_reply_effect_tracking_enabled():
            asyncio.create_task(self._reply_effect_tracker.observe_user_message(message))
        if self._agent_state == self._STATE_RUNNING:
            self._message_debounce_required = True
        if self._agent_state == self._STATE_RUNNING and self._planner_interrupt_flag is not None:
            if self._planner_interrupt_requested:
                logger.info(
                    f"{self.log_prefix} 收到新消息，但当前请求已发起过一次规划器打断，"
                    f"本次不重复打断; 消息编号={message.message_id} "
                    f"连续打断次数={self._planner_interrupt_consecutive_count}/"
                    f"{self._planner_interrupt_max_consecutive_count}"
                )
            elif self._planner_interrupt_consecutive_count >= self._planner_interrupt_max_consecutive_count:
                logger.info(
                    f"{self.log_prefix} 收到新消息，但已达到规划器连续打断上限，"
                    f"将等待当前请求自然完成; 消息编号={message.message_id} "
                    f"连续打断次数={self._planner_interrupt_consecutive_count}/"
                    f"{self._planner_interrupt_max_consecutive_count}"
                )
            else:
                self._planner_interrupt_requested = True
                self._planner_interrupt_consecutive_count += 1
                logger.info(
                    f"{self.log_prefix} 收到新消息，发起规划器打断; "
                    f"消息编号={message.message_id} 缓存条数={len(self.message_cache)} "
                    f"时间戳={time.time():.3f} "
                    f"连续打断次数={self._planner_interrupt_consecutive_count}/"
                    f"{self._planner_interrupt_max_consecutive_count}"
                )
                self._planner_interrupt_flag.set()
        if self._running:
            self._schedule_message_turn()

    def _get_effective_reply_frequency(self) -> float:
        """返回当前会话生效的回复频率。"""
        talk_value = max(
            0.01,
            float(
                ChatConfigUtils.get_talk_value(
                    self.session_id,
                    is_group_chat=self.chat_stream.is_group_session,
                )
            ),
        )
        return max(0.01, talk_value * self._talk_frequency_adjust)

    async def track_reply_effect(
        self,
        *,
        tool_call_id: str,
        target_message: SessionMessage,
        set_quote: bool,
        reply_text: str,
        reply_segments: list[str],
        planner_reasoning: str,
        reference_info: str,
        tool_context: Optional[dict[str, Any]] = None,
        send_results: Optional[list[dict[str, Any]]] = None,
        reply_metadata: Optional[dict[str, Any]] = None,
        replyer_context_messages: Optional[Sequence[LLMContextMessage]] = None,
    ) -> None:
        """登记一次已成功发送的 reply 工具回复，供后续用户反馈评分。"""

        if not self._is_reply_effect_tracking_enabled():
            return

        try:
            context_snapshot = self._build_reply_effect_context_snapshot(
                context_messages=replyer_context_messages,
                exclude_reply_segments=reply_segments if replyer_context_messages is None else None,
            )
            enriched_reply_metadata = dict(reply_metadata or {})
            enriched_reply_metadata["replyer_context_count"] = (
                len(replyer_context_messages) if replyer_context_messages is not None else len(self._chat_history)
            )
            enriched_reply_metadata["recorded_context_count"] = len(context_snapshot)
            await self._reply_effect_tracker.record_reply(
                tool_call_id=tool_call_id,
                target_message=target_message,
                set_quote=set_quote,
                reply_text=reply_text,
                reply_segments=reply_segments,
                planner_reasoning=planner_reasoning,
                reference_info=reference_info,
                tool_context=tool_context,
                send_results=send_results,
                reply_metadata=enriched_reply_metadata,
                context_snapshot=context_snapshot,
            )
        except Exception as exc:
            logger.warning(f"{self.log_prefix} 创建回复效果观察记录失败: {exc}")

    def _build_reply_effect_context_snapshot(
        self,
        *,
        context_messages: Optional[Sequence[LLMContextMessage]] = None,
        exclude_reply_segments: Optional[Sequence[str]] = None,
    ) -> list[dict[str, Any]]:
        """构建回复效果观察使用的上下文快照。

        优先记录 replyer 当次生成时实际收到的完整上下文列表；只有旧调用未传入时才回退到当前运行时历史。
        """

        source_messages = list(context_messages) if context_messages is not None else list(self._chat_history)
        snapshot: list[dict[str, Any]] = []
        excluded_segments = [segment.strip() for segment in (exclude_reply_segments or []) if segment.strip()]
        for message in source_messages:
            text = str(message.processed_plain_text or "").strip()
            if not text:
                continue
            if message.source == "guided_reply" and any(segment in text for segment in excluded_segments):
                continue
            snapshot.append(
                {
                    "message_id": message_id_from_context_message(message),
                    "source": message.source,
                    "role": message.role,
                    "timestamp": message.timestamp.isoformat(timespec="seconds"),
                    "text": text,
                    "quote_target_ids": extract_quote_target_ids(getattr(message, "raw_message", None)),
                    "attachments": extract_visual_attachments_from_sequence(getattr(message, "raw_message", None)),
                }
            )
        return snapshot

    def _get_message_trigger_threshold(self) -> int:
        """根据回复频率折算出触发一轮循环所需的消息数。"""
        effective_frequency = min(1.0, self._get_effective_reply_frequency())
        return max(1, int(ceil(1.0 / effective_frequency)))

    def _get_pending_message_count(self) -> int:
        """统计当前尚未进入内部循环的新消息数量。"""
        pending_messages = self.message_cache[self._last_processed_index :]
        if not pending_messages:
            return 0

        seen_message_ids: set[str] = set()
        for message in pending_messages:
            seen_message_ids.add(message.message_id)
        return len(seen_message_ids)

    def _prune_recent_reply_latencies(self, now: Optional[float] = None) -> None:
        """仅保留最近 10 分钟内的回复时长记录。"""
        current_time = time.time() if now is None else now
        expire_before = current_time - 600.0
        while self._recent_reply_latencies and self._recent_reply_latencies[0][0] < expire_before:
            self._recent_reply_latencies.popleft()

    def _get_recent_average_reply_latency(self) -> Optional[float]:
        """获取最近 10 分钟平均消息回复时长。"""
        self._prune_recent_reply_latencies()
        if not self._recent_reply_latencies:
            return None

        total_duration = sum(duration for _, duration in self._recent_reply_latencies)
        return total_duration / len(self._recent_reply_latencies)

    def _record_reply_sent(self) -> None:
        """在成功发送 reply 后记录本轮消息回复时长。"""
        if self._reply_latency_measurement_started_at is None:
            return

        reply_duration = max(0.0, time.time() - self._reply_latency_measurement_started_at)
        self._reply_latency_measurement_started_at = None
        self._recent_reply_latencies.append((time.time(), reply_duration))
        self._prune_recent_reply_latencies()
        logger.debug(
            f"{self.log_prefix} 已记录消息回复时长: {reply_duration:.2f} 秒 "
            f"最近10分钟样本数={len(self._recent_reply_latencies)}"
        )

    def _should_trigger_message_turn_by_idle_compensation(
        self,
        *,
        pending_count: int,
        trigger_threshold: int,
    ) -> bool:
        """在新消息不足阈值时，按空窗时间折算补齐触发条件。"""
        average_reply_latency = self._get_recent_average_reply_latency()
        if average_reply_latency is None or average_reply_latency <= 0:
            return False

        idle_seconds = max(0.0, time.time() - self._last_message_received_at)
        equivalent_message_count = pending_count + idle_seconds / average_reply_latency
        return equivalent_message_count >= trigger_threshold

    def _cancel_deferred_message_turn_task(self) -> None:
        """取消等待空窗补偿触发的延迟任务。"""
        if self._deferred_message_turn_task is None:
            return
        self._deferred_message_turn_task.cancel()
        self._deferred_message_turn_task = None

    async def _schedule_deferred_message_turn(self, delay_seconds: float) -> None:
        """在预计满足空窗补偿条件时再次检查是否应触发循环。"""
        try:
            if delay_seconds > 0:
                await asyncio.sleep(delay_seconds)
            if not self._running:
                return
            self._schedule_message_turn()
        except asyncio.CancelledError:
            return
        finally:
            self._deferred_message_turn_task = None

    def _update_message_trigger_state(self, message: SessionMessage) -> None:
        """补齐消息中的 @/提及 标记，并在命中时启用强制 continue。"""

        detected_mentioned, detected_at, reply_probability_boost = is_mentioned_bot_in_message(message)
        if detected_at:
            message.is_at = True
        if detected_mentioned:
            message.is_mentioned = True

        should_force_reply = (
            reply_probability_boost >= 1.0
            or (message.is_at and global_config.chat.inevitable_at_reply)
            or (message.is_mentioned and global_config.chat.mentioned_bot_reply)
        )
        if not should_force_reply or (not message.is_at and not message.is_mentioned):
            return

        self._arm_force_next_timing_continue(
            message,
            is_at=message.is_at,
            is_mentioned=message.is_mentioned,
        )

    def _arm_force_next_timing_continue(
        self,
        message: SessionMessage,
        *,
        is_at: bool,
        is_mentioned: bool,
    ) -> None:
        """在检测到 @ 或提及时，要求下一次 Timing Gate 直接 continue。"""

        trigger_reason = "@消息" if is_at else "提及消息" if is_mentioned else "触发消息"
        was_armed = self._force_next_timing_continue
        self._force_next_timing_continue = True
        self._force_next_timing_message_id = message.message_id
        self._force_next_timing_reason = trigger_reason

        if was_armed:
            logger.info(
                f"{self.log_prefix} 检测到新的{trigger_reason}，刷新强制 continue 状态；"
                f"消息编号={message.message_id}"
            )
            return

        logger.info(
            f"{self.log_prefix} 检测到{trigger_reason}，下一次 Timing Gate 将直接视作 continue；"
            f"消息编号={message.message_id}"
        )

    def _consume_force_next_timing_continue_reason(self) -> str | None:
        """消费一次性 Timing Gate continue 状态，并返回原因描述。"""

        if not self._force_next_timing_continue:
            return None

        trigger_reason = self._force_next_timing_reason or "@/提及消息"
        trigger_message_id = self._force_next_timing_message_id or "unknown"
        reason = (
            f"检测到新的{trigger_reason}（消息编号={trigger_message_id}），"
            "本轮直接跳过 Timing Gate 并视作 continue。"
        )
        logger.info(
            f"{self.log_prefix} 已结束本次强制 continue，恢复 Timing Gate；"
            f"触发原因={trigger_reason} "
            f"触发消息编号={trigger_message_id}"
        )
        self._force_next_timing_continue = False
        self._force_next_timing_message_id = ""
        self._force_next_timing_reason = ""
        return reason

    def _has_forced_timing_trigger(self) -> bool:
        """判断是否已有 @/提及必回触发，需绕过普通频率阈值。"""

        return self._force_next_timing_continue

    def _bind_planner_interrupt_flag(self, interrupt_flag: asyncio.Event) -> None:
        """绑定当前可打断请求使用的中断标记。"""
        self._planner_interrupt_flag = interrupt_flag
        self._planner_interrupt_requested = False

    def _unbind_planner_interrupt_flag(
        self,
        interrupt_flag: asyncio.Event,
        *,
        interrupted: bool,
    ) -> None:
        """解绑当前可打断请求的中断标记，并维护连续打断计数。"""
        if self._planner_interrupt_flag is interrupt_flag:
            self._planner_interrupt_flag = None
        self._planner_interrupt_requested = False
        if not interrupted:
            self._planner_interrupt_consecutive_count = 0

    def _ensure_background_tasks_running(self) -> None:
        """确保后台任务仍在运行，若崩溃则自动拉起。"""
        if not self._running:
            return

        if self._internal_loop_task is None or self._internal_loop_task.done():
            if self._internal_loop_task is not None and not self._internal_loop_task.cancelled():
                try:
                    exc = self._internal_loop_task.exception()
                except Exception:
                    exc = None
                if exc is not None:
                    logger.error(f"{self.log_prefix} 内部循环任务异常退出: {exc}")
            self._internal_loop_task = asyncio.create_task(self._reasoning_engine.run_loop())
            logger.warning(f"{self.log_prefix} 已重新拉起 Maisaka 内部循环任务")

    def _register_tool_providers(self) -> None:
        """注册 Maisaka 运行时默认启用的工具 Provider。"""

        self._tool_registry.register_provider(
            MaisakaBuiltinToolProvider(self._reasoning_engine.build_builtin_tool_handlers())
        )
        self._tool_registry.register_provider(PluginToolProvider())
        self._chat_loop_service.set_tool_registry(self._tool_registry)

    async def run_sub_agent(
        self,
        *,
        context_message_limit: int,
        drop_head_context_count: int = 0,
        system_prompt: str,
        request_kind: str = "sub_agent",
        extra_messages: Optional[Sequence[LLMContextMessage]] = None,
        interrupt_flag: asyncio.Event | None = None,
        model_task_name: str = "planner",
        response_format: RespFormat | None = None,
        tool_definitions: Optional[Sequence[ToolDefinitionInput]] = None,
    ) -> ChatResponse:
        """运行一个复制上下文的临时子代理，并在完成后立即销毁。"""

        selected_history, _ = MaisakaChatLoopService.select_llm_context_messages(
            self._chat_history,
            request_kind=request_kind,
            max_context_size=context_message_limit,
        )
        sub_agent_history = self._drop_head_context_messages(
            selected_history,
            drop_head_context_count,
            trim_threshold_context_count=context_message_limit,
        )
        if extra_messages:
            sub_agent_history.extend(list(extra_messages))

        sub_agent = MaisakaChatLoopService(
            chat_system_prompt=system_prompt,
            session_id=self.session_id,
            is_group_chat=self.chat_stream.is_group_session,
            model_task_name=model_task_name,
        )
        sub_agent.set_interrupt_flag(interrupt_flag)
        return await sub_agent.chat_loop_step(
            sub_agent_history,
            request_kind=request_kind,
            response_format=response_format,
            tool_definitions=[] if tool_definitions is None else tool_definitions,
        )

    @staticmethod
    def _drop_head_context_messages(
        chat_history: Sequence[LLMContextMessage],
        drop_context_count: int,
        *,
        trim_threshold_context_count: int | None = None,
    ) -> list[LLMContextMessage]:
        """从已选上下文头部丢弃指定数量的普通上下文消息。"""

        if drop_context_count <= 0:
            return list(chat_history)

        context_message_count = sum(1 for message in chat_history if message.count_in_context)
        if trim_threshold_context_count is not None and context_message_count <= trim_threshold_context_count:
            return list(chat_history)

        if context_message_count <= drop_context_count:
            return list(chat_history)

        first_kept_index = 0
        dropped_context_count = 0
        while (
            first_kept_index < len(chat_history)
            and dropped_context_count < drop_context_count
        ):
            message = chat_history[first_kept_index]
            if message.count_in_context:
                dropped_context_count += 1
            first_kept_index += 1

        trimmed_history = list(chat_history[first_kept_index:])
        trimmed_history, _ = drop_leading_orphan_tool_results(trimmed_history)
        return trimmed_history

    async def _run_reply_effect_judge(self, prompt: str) -> str:
        """运行回复效果观察器使用的临时 LLM 评审。"""

        judge_message = ReferenceMessage(
            content=prompt,
            timestamp=datetime.now(),
            reference_type=ReferenceMessageType.TOOL_HINT,
            remaining_uses_value=1,
            display_prefix="[回复效果评分任务]",
        )
        response = await self.run_sub_agent(
            context_message_limit=1,
            system_prompt="你是回复效果评分器。请严格按用户给出的 JSON 格式输出，不要输出 JSON 之外的内容。",
            request_kind="reply_effect_judge",
            extra_messages=[judge_message],
            tool_definitions=[],
        )
        return (response.content or "").strip()

    def set_current_action_tool_names(self, tool_names: Sequence[str]) -> None:
        """记录当前 Action Loop 已实际暴露给 planner 的工具名集合。"""

        self._current_action_tool_names = {tool_name for tool_name in tool_names if str(tool_name).strip()}

    def is_action_tool_currently_available(self, tool_name: str) -> bool:
        """判断指定工具在当前 Action Loop 轮次中是否真实可用。"""

        normalized_name = str(tool_name).strip()
        return bool(normalized_name) and normalized_name in self._current_action_tool_names

    def update_deferred_tool_specs(self, deferred_tool_specs: Sequence[ToolSpec]) -> None:
        """刷新当前会话的 deferred tools 池，并清理失效的已发现工具。"""

        next_specs_by_name: dict[str, ToolSpec] = {}
        for tool_spec in deferred_tool_specs:
            normalized_name = tool_spec.name.strip()
            if not normalized_name:
                continue
            next_specs_by_name[normalized_name] = tool_spec

        self.deferred_tool_specs_by_name = next_specs_by_name
        self.discovered_tool_names.intersection_update(next_specs_by_name.keys())

    def sync_discovered_deferred_tools_with_context(
        self,
        selected_history: Sequence[LLMContextMessage],
    ) -> None:
        """根据当前实际上下文中的 tool_search 调用链同步已发现 deferred tools。

        已激活 deferred tool 必须能在本轮上下文中找到对应的 tool_search call 与 result。
        当这条调用链被上下文窗口裁掉后，工具会重新折回 deferred tools 提示中。
        """

        visible_tool_names = self._extract_visible_tool_search_discoveries(selected_history)
        self.discovered_tool_names = visible_tool_names.intersection(self.deferred_tool_specs_by_name.keys())

    def _extract_visible_tool_search_discoveries(
        self,
        selected_history: Sequence[LLMContextMessage],
    ) -> set[str]:
        """提取当前上下文中仍有完整 tool_search call/result 支撑的工具名。"""

        tool_search_call_ids = {
            tool_call.call_id
            for message in selected_history
            if isinstance(message, AssistantMessage)
            for tool_call in message.tool_calls
            if tool_call.func_name == "tool_search" and tool_call.call_id
        }
        if not tool_search_call_ids:
            return set()

        discovered_tool_names: set[str] = set()
        for message in selected_history:
            if not isinstance(message, ToolResultMessage):
                continue
            if message.tool_name != "tool_search" or message.tool_call_id not in tool_search_call_ids:
                continue
            if not message.success:
                continue
            discovered_tool_names.update(self._parse_tool_search_result_tool_names(message.content))
        return discovered_tool_names

    def _parse_tool_search_result_tool_names(self, content: str) -> set[str]:
        """从 tool_search 的历史结果文本中解析有效 deferred tool 名称。"""

        discovered_tool_names: set[str] = set()
        try:
            structured_content = json.loads(content)
        except (TypeError, ValueError):
            structured_content = None

        if isinstance(structured_content, dict):
            raw_tool_names = structured_content.get("matched_tool_names")
            if isinstance(raw_tool_names, list):
                for raw_tool_name in raw_tool_names:
                    normalized_name = str(raw_tool_name).strip()
                    if normalized_name in self.deferred_tool_specs_by_name:
                        discovered_tool_names.add(normalized_name)

        for raw_line in content.splitlines():
            normalized_line = raw_line.strip()
            if not normalized_line.startswith("- "):
                continue
            normalized_name = normalized_line[2:].strip()
            if normalized_name in self.deferred_tool_specs_by_name:
                discovered_tool_names.add(normalized_name)

        return discovered_tool_names

    def get_discovered_deferred_tool_specs(self) -> list[ToolSpec]:
        """返回当前会话中已发现、且仍然有效的 deferred tools。"""

        return [
            tool_spec
            for tool_name, tool_spec in self.deferred_tool_specs_by_name.items()
            if tool_name in self.discovered_tool_names
        ]

    def build_deferred_tools_reminder(self) -> str:
        """构造供 planner 使用的 deferred tools 提示消息。"""

        undiscovered_tool_specs = [
            tool_spec
            for tool_name, tool_spec in self.deferred_tool_specs_by_name.items()
            if tool_name not in self.discovered_tool_names
        ]
        if not undiscovered_tool_specs:
            return ""

        tool_lines: list[str] = []
        for index, tool_spec in enumerate(undiscovered_tool_specs, start=1):
            tool_name = tool_spec.name.strip()
            tool_description = tool_spec.brief_description.strip()
            if tool_description:
                tool_lines.append(f"{index}. {tool_name}: {tool_description}")
            else:
                tool_lines.append(f"{index}. {tool_name}")

        reminder_lines = [
            "<system-reminder>",
            "以下工具当前未直接暴露给你，但可以通过 tool_search 工具发现并在后续轮次中使用：",
            *tool_lines,
            "",
            "如需其中某个工具，请先调用 tool_search。tool_search 只负责发现工具，不直接执行业务。",
            "</system-reminder>",
        ]
        return "\n".join(reminder_lines)

    def search_deferred_tool_specs(
        self,
        query: str,
        *,
        limit: int,
    ) -> list[ToolSpec]:
        """按名称或简要描述搜索 deferred tools。"""

        normalized_query = " ".join(query.lower().split()).strip()
        if not normalized_query:
            return []

        scored_matches: list[tuple[int, str, ToolSpec]] = []
        query_terms = [term for term in normalized_query.replace("_", " ").replace("-", " ").split() if term]
        for tool_name, tool_spec in self.deferred_tool_specs_by_name.items():
            lower_name = tool_name.lower()
            lower_description = tool_spec.brief_description.lower()
            score = 0

            if normalized_query == lower_name:
                score += 1000
            if lower_name.startswith(normalized_query):
                score += 300
            if normalized_query in lower_name:
                score += 200
            if normalized_query in lower_description:
                score += 100

            for query_term in query_terms:
                if query_term in lower_name:
                    score += 25
                if query_term in lower_description:
                    score += 10

            if score <= 0:
                continue

            scored_matches.append((score, tool_name, tool_spec))

        scored_matches.sort(key=lambda item: (-item[0], item[1]))
        return [tool_spec for _, _, tool_spec in scored_matches[: max(1, limit)]]

    def discover_deferred_tools(self, tool_names: Sequence[str]) -> list[str]:
        """将指定 deferred tools 标记为已发现，并返回本次新发现的工具名。"""

        newly_discovered_tool_names: list[str] = []
        for raw_tool_name in tool_names:
            normalized_name = str(raw_tool_name).strip()
            if not normalized_name or normalized_name not in self.deferred_tool_specs_by_name:
                continue
            if normalized_name in self.discovered_tool_names:
                continue
            self.discovered_tool_names.add(normalized_name)
            newly_discovered_tool_names.append(normalized_name)
        return newly_discovered_tool_names

    def _has_pending_messages(self) -> bool:
        return self._last_processed_index < len(self.message_cache)

    def _schedule_message_turn(self) -> None:
        """为当前待处理消息安排一次内部 turn。"""
        if self._agent_state == self._STATE_WAIT:
            return

        if not self._has_pending_messages() or self._message_turn_scheduled:
            return

        pending_count = self._get_pending_message_count()
        if pending_count <= 0:
            return

        if self._has_forced_timing_trigger():
            self._cancel_deferred_message_turn_task()
            self._message_turn_scheduled = True
            self._internal_turn_queue.put_nowait("message")
            return

        trigger_threshold = self._get_message_trigger_threshold()
        if pending_count >= trigger_threshold or self._should_trigger_message_turn_by_idle_compensation(
            pending_count=pending_count,
            trigger_threshold=trigger_threshold,
        ):
            self._cancel_deferred_message_turn_task()
            self._message_turn_scheduled = True
            self._internal_turn_queue.put_nowait("message")
            return

        average_reply_latency = self._get_recent_average_reply_latency()
        if average_reply_latency is None or average_reply_latency <= 0:
            return

        idle_seconds = max(0.0, time.time() - self._last_message_received_at)
        delay_seconds = max(0.0, (trigger_threshold - pending_count) * average_reply_latency - idle_seconds)
        self._cancel_deferred_message_turn_task()
        self._deferred_message_turn_task = asyncio.create_task(
            self._schedule_deferred_message_turn(delay_seconds)
        )

    def _collect_pending_messages(self) -> list[SessionMessage]:
        """从消息缓存中收集一批尚未处理的消息。"""
        start_index = self._last_processed_index
        pending_messages = self.message_cache[start_index:]
        if not pending_messages:
            return []

        unique_messages: list[SessionMessage] = []
        seen_message_ids: set[str] = set()
        for message in pending_messages:
            message_id = message.message_id
            if message_id in seen_message_ids:
                continue
            seen_message_ids.add(message_id)
            unique_messages.append(message)

        self._last_processed_index = len(self.message_cache)
        # logger.info(
            # f"{self.log_prefix} 已从消息缓存区[{start_index}:{self._last_processed_index}] "
            # f"收集 {len(unique_messages)} 条新消息"
        # )
        if unique_messages and self._reply_latency_measurement_started_at is None:
            self._reply_latency_measurement_started_at = min(
                self._message_received_at_by_id.get(message.message_id, self._last_message_received_at)
                for message in unique_messages
            )
        for message in unique_messages:
            self._message_received_at_by_id.pop(message.message_id, None)
        return unique_messages

    async def _wait_for_message_quiet_period(self) -> None:
        """等待消息静默窗口结束后，再启动由打断触发的新一轮。"""
        if not self._message_debounce_required:
            return

        if self._message_debounce_seconds <= 0:
            self._message_debounce_required = False
            return

        while self._running:
            elapsed = time.time() - self._last_message_received_at
            remaining = self._message_debounce_seconds - elapsed
            if remaining <= 0:
                break
            await asyncio.sleep(remaining)

        self._message_debounce_required = False

    def _enter_wait_state(self, seconds: Optional[float] = None, tool_call_id: Optional[str] = None) -> None:
        """切换到等待状态。"""
        self._agent_state = self._STATE_WAIT
        self._pending_wait_tool_call_id = tool_call_id
        self._message_turn_scheduled = False
        self._cancel_deferred_message_turn_task()
        self._cancel_wait_timeout_task()
        if seconds is not None:
            self._wait_timeout_task = asyncio.create_task(
                self._schedule_wait_timeout(seconds=seconds, tool_call_id=tool_call_id)
            )

    def _enter_stop_state(self) -> None:
        """切换到停止状态。"""
        self._agent_state = self._STATE_STOP
        self._pending_wait_tool_call_id = None
        self._cancel_wait_timeout_task()

    def _cancel_wait_timeout_task(self) -> None:
        """取消当前 wait 对应的超时任务。"""
        if self._wait_timeout_task is None:
            return
        self._wait_timeout_task.cancel()
        self._wait_timeout_task = None

    async def _schedule_wait_timeout(self, seconds: float, tool_call_id: Optional[str]) -> None:
        """在 wait 到期后向内部循环投递 timeout 触发。"""
        try:
            if seconds > 0:
                await asyncio.sleep(seconds)
            if not self._running:
                return
            if self._agent_state != self._STATE_WAIT:
                return
            if self._pending_wait_tool_call_id != tool_call_id:
                return

            logger.debug(f"{self.log_prefix} Maisaka 等待已超时")
            self._agent_state = self._STATE_RUNNING
            await self._internal_turn_queue.put("timeout")
        except asyncio.CancelledError:
            return
        finally:
            if self._wait_timeout_task is not None and self._pending_wait_tool_call_id == tool_call_id:
                self._wait_timeout_task = None

    async def _trigger_batch_learning(self, messages: list[SessionMessage]) -> None:
        """按同一批消息触发表达方式和黑话学习。"""
        try:
            await self._trigger_expression_learning(messages)
        except Exception as exc:
            logger.error(f"{self.log_prefix} 表达学习任务异常退出: {exc}")

    def _should_trigger_learning(
        self,
        *,
        enabled: bool,
        feature_name: str,
        last_extraction_time: float,
        pending_count: int,
        min_messages_for_extraction: int,
    ) -> bool:
        """判断周期性学习任务是否满足执行条件。"""

        if not enabled:
            logger.debug(f"{self.log_prefix} {feature_name}未启用，跳过本轮学习")
            return False

        elapsed = time.time() - last_extraction_time
        if elapsed < self._min_extraction_interval:
            logger.debug(
                f"{self.log_prefix} {feature_name}触发间隔不足: "
                f"已过={elapsed:.2f} 秒 阈值={self._min_extraction_interval} 秒"
            )
            return False

        if pending_count < min_messages_for_extraction:
            logger.debug(
                f"{self.log_prefix} {feature_name}待处理消息不足: "
                f"待处理={pending_count} 阈值={min_messages_for_extraction} "
                f"缓存总量={len(self.message_cache)}"
            )
            return False

        return True

    async def _trigger_expression_learning(self, messages: list[SessionMessage]) -> None:
        """触发表达方式学习"""
        pending_count = self._expression_learner.get_pending_count(self.message_cache)
        if not self._should_trigger_learning(
            enabled=self._enable_expression_learning,
            feature_name="表达学习",
            last_extraction_time=self._last_expression_extraction_time,
            pending_count=pending_count,
            min_messages_for_extraction=self._expression_learner.min_messages_for_extraction,
        ):
            return

        self._last_expression_extraction_time = time.time()
        logger.info(
            f"{self.log_prefix} 触发表达方式学习: "
            f"消息数量={len(messages)} 待处理消息数量={pending_count} "
            f"缓存总量={len(self.message_cache)} "
            f"是否启用黑话学习={self._enable_jargon_learning}"
        )

        try:
            jargon_miner = self._jargon_miner if self._enable_jargon_learning else None
            learnt_style = await self._expression_learner.learn(self.message_cache, jargon_miner)
            if learnt_style:
                logger.info(f"{self.log_prefix} 表达方式学习成功")
            else:
                logger.debug(f"{self.log_prefix} 表达方式学习失败")
        except Exception:
            logger.exception(f"{self.log_prefix} 表达方式学习异常")

    async def _init_mcp(self) -> None:
        """初始化 MCP 工具并注册到统一工具层。"""
        self._mcp_host_bridge = MCPHostLLMBridge(
            sampling_task_name=global_config.mcp.client.sampling.task_name,
        )
        self._mcp_manager = await MCPManager.from_app_config(
            global_config.mcp,
            host_callbacks=self._mcp_host_bridge.build_callbacks(),
        )
        if self._mcp_manager is None:
            logger.info(f"{self.log_prefix} Maisaka MCP 管理器不可用")
            return

        mcp_tool_specs = self._mcp_manager.get_tool_specs()
        if not mcp_tool_specs:
            logger.info(f"{self.log_prefix} Maisaka 没有可供使用的 MCP 工具")
            return

        self._tool_registry.register_provider(MCPToolProvider(self._mcp_manager))
        logger.info(
            f"{self.log_prefix} 已向 Maisaka 加载 {len(mcp_tool_specs)} 个 MCP 工具。\n"
            f"{self._mcp_manager.get_feature_summary()}"
        )

    def _build_runtime_user_info(self) -> UserInfo:
        if self.chat_stream.user_id:
            return UserInfo(
                user_id=self.chat_stream.user_id,
                user_nickname=global_config.maisaka.cli_user_name.strip() or "用户",
                user_cardname=None,
            )
        return UserInfo(user_id="maisaka_user", user_nickname="用户", user_cardname=None)

    def _build_group_info(self, message: Optional[SessionMessage] = None) -> Optional[GroupInfo]:
        group_info = None
        if message is not None:
            group_info = message.message_info.group_info
        elif self.chat_stream.context and self.chat_stream.context.message:
            group_info = self.chat_stream.context.message.message_info.group_info

        if group_info is None:
            return None

        return GroupInfo(group_id=group_info.group_id, group_name=group_info.group_name)

    def _render_context_usage_panel(
        self,
        *,
        cycle_id: Optional[int] = None,
        time_records: Optional[dict[str, float]] = None,
        timing_selected_history_count: Optional[int] = None,
        timing_prompt_tokens: Optional[int] = None,
        timing_action: str = "",
        timing_response: str = "",
        timing_tool_calls: Optional[list[Any]] = None,
        timing_tool_results: Optional[list[str]] = None,
        timing_tool_detail_results: Optional[list[dict[str, Any]]] = None,
        timing_prompt_section: Optional[RenderableType] = None,
        planner_selected_history_count: Optional[int] = None,
        planner_prompt_tokens: Optional[int] = None,
        planner_response: str = "",
        planner_tool_calls: Optional[list[Any]] = None,
        planner_tool_results: Optional[list[str]] = None,
        planner_tool_detail_results: Optional[list[dict[str, Any]]] = None,
        planner_prompt_section: Optional[RenderableType] = None,
        planner_extra_lines: Optional[list[str]] = None,
    ) -> None:
        """在终端展示当前聊天流本轮 cycle 的最终结果。"""
        if not global_config.debug.show_maisaka_thinking:
            return

        body_lines = [
            f"聊天流名称：{getattr(self, 'session_name', self.session_id)}",
            f"聊天流ID：{self.session_id}",
        ]

        panel_title = "MaiSaka 循环"
        if cycle_id is not None:
            panel_title = f"{panel_title} [{cycle_id}]"
        panel_subtitle = self._build_cycle_time_records_text(time_records or {})
        renderables: list[RenderableType] = [Text("\n".join(body_lines))]
        timing_panel = self._build_cycle_stage_panel(
            title="Timing Gate",
            border_style="bright_magenta",
            selected_history_count=timing_selected_history_count,
            prompt_tokens=timing_prompt_tokens,
            response_text=timing_response,
            prompt_section=timing_prompt_section,
            extra_lines=None,
        )
        if timing_panel is not None:
            renderables.append(timing_panel)

        timing_tool_cards = self._build_tool_activity_cards(
            stage_title="Timing Tool",
            tool_calls=timing_tool_calls,
            tool_results=timing_tool_results,
            tool_detail_results=timing_tool_detail_results,
            planner_style=False,
        )
        if timing_tool_cards:
            renderables.extend(timing_tool_cards)

        planner_panel = self._build_cycle_stage_panel(
            title="Planner",
            border_style="green",
            selected_history_count=planner_selected_history_count,
            prompt_tokens=planner_prompt_tokens,
            response_text=planner_response,
            prompt_section=planner_prompt_section,
            extra_lines=planner_extra_lines,
        )
        if planner_panel is not None:
            renderables.append(planner_panel)

        planner_tool_cards = self._build_tool_activity_cards(
            stage_title="Planner Tool",
            tool_calls=planner_tool_calls,
            tool_results=planner_tool_results,
            tool_detail_results=planner_tool_detail_results,
            planner_style=True,
        )
        if planner_tool_cards:
            renderables.extend(planner_tool_cards)

        console.print(
            Panel(
                Group(*renderables),
                title=panel_title,
                subtitle=panel_subtitle,
                border_style="bright_blue",
                padding=(0, 1),
            )
        )

    def _build_cycle_stage_panel(
        self,
        *,
        title: str,
        border_style: str,
        selected_history_count: Optional[int],
        prompt_tokens: Optional[int],
        response_text: str = "",
        prompt_section: Optional[RenderableType] = None,
        extra_lines: Optional[list[str]] = None,
    ) -> Optional[Panel]:
        """构建单个 cycle 阶段的展示卡片。"""

        has_content = any([
            selected_history_count is not None,
            prompt_tokens is not None,
            bool(response_text.strip()),
            prompt_section is not None,
            bool(extra_lines),
        ])
        if not has_content:
            return None

        body_lines: list[str] = []
        if prompt_tokens is not None:
            body_lines.append(f"本次请求token消耗：{format_token_count(prompt_tokens)}")
        if extra_lines:
            body_lines.extend([line for line in extra_lines if isinstance(line, str) and line.strip()])

        renderables: list[RenderableType] = []
        if body_lines:
            renderables.append(Text("\n".join(body_lines)))
        if prompt_section is not None:
            renderables.append(prompt_section)

        normalized_response = response_text.strip()
        if normalized_response:
            renderables.append(
                Panel(
                    Text(normalized_response),
                    title="Maisaka 返回",
                    border_style=border_style,
                    padding=(0, 1),
                )
            )

        return Panel(
            Group(*renderables),
            title=title,
            border_style=border_style,
            padding=(0, 1),
        )

    def _build_tool_activity_cards(
        self,
        *,
        stage_title: str,
        tool_calls: Optional[list[Any]] = None,
        tool_results: Optional[list[str]] = None,
        tool_detail_results: Optional[list[dict[str, Any]]] = None,
        planner_style: bool = False,
    ) -> list[RenderableType]:
        """构建与阶段同级的工具执行卡片列表。"""

        detail_results = tool_detail_results or []
        cards = self._build_tool_detail_cards(
            detail_results,
            stage_title=stage_title,
            planner_style=planner_style,
        )
        if cards:
            return cards

        # 兼容旧数据结构：若尚无 detail，则降级为简单文本卡片。
        fallback_lines = self._filter_redundant_tool_results(
            tool_results=tool_results or [],
            tool_detail_results=detail_results,
        )
        if not fallback_lines and tool_calls:
            fallback_lines = build_tool_call_summary_lines(tool_calls)
        if not fallback_lines:
            return []

        fallback_border_style = "yellow"
        return [
            Panel(
                Text("\n".join(fallback_lines)),
                title=stage_title,
                border_style=fallback_border_style,
                padding=(0, 1),
            )
        ]

    @staticmethod
    def _build_cycle_time_records_text(time_records: dict[str, float]) -> str:
        """构建循环最外层面板展示的阶段耗时文本。"""

        if not time_records:
            return "流程耗时：无"

        label_map = {
            "timing_gate": "Timing Gate",
            "planner": "Planner",
            "tool_calls": "工具执行",
        }
        ordered_keys = ["timing_gate", "planner", "tool_calls"]

        parts: list[str] = []
        for key in ordered_keys:
            duration = time_records.get(key)
            if isinstance(duration, (int, float)):
                parts.append(f"{label_map.get(key, key)} {float(duration):.2f} s")

        for key, duration in time_records.items():
            if key in ordered_keys or not isinstance(duration, (int, float)):
                continue
            parts.append(f"{label_map.get(key, key)} {float(duration):.2f} s")

        if not parts:
            return "流程耗时：无"
        return "流程耗时：" + " | ".join(parts)

    @staticmethod
    def _filter_redundant_tool_results(
        *,
        tool_results: list[str],
        tool_detail_results: list[dict[str, Any]],
    ) -> list[str]:
        """过滤掉已经在详情卡片中展示过的工具摘要。"""

        detailed_summaries = {
            str(tool_result.get("summary") or "").strip()
            for tool_result in tool_detail_results
            if isinstance(tool_result.get("detail"), dict) and tool_result.get("detail")
        }
        return [
            result.strip()
            for result in tool_results
            if isinstance(result, str)
            and result.strip()
            and result.strip() not in detailed_summaries
        ]

    @staticmethod
    def _build_tool_metrics_text(metrics: dict[str, Any]) -> str:
        """将工具监控 metrics 转换为便于 CLI 阅读的文本。"""

        lines: list[str] = []
        model_name = str(metrics.get("model_name") or "").strip()
        if model_name:
            lines.append(f"模型：{model_name}")

        prompt_tokens = metrics.get("prompt_tokens")
        completion_tokens = metrics.get("completion_tokens")
        total_tokens = metrics.get("total_tokens")
        if isinstance(prompt_tokens, int) or isinstance(completion_tokens, int) or isinstance(total_tokens, int):
            lines.append(
                "Token："
                f"输入 {format_token_count(int(prompt_tokens or 0))} / "
                f"输出 {format_token_count(int(completion_tokens or 0))} / "
                f"总计 {format_token_count(int(total_tokens or 0))}"
            )

        prompt_ms = metrics.get("prompt_ms")
        llm_ms = metrics.get("llm_ms")
        overall_ms = metrics.get("overall_ms")
        timing_parts: list[str] = []
        if isinstance(prompt_ms, (int, float)):
            timing_parts.append(f"prompt {round(float(prompt_ms), 2)} ms")
        if isinstance(llm_ms, (int, float)):
            timing_parts.append(f"llm {round(float(llm_ms), 2)} ms")
        if isinstance(overall_ms, (int, float)):
            timing_parts.append(f"overall {round(float(overall_ms), 2)} ms")
        if timing_parts:
            lines.append("耗时：" + " / ".join(timing_parts))

        return "\n".join(lines)

    @staticmethod
    def _get_tool_detail_labels(tool_name: str) -> dict[str, str]:
        """返回不同工具对应的详情区标题与预览类别。"""

        normalized_tool_name = str(tool_name or "").strip().lower()
        if normalized_tool_name == "reply":
            return {
                "prompt_title": "Reply Prompt",
                "reasoning_title": "Reply 思考",
                "output_title": "Reply 输出",
                "prompt_category": "replyer",
                "request_kind": "replyer",
            }
        if normalized_tool_name == "send_emoji":
            return {
                "prompt_title": "Emotion Prompt",
                "reasoning_title": "Emotion 思考",
                "output_title": "Emotion 输出",
                "prompt_category": "emotion",
                "request_kind": "emotion",
            }
        display_name = normalized_tool_name or "tool"
        return {
            "prompt_title": f"{display_name} Prompt",
            "reasoning_title": f"{display_name} 思考",
            "output_title": f"{display_name} 输出",
            "prompt_category": display_name,
            "request_kind": "sub_agent",
        }

    def _build_tool_prompt_access_panel(
        self,
        *,
        tool_name: str,
        prompt_text: str,
        request_messages: Optional[list[Any]] = None,
        tool_call_id: str,
        border_style: str = "bright_yellow",
    ) -> Panel:
        """将工具 prompt 渲染为可点击查看的预览入口。"""

        labels = self._get_tool_detail_labels(tool_name)
        subtitle = f"会话ID: {self.session_id}"
        if tool_call_id:
            subtitle += f"\n调用ID: {tool_call_id}"

        if isinstance(request_messages, list) and request_messages:
            try:
                normalized_messages = deserialize_prompt_messages(request_messages)
            except Exception as exc:
                logger.warning(f"工具 {tool_name} 的 request_messages 无法反序列化，已回退为文本预览: {exc}")
            else:
                return Panel(
                    PromptCLIVisualizer.build_prompt_access_panel(
                        normalized_messages,
                        category=labels["prompt_category"],
                        chat_id=self.session_id,
                        request_kind=labels["request_kind"],
                        selection_reason=subtitle,
                    ),
                    title=labels["prompt_title"],
                    border_style=border_style,
                    padding=(0, 1),
                )

        return Panel(
            PromptCLIVisualizer.build_text_access_panel(
                prompt_text,
                category=labels["prompt_category"],
                chat_id=self.session_id,
                request_kind=labels["request_kind"],
                subtitle=subtitle,
            ),
            title=labels["prompt_title"],
            border_style=border_style,
            padding=(0, 1),
        )

    def _normalize_tool_card_body_lines(self, body: Any) -> list[str]:
        """将工具卡片正文规范化为行列表。"""

        if isinstance(body, str):
            return [line for line in body.splitlines() if line.strip()]
        if isinstance(body, list):
            return [
                str(item).strip()
                for item in body
                if str(item).strip()
            ]
        return []

    def _build_custom_tool_sub_cards(
        self,
        sub_cards: Any,
        *,
        default_border_style: str,
    ) -> list[RenderableType]:
        """构建工具自定义子卡片。"""

        if not isinstance(sub_cards, list):
            return []

        renderables: list[RenderableType] = []
        for sub_card in sub_cards:
            if not isinstance(sub_card, dict):
                continue
            title = str(sub_card.get("title") or "").strip() or "附加信息"
            border_style = str(sub_card.get("border_style") or "").strip() or default_border_style
            body_lines = self._normalize_tool_card_body_lines(
                sub_card.get("body_lines", sub_card.get("content", ""))
            )
            if not body_lines:
                continue
            renderables.append(
                Panel(
                    Text("\n".join(body_lines)),
                    title=title,
                    border_style=border_style,
                    padding=(0, 1),
                )
            )
        return renderables

    def _build_default_tool_detail_parts(
        self,
        *,
        tool_name: str,
        tool_call_id: str,
        tool_args: Any,
        summary: str,
        duration_ms: Any,
        detail: dict[str, Any],
        planner_style: bool,
    ) -> list[RenderableType]:
        """构建工具卡片默认内容块。"""

        argument_border_style = "yellow"
        metrics_border_style = "bright_yellow"
        prompt_border_style = "bright_yellow"
        reasoning_border_style = "yellow"
        output_border_style = "bright_yellow"
        extra_info_border_style = "yellow"
        detail_labels = self._get_tool_detail_labels(tool_name)

        parts: list[RenderableType] = []
        header_lines: list[str] = []
        if summary:
            header_lines.append(summary)
        if tool_call_id:
            header_lines.append(f"调用ID：{tool_call_id}")
        if isinstance(duration_ms, (int, float)):
            header_lines.append(f"执行耗时：{round(float(duration_ms), 2)} ms")
        if header_lines:
            parts.append(Text("\n".join(header_lines)))

        if isinstance(tool_args, dict) and tool_args:
            parts.append(
                Panel(
                    Pretty(tool_args, expand_all=True),
                    title="工具参数",
                    border_style=argument_border_style,
                    padding=(0, 1),
                )
            )

        metrics = detail.get("metrics")
        if isinstance(metrics, dict):
            metrics_text = self._build_tool_metrics_text(metrics)
            if metrics_text:
                parts.append(
                    Panel(
                        Text(metrics_text),
                        title="执行指标",
                        border_style=metrics_border_style,
                        padding=(0, 1),
                    )
                )

        prompt_text = str(detail.get("prompt_text") or "").strip()
        if prompt_text:
            parts.append(
                self._build_tool_prompt_access_panel(
                    tool_name=tool_name,
                    prompt_text=prompt_text,
                    request_messages=detail.get("request_messages") if isinstance(detail.get("request_messages"), list) else None,
                    tool_call_id=tool_call_id,
                    border_style=prompt_border_style,
                )
            )

        reasoning_text = str(detail.get("reasoning_text") or "").strip()
        if reasoning_text:
            parts.append(
                Panel(
                    Text(reasoning_text),
                    title=detail_labels["reasoning_title"],
                    border_style=reasoning_border_style,
                    padding=(0, 1),
                )
            )

        output_text = str(detail.get("output_text") or "").strip()
        if output_text:
            parts.append(
                Panel(
                    Text(output_text),
                    title=detail_labels["output_title"],
                    border_style=output_border_style,
                    padding=(0, 1),
                )
            )

        extra_sections = detail.get("extra_sections")
        if isinstance(extra_sections, list):
            for section in extra_sections:
                if not isinstance(section, dict):
                    continue
                section_title = str(section.get("title") or "").strip() or "附加信息"
                section_content = str(section.get("content") or "").strip()
                if not section_content:
                    continue
                parts.append(
                    Panel(
                        Text(section_content),
                        title=section_title,
                        border_style=extra_info_border_style,
                        padding=(0, 1),
                    )
                )

        return parts

    def _build_tool_detail_cards(
        self,
        tool_detail_results: list[dict[str, Any]],
        *,
        stage_title: str,
        planner_style: bool = False,
    ) -> list[RenderableType]:
        """将 tool monitor detail 渲染为与 Planner/Timing 平级的工具卡片。"""

        detail_panel_border_style = "yellow"
        sub_card_border_style = "bright_yellow"

        panels: list[RenderableType] = []
        for tool_result in tool_detail_results:
            detail = tool_result.get("detail")
            detail_dict = detail if isinstance(detail, dict) else {}
            tool_name = str(tool_result.get("tool_name") or "unknown").strip() or "unknown"
            tool_title = str(tool_result.get("tool_title") or "").strip() or tool_name
            tool_call_id = str(tool_result.get("tool_call_id") or "").strip()
            tool_args = tool_result.get("tool_args")
            summary = str(tool_result.get("summary") or "").strip()
            duration_ms = tool_result.get("duration_ms")
            custom_card = tool_result.get("card")

            parts: list[RenderableType] = []
            custom_title = ""
            card_border_style = detail_panel_border_style
            replace_default_children = False
            if isinstance(custom_card, dict):
                custom_title = str(custom_card.get("title") or "").strip()
                card_border_style = str(custom_card.get("border_style") or "").strip() or detail_panel_border_style
                replace_default_children = bool(custom_card.get("replace_default_children", False))
                custom_body_lines = self._normalize_tool_card_body_lines(
                    custom_card.get("body_lines", custom_card.get("content", ""))
                )
                if custom_body_lines:
                    parts.append(Text("\n".join(custom_body_lines)))

            if not replace_default_children:
                parts.extend(
                    self._build_default_tool_detail_parts(
                        tool_name=tool_name,
                        tool_call_id=tool_call_id,
                        tool_args=tool_args,
                        summary=summary,
                        duration_ms=duration_ms,
                        detail=detail_dict,
                        planner_style=planner_style,
                    )
                )

            if isinstance(custom_card, dict):
                parts.extend(
                    self._build_custom_tool_sub_cards(
                        custom_card.get("sub_cards"),
                        default_border_style=sub_card_border_style,
                    )
                )
            parts.extend(
                self._build_custom_tool_sub_cards(
                    tool_result.get("sub_cards"),
                    default_border_style=sub_card_border_style,
                )
            )

            if parts:
                panels.append(
                    Panel(
                        Group(*parts),
                        title=custom_title or f"{stage_title} · {tool_title}",
                        border_style=card_border_style,
                        padding=(0, 1),
                    )
                )

        return panels

    def _log_cycle_started(self, cycle_detail: CycleDetail, round_index: int) -> None:
        logger.debug(
            f"{self.log_prefix} MaiSaka 轮次开始: 循环编号={cycle_detail.cycle_id} "
            f"回合={round_index + 1}/{self._max_internal_rounds} "
            f"上下文消息数={len(self._chat_history)}"
        )

    def _log_cycle_completed(self, cycle_detail: CycleDetail, timer_strings: list[str]) -> None:
        end_time = cycle_detail.end_time if cycle_detail.end_time is not None else cycle_detail.start_time
        logger.debug(
            f"{self.log_prefix} MaiSaka 轮次结束: 循环编号={cycle_detail.cycle_id} "
            f"总耗时={end_time - cycle_detail.start_time:.2f} 秒; "
            f"阶段耗时={', '.join(timer_strings) if timer_strings else '无'}"
        )

    def _log_history_trimmed(self, removed_count: int, user_message_count: int) -> None:
        logger.debug(
            f"{self.log_prefix} 已裁剪 {removed_count} 条历史消息; "
            # f"剩余计入上下文的消息数={user_message_count}"
        )

    def _log_internal_loop_cancelled(self) -> None:
        logger.info(f"{self.log_prefix} Maisaka 内部循环已取消")
