"""
推理-行动引擎 (ReAct Pattern)

从 agent.py 的 _chat_with_tools_and_context 重构为显式的
Reason -> Act -> Observe 三阶段循环。

核心职责:
- 显式推理循环管理（Reason / Act / Observe）
- LLM 响应解析与 Decision 分类
- 工具调用编排（委托给 ToolExecutor）
- 上下文压缩触发（委托给 ContextManager）
- 循环检测（签名重复、自检间隔、安全阈值）
- 模型切换逻辑
- 任务完成度验证（委托给 ResponseHandler）
"""

import asyncio
import copy
import hashlib
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from ..config import settings
from ..tracing.tracer import get_tracer
from .agent_state import AgentState, TaskState, TaskStatus
from .context_manager import ContextManager
from .response_handler import ResponseHandler, clean_llm_response, strip_thinking_tags
from .tool_executor import ToolExecutor

logger = logging.getLogger(__name__)


class DecisionType(Enum):
    """LLM 决策类型"""
    FINAL_ANSWER = "final_answer"  # 纯文本响应
    TOOL_CALLS = "tool_calls"  # 需要工具调用


@dataclass
class Decision:
    """LLM 推理决策"""
    type: DecisionType
    text_content: str = ""
    tool_calls: list[dict] = field(default_factory=list)
    thinking_content: str = ""
    raw_response: Any = None
    stop_reason: str = ""
    # 完整的 assistant_content（保留 thinking 块等）
    assistant_content: list[dict] = field(default_factory=list)


@dataclass
class Checkpoint:
    """
    决策检查点，用于多路径探索和回滚。

    在关键决策点保存消息历史和任务状态的快照，
    当检测到循环、连续失败等问题时可回滚到之前的检查点，
    附加失败经验提示后重新推理。
    """

    id: str
    messages_snapshot: list[dict]  # 深拷贝消息历史
    state_snapshot: dict  # 序列化的 TaskState 关键字段
    decision_summary: str  # 做出的决策摘要
    iteration: int  # 保存时的迭代次数
    timestamp: float = field(default_factory=time.time)
    tool_names: list[str] = field(default_factory=list)  # 该决策调用的工具


class ReasoningEngine:
    """
    显式推理-行动引擎。

    替代 agent.py 中的 _chat_with_tools_and_context()，
    将隐式循环重构为清晰的 Reason -> Act -> Observe 三阶段。
    支持 Checkpoint + Rollback 多路径探索。
    """

    # 检查点配置
    MAX_CHECKPOINTS = 5  # 保留最近 N 个检查点
    CONSECUTIVE_FAIL_THRESHOLD = 3  # 同一工具连续失败 N 次触发回滚

    def __init__(
        self,
        brain: Any,
        tool_executor: ToolExecutor,
        context_manager: ContextManager,
        response_handler: ResponseHandler,
        agent_state: AgentState,
    ) -> None:
        self._brain = brain
        self._tool_executor = tool_executor
        self._context_manager = context_manager
        self._response_handler = response_handler
        self._state = agent_state

        # Checkpoint 管理
        self._checkpoints: list[Checkpoint] = []
        self._tool_failure_counter: dict[str, int] = {}  # tool_name -> consecutive_failures

        # 浏览器"读页面状态"工具
        self._browser_page_read_tools = frozenset({
            "browser_get_content", "browser_screenshot", "browser_list_tabs",
        })

    # ==================== ask_user 等待用户回复 ====================

    async def _wait_for_user_reply(
        self,
        question: str,
        state: TaskState,
        *,
        timeout_seconds: int = 60,
        max_reminders: int = 1,
        poll_interval: float = 2.0,
    ) -> str | None:
        """
        等待用户回复 ask_user 的问题（仅 IM 模式生效）。

        利用 Gateway 的中断队列机制：IM 用户在 Agent 处理中发送的消息
        会被 Gateway 放入 interrupt_queue，本方法轮询该队列获取回复。

        流程:
        1. 通过 Gateway 发送问题给用户
        2. 轮询 interrupt_queue 等待回复（timeout_seconds 超时）
        3. 第一次超时 → 发送提醒，再等一轮
        4. 第二次超时 → 返回 None，由调用方注入系统消息让 LLM 自行决策

        Args:
            question: 要发送给用户的问题文本
            state: 当前任务状态（用于取消检查）
            timeout_seconds: 每轮等待超时（秒）
            max_reminders: 最大追问提醒次数
            poll_interval: 轮询间隔（秒）

        Returns:
            用户回复文本，或 None（超时/无 gateway/被取消）
        """
        # 获取 gateway 和 session 引用
        session = self._state.current_session
        if not session:
            return None

        gateway = session.get_metadata("_gateway") if hasattr(session, "get_metadata") else None
        session_key = session.get_metadata("_session_key") if gateway else None

        if not gateway or not session_key:
            # CLI 模式或无 gateway，不做等待
            return None

        # 发送问题到用户
        try:
            await gateway.send_to_session(session, question, role="assistant")
            logger.info(f"[ask_user] Question sent to user, waiting for reply (timeout={timeout_seconds}s)")
        except Exception as e:
            logger.warning(f"[ask_user] Failed to send question via gateway: {e}")
            return None

        reminders_sent = 0

        while reminders_sent <= max_reminders:
            # 轮询等待用户回复
            elapsed = 0.0

            while elapsed < timeout_seconds:
                # 检查任务是否被取消
                if state.cancelled:
                    logger.info("[ask_user] Task cancelled while waiting for reply")
                    return None

                # 检查中断队列
                try:
                    reply_msg = await gateway.check_interrupt(session_key)
                except Exception as e:
                    logger.warning(f"[ask_user] check_interrupt error: {e}")
                    reply_msg = None

                if reply_msg:
                    # 从 UnifiedMessage 提取文本
                    reply_text = (
                        reply_msg.plain_text.strip()
                        if hasattr(reply_msg, "plain_text") and reply_msg.plain_text
                        else str(reply_msg).strip()
                    )
                    if reply_text:
                        logger.info(f"[ask_user] User replied: {reply_text[:80]}")
                        # 记录到 session 历史
                        try:
                            session.add_message(role="user", content=reply_text, source="ask_user_reply")
                        except Exception:
                            pass
                        return reply_text

                await asyncio.sleep(poll_interval)
                elapsed += poll_interval

            # 本轮超时
            if reminders_sent < max_reminders:
                # 发送追问提醒
                reminders_sent += 1
                reminder = "⏰ 我在等你回复上面的问题哦，看到的话回复一下~"
                try:
                    await gateway.send_to_session(session, reminder, role="assistant")
                    logger.info(f"[ask_user] Timeout #{reminders_sent}, reminder sent")
                except Exception as e:
                    logger.warning(f"[ask_user] Failed to send reminder: {e}")
            else:
                # 追问次数用尽，返回 None
                logger.info(
                    f"[ask_user] Final timeout after {reminders_sent} reminder(s), "
                    f"total wait ~{timeout_seconds * (max_reminders + 1)}s"
                )
                return None

        return None

    # ==================== Checkpoint / Rollback ====================

    def _save_checkpoint(
        self,
        messages: list[dict],
        state: TaskState,
        decision: Decision,
        iteration: int,
    ) -> None:
        """
        在关键决策点保存检查点。

        仅在工具调用决策时保存（纯文本响应不需要回滚）。
        保留最近 MAX_CHECKPOINTS 个检查点以控制内存。
        """
        tool_names = [tc.get("name", "") for tc in decision.tool_calls]
        summary = f"iteration={iteration}, tools=[{', '.join(tool_names)}]"

        cp = Checkpoint(
            id=str(uuid.uuid4())[:8],
            messages_snapshot=copy.deepcopy(messages),
            state_snapshot={
                "iteration": state.iteration,
                "status": state.status.value,
                "executed_tools": list(state.tools_executed),
            },
            decision_summary=summary,
            iteration=iteration,
            tool_names=tool_names,
        )
        self._checkpoints.append(cp)

        # 保留最近 N 个
        if len(self._checkpoints) > self.MAX_CHECKPOINTS:
            self._checkpoints = self._checkpoints[-self.MAX_CHECKPOINTS:]

        logger.debug(f"[Checkpoint] Saved: {cp.id} at iteration {iteration}")

    def _record_tool_result(self, tool_name: str, success: bool) -> None:
        """记录工具执行结果，用于连续失败检测。"""
        if success:
            self._tool_failure_counter[tool_name] = 0
        else:
            self._tool_failure_counter[tool_name] = (
                self._tool_failure_counter.get(tool_name, 0) + 1
            )

    def _should_rollback(self, tool_results: list[dict]) -> tuple[bool, str]:
        """
        检查是否应该触发回滚。

        触发条件:
        1. 同一工具连续失败 >= CONSECUTIVE_FAIL_THRESHOLD 次
        2. 整批工具全部失败

        Returns:
            (should_rollback, reason)
        """
        if not self._checkpoints:
            return False, ""

        # 检查本批次工具执行结果
        batch_failures = []
        for result in tool_results:
            content = ""
            if isinstance(result, dict):
                content = str(result.get("content", ""))
            elif isinstance(result, str):
                content = result

            has_error = any(marker in content for marker in [
                "❌", "⚠️ 工具执行错误", "错误类型:", "ToolError",
            ])
            has_success = any(marker in content for marker in [
                "✅", '"status": "delivered"', '"ok": true',
            ])

            # 部分成功（如 deliver_artifacts 2张图发了1张）不算失败，
            # 避免回滚已经发出的不可撤回内容
            is_failed = has_error and not has_success
            batch_failures.append(is_failed)

        # 整批全部失败
        if batch_failures and all(batch_failures):
            return True, "本轮所有工具调用均失败"

        # 单工具连续失败
        for tool_name, count in self._tool_failure_counter.items():
            if count >= self.CONSECUTIVE_FAIL_THRESHOLD:
                return True, f"工具 '{tool_name}' 连续失败 {count} 次"

        return False, ""

    def _rollback(self, reason: str) -> tuple[list[dict], int] | None:
        """
        执行回滚: 恢复到上一个检查点。

        在恢复的消息历史末尾附加失败经验提示，
        帮助 LLM 避免重蹈覆辙。

        Returns:
            (restored_messages, checkpoint_iteration) or None if no checkpoints
        """
        if not self._checkpoints:
            return None

        # 弹出最近的检查点（避免回滚到同一个点）
        cp = self._checkpoints.pop()
        restored_messages = copy.deepcopy(cp.messages_snapshot)

        # 附加失败经验
        failure_hint = (
            f"[系统提示] 之前的方案失败了（原因: {reason}）。"
            f"失败的决策: {cp.decision_summary}。"
            f"请尝试完全不同的方法来完成任务。"
            f"避免使用与之前相同的工具参数组合。"
        )
        restored_messages.append({
            "role": "user",
            "content": failure_hint,
        })

        # 重置失败计数器
        self._tool_failure_counter.clear()

        logger.info(
            f"[Rollback] Rolled back to checkpoint {cp.id} "
            f"(iteration {cp.iteration}). Reason: {reason}"
        )

        return restored_messages, cp.iteration

    async def run(
        self,
        messages: list[dict],
        *,
        tools: list[dict],
        system_prompt: str = "",
        base_system_prompt: str = "",
        task_description: str = "",
        task_monitor: Any = None,
        session_type: str = "cli",
        interrupt_check_fn: Any = None,
        conversation_id: str | None = None,
    ) -> str:
        """
        主推理循环: Reason -> Act -> Observe。

        Args:
            messages: 初始消息列表
            tools: 工具定义列表
            system_prompt: 系统提示词
            base_system_prompt: 基础系统提示词（不含动态 Plan）
            task_description: 任务描述
            task_monitor: 任务监控器
            session_type: 会话类型
            interrupt_check_fn: 中断检查函数
            conversation_id: 对话 ID

        Returns:
            最终响应文本
        """
        state = self._state.current_task
        if not state or not state.is_active:
            # 无任务 或 上一个任务已结束（COMPLETED/FAILED/CANCELLED/IDLE），需新建
            state = self._state.begin_task()

        # 启动 Trace（非上下文管理器，因为 run() 有多个 return 路径）
        tracer = get_tracer()
        tracer.begin_trace(session_id=state.session_id, metadata={
            "task_description": task_description[:200] if task_description else "",
            "session_type": session_type,
            "model": self._brain.model,
        })

        max_iterations = settings.max_iterations

        # 保存原始用户消息（用于模型切换时重置上下文）
        state.original_user_messages = [
            msg for msg in messages if self._is_human_user_message(msg)
        ]

        working_messages = list(messages)
        current_model = self._brain.model

        # ForceToolCall 配置
        if session_type == "im":
            base_force_retries = 0
        else:
            base_force_retries = max(0, int(getattr(settings, "force_tool_call_max_retries", 1)))

        max_no_tool_retries = self._effective_force_retries(base_force_retries, conversation_id)
        max_verify_retries = 3
        max_confirmation_text_retries = 1

        # 追踪变量
        executed_tool_names: list[str] = []
        delivery_receipts: list[dict] = []
        _last_browser_url = ""

        # 循环计数器
        consecutive_tool_rounds = 0
        no_tool_call_count = 0
        verify_incomplete_count = 0
        no_confirmation_text_count = 0
        tools_executed_in_task = False

        # 循环检测
        recent_tool_signatures: list[str] = []
        tool_pattern_window = 8
        llm_self_check_interval = 10
        extreme_safety_threshold = 50

        def _build_effective_system_prompt() -> str:
            """动态追加活跃 Plan"""
            try:
                from ..tools.handlers.plan import get_active_plan_prompt
                _cid = conversation_id
                prompt = base_system_prompt or system_prompt
                if _cid:
                    plan_section = get_active_plan_prompt(_cid)
                    if plan_section:
                        prompt += f"\n\n{plan_section}\n"
                return prompt
            except Exception:
                return base_system_prompt or system_prompt

        def _make_tool_signature(tc: dict) -> str:
            """生成工具签名"""
            nonlocal _last_browser_url
            name = tc.get("name", "")
            inp = tc.get("input", {})

            if name == "browser_navigate":
                _last_browser_url = inp.get("url", "")

            try:
                param_str = json.dumps(inp, sort_keys=True, ensure_ascii=False)
            except Exception:
                param_str = str(inp)

            if name in self._browser_page_read_tools and len(param_str) <= 20 and _last_browser_url:
                param_str = f"{param_str}|url={_last_browser_url}"

            param_hash = hashlib.md5(param_str.encode()).hexdigest()[:8]
            return f"{name}({param_hash})"

        # ==================== 主循环 ====================
        logger.info(f"[ReAct] === Loop started (max_iterations={max_iterations}, model={current_model}) ===")

        for iteration in range(max_iterations):
            state.iteration = iteration

            # 检查取消
            if state.cancelled:
                logger.info(f"[ReAct] Task cancelled at iteration start: {state.cancel_reason}")
                tracer.end_trace(metadata={"result": "cancelled", "iterations": iteration})
                return "✅ 任务已停止。"

            # 任务监控
            if task_monitor:
                task_monitor.begin_iteration(iteration + 1, current_model)
                # 模型切换检查
                switch_result = self._check_model_switch(
                    task_monitor, state, working_messages, current_model
                )
                if switch_result:
                    current_model, working_messages = switch_result
                    no_tool_call_count = 0
                    tools_executed_in_task = False
                    verify_incomplete_count = 0
                    executed_tool_names = []
                    consecutive_tool_rounds = 0
                    recent_tool_signatures = []
                    no_confirmation_text_count = 0

            # 上下文压缩
            if iteration > 0:
                working_messages = await self._context_manager.compress_if_needed(
                    working_messages,
                    system_prompt=_build_effective_system_prompt(),
                    tools=tools,
                )

            # ==================== REASON 阶段 ====================
            logger.info(f"[ReAct] Iter {iteration+1}/{max_iterations} — REASON (model={current_model})")
            if state.status != TaskStatus.REASONING:
                state.transition(TaskStatus.REASONING)

            try:
                decision = await self._reason(
                    working_messages,
                    system_prompt=_build_effective_system_prompt(),
                    tools=tools,
                    current_model=current_model,
                    conversation_id=conversation_id,
                )

                if task_monitor:
                    task_monitor.reset_retry_count()

            except Exception as e:
                logger.error(f"[LLM] Brain call failed: {e}")
                retry_result = self._handle_llm_error(
                    e, task_monitor, state, working_messages, current_model
                )
                if retry_result == "retry":
                    await asyncio.sleep(2)
                    continue
                elif isinstance(retry_result, tuple):
                    current_model, working_messages = retry_result
                    no_tool_call_count = 0
                    tools_executed_in_task = False
                    verify_incomplete_count = 0
                    executed_tool_names = []
                    consecutive_tool_rounds = 0
                    recent_tool_signatures = []
                    no_confirmation_text_count = 0
                    continue
                else:
                    raise

            if task_monitor:
                task_monitor.end_iteration(decision.text_content or "")

            # ==================== 决策分支 ====================

            if decision.type == DecisionType.FINAL_ANSWER:
                # 纯文本响应 - 处理完成度验证
                answer_preview = (decision.text_content or "")[:80].replace("\n", " ")
                logger.info(f"[ReAct] Iter {iteration+1} — FINAL_ANSWER: \"{answer_preview}...\"")
                consecutive_tool_rounds = 0

                result = await self._handle_final_answer(
                    decision=decision,
                    working_messages=working_messages,
                    original_messages=messages,
                    tools_executed_in_task=tools_executed_in_task,
                    executed_tool_names=executed_tool_names,
                    delivery_receipts=delivery_receipts,
                    no_tool_call_count=no_tool_call_count,
                    verify_incomplete_count=verify_incomplete_count,
                    no_confirmation_text_count=no_confirmation_text_count,
                    max_no_tool_retries=max_no_tool_retries,
                    max_verify_retries=max_verify_retries,
                    max_confirmation_text_retries=max_confirmation_text_retries,
                    base_force_retries=base_force_retries,
                    conversation_id=conversation_id,
                )

                if isinstance(result, str):
                    # 最终答案
                    logger.info(
                        f"[ReAct] === COMPLETED after {iteration+1} iterations, "
                        f"tools: {list(set(executed_tool_names))} ==="
                    )
                    state.transition(TaskStatus.COMPLETED)
                    tracer.end_trace(metadata={
                        "result": "completed",
                        "iterations": iteration + 1,
                        "tools_used": list(set(executed_tool_names)),
                    })
                    return result
                else:
                    # 需要继续循环（验证不通过）
                    logger.info(f"[ReAct] Iter {iteration+1} — VERIFY: incomplete, continuing loop")
                    state.transition(TaskStatus.VERIFYING)
                    (
                        working_messages,
                        no_tool_call_count,
                        verify_incomplete_count,
                        no_confirmation_text_count,
                        max_no_tool_retries,
                    ) = result
                    continue

            elif decision.type == DecisionType.TOOL_CALLS:
                # ==================== ACT 阶段 ====================
                tool_names = [tc.get("name", "?") for tc in decision.tool_calls]
                logger.info(f"[ReAct] Iter {iteration+1} — ACT: {tool_names}")
                state.transition(TaskStatus.ACTING)

                # ---- ask_user 拦截 ----
                # 如果 LLM 调用了 ask_user，立即中断循环，将问题返回给用户
                ask_user_calls = [tc for tc in decision.tool_calls if tc.get("name") == "ask_user"]
                other_calls = [tc for tc in decision.tool_calls if tc.get("name") != "ask_user"]

                if ask_user_calls:
                    logger.info(
                        f"[ReAct] Iter {iteration+1} — ask_user intercepted, "
                        f"pausing for user input (other_tools={[tc.get('name') for tc in other_calls]})"
                    )

                    # 添加 assistant 消息（保留完整的 tool_use 内容用于上下文连贯）
                    working_messages.append({
                        "role": "assistant",
                        "content": decision.assistant_content,
                    })

                    # 如果同时还有其他工具调用，先执行它们
                    # 收集其他工具的 tool_result（Claude API 要求每个 tool_use 都有对应 tool_result）
                    other_tool_results: list[dict] = []
                    if other_calls:
                        other_results, other_executed, other_receipts = (
                            await self._tool_executor.execute_batch(
                                other_calls,
                                state=state,
                                task_monitor=task_monitor,
                                allow_interrupt_checks=self._state.interrupt_enabled,
                                capture_delivery_receipts=True,
                            )
                        )
                        if other_executed:
                            tools_executed_in_task = True
                            executed_tool_names.extend(other_executed)
                            state.record_tool_execution(other_executed)
                        # 保留其他工具的 tool_result 内容
                        other_tool_results = other_results if other_results else []

                    # 提取 ask_user 的问题文本
                    question = ask_user_calls[0].get("input", {}).get("question", "")
                    ask_tool_id = ask_user_calls[0].get("id", "ask_user_0")

                    # 合并 LLM 的文本回复 + 问题
                    text_part = strip_thinking_tags(decision.text_content or "").strip()
                    if text_part and question:
                        final_text = f"{text_part}\n\n{question}"
                    elif question:
                        final_text = question
                    else:
                        final_text = text_part or "（等待用户回复）"

                    state.transition(TaskStatus.WAITING_USER)

                    # ---- IM 模式：等待用户回复（超时 + 追问） ----
                    user_reply = await self._wait_for_user_reply(
                        final_text, state, timeout_seconds=60, max_reminders=1,
                    )

                    # 构建 tool_result 消息（其他工具结果 + ask_user 结果必须在同一条 user 消息中）
                    def _build_ask_user_tool_results(
                        ask_user_content: str,
                        _other_results: list[dict] = other_tool_results,
                        _ask_id: str = ask_tool_id,
                    ) -> list[dict]:
                        """构建包含所有 tool_result 的 user 消息 content"""
                        results = list(_other_results)  # 其他工具的 tool_result
                        results.append({
                            "type": "tool_result",
                            "tool_use_id": _ask_id,
                            "content": ask_user_content,
                        })
                        return results

                    if user_reply:
                        # 用户在超时内回复了 → 注入回复，继续 ReAct 循环
                        logger.info(
                            f"[ReAct] Iter {iteration+1} — ask_user: user replied, resuming loop"
                        )
                        working_messages.append({
                            "role": "user",
                            "content": _build_ask_user_tool_results(f"用户回复：{user_reply}"),
                        })
                        state.transition(TaskStatus.REASONING)
                        continue  # 继续 ReAct 循环

                    elif user_reply is None and self._state.current_session and (
                        self._state.current_session.get_metadata("_gateway")
                        if hasattr(self._state.current_session, "get_metadata")
                        else None
                    ):
                        # IM 模式，用户超时未回复 → 注入系统提示让 LLM 自行决策
                        logger.info(
                            f"[ReAct] Iter {iteration+1} — ask_user: user timeout, "
                            f"injecting auto-decide prompt"
                        )
                        working_messages.append({
                            "role": "user",
                            "content": _build_ask_user_tool_results(
                                "[系统] 用户 2 分钟内未回复你的提问。"
                                "请自行决策：如果能合理推断用户意图，继续执行任务；"
                                "否则终止当前任务并告知用户你需要什么信息。"
                            ),
                        })
                        state.transition(TaskStatus.REASONING)
                        continue  # 继续 ReAct 循环，让 LLM 自行决策

                    else:
                        # CLI 模式或无 gateway → 直接返回问题文本
                        tracer.end_trace(metadata={
                            "result": "waiting_user",
                            "iterations": iteration + 1,
                            "tools_used": list(set(executed_tool_names)),
                        })
                        logger.info(
                            f"[ReAct] === WAITING_USER (CLI) after {iteration+1} iterations ==="
                        )
                        return final_text

                # 保存检查点（在工具执行前）
                self._save_checkpoint(working_messages, state, decision, iteration)

                # 添加 assistant 消息
                working_messages.append({
                    "role": "assistant",
                    "content": decision.assistant_content,
                })

                # 检查取消
                if state.cancelled:
                    tracer.end_trace(metadata={"result": "cancelled", "iterations": iteration + 1})
                    return "✅ 任务已停止。"

                # 执行工具
                tool_results, executed, receipts = await self._tool_executor.execute_batch(
                    decision.tool_calls,
                    state=state,
                    task_monitor=task_monitor,
                    allow_interrupt_checks=self._state.interrupt_enabled,
                    capture_delivery_receipts=True,
                )

                if executed:
                    tools_executed_in_task = True
                    executed_tool_names.extend(executed)
                    state.record_tool_execution(executed)

                    # 记录工具成功/失败状态
                    for i, tool_name in enumerate(executed):
                        result_content = ""
                        if i < len(tool_results):
                            r = tool_results[i]
                            result_content = str(r.get("content", "")) if isinstance(r, dict) else str(r)
                        is_error = any(m in result_content for m in ["❌", "⚠️ 工具执行错误", "错误类型:"])
                        self._record_tool_result(tool_name, success=not is_error)

                if receipts:
                    delivery_receipts = receipts

                # ==================== OBSERVE 阶段 ====================
                logger.info(
                    f"[ReAct] Iter {iteration+1} — OBSERVE: "
                    f"{len(tool_results)} results from {executed or []}"
                )
                state.transition(TaskStatus.OBSERVING)

                # 检查是否应该回滚
                should_rb, rb_reason = self._should_rollback(tool_results)
                if should_rb:
                    rollback_result = self._rollback(rb_reason)
                    if rollback_result:
                        working_messages, _ = rollback_result
                        logger.info("[Rollback] 回滚成功，将用不同方法重新推理")
                        continue

                if state.cancelled:
                    tracer.end_trace(metadata={"result": "cancelled", "iterations": iteration + 1})
                    return "✅ 任务已停止。"

                # 添加工具结果
                working_messages.append({
                    "role": "user",
                    "content": tool_results,
                })

                # 循环检测
                consecutive_tool_rounds += 1

                # stop_reason 检查
                if decision.stop_reason == "end_turn":
                    cleaned_text = strip_thinking_tags(decision.text_content)
                    if cleaned_text and cleaned_text.strip():
                        logger.info(f"[LoopGuard] stop_reason=end_turn after {consecutive_tool_rounds} rounds")
                        state.transition(TaskStatus.COMPLETED)
                        tracer.end_trace(metadata={
                            "result": "completed_end_turn",
                            "iterations": iteration + 1,
                            "tools_used": list(set(executed_tool_names)),
                        })
                        return cleaned_text

                # 工具签名循环检测
                round_signatures = [_make_tool_signature(tc) for tc in decision.tool_calls]
                round_sig_str = "+".join(sorted(round_signatures))
                recent_tool_signatures.append(round_sig_str)
                if len(recent_tool_signatures) > tool_pattern_window:
                    recent_tool_signatures = recent_tool_signatures[-tool_pattern_window:]

                loop_result = self._detect_loops(
                    recent_tool_signatures,
                    consecutive_tool_rounds,
                    working_messages,
                    decision.text_content,
                    llm_self_check_interval,
                    extreme_safety_threshold,
                    conversation_id,
                )
                if loop_result == "terminate":
                    cleaned = strip_thinking_tags(decision.text_content)
                    state.transition(TaskStatus.FAILED)
                    tracer.end_trace(metadata={"result": "loop_terminated", "iterations": iteration + 1})
                    return cleaned or "⚠️ 检测到工具调用陷入死循环，任务已自动终止。请重新描述您的需求。"
                if loop_result == "disable_force":
                    max_no_tool_retries = 0

        state.transition(TaskStatus.FAILED)
        tracer.end_trace(metadata={"result": "max_iterations", "iterations": max_iterations})
        return "已达到最大工具调用次数，请重新描述您的需求。"

    # ==================== 流式输出 (SSE) ====================

    async def reason_stream(
        self,
        messages: list[dict],
        *,
        tools: list[dict] | None = None,
        system_prompt: str = "",
        plan_mode: bool = False,
        endpoint_override: str | None = None,
        conversation_id: str | None = None,
    ):
        """
        流式推理循环，为 HTTP API (SSE) 设计。

        调用方（如 Agent）需传入 tools 和 system_prompt，
        与 run() 保持一致的上下文。

        Yields dict events:
        - {"type": "thinking_start"}
        - {"type": "thinking_delta", "content": "..."}
        - {"type": "thinking_end"}
        - {"type": "text_delta", "content": "..."}
        - {"type": "tool_call_start", "tool": "...", "args": {...}}
        - {"type": "tool_call_end", "tool": "...", "result": "..."}
        - {"type": "plan_created", "plan": {...}}
        - {"type": "plan_step_updated", "stepIdx": N, "status": "..."}
        - {"type": "error", "message": "..."}
        - {"type": "done"}
        """
        tools = tools or []

        try:
            # Build system prompt with optional plan context
            def _build_stream_system_prompt() -> str:
                """动态追加活跃 Plan（与 run() 中逻辑一致）"""
                try:
                    from ..tools.handlers.plan import get_active_plan_prompt
                    prompt = system_prompt
                    if conversation_id:
                        plan_section = get_active_plan_prompt(conversation_id)
                        if plan_section:
                            prompt += f"\n\n{plan_section}\n"
                    return prompt
                except Exception:
                    return system_prompt

            effective_prompt = _build_stream_system_prompt()
            if plan_mode:
                effective_prompt += "\n\n[PLAN MODE] 用户请求 Plan 模式。请先制定详细计划（使用 create_plan 工具），然后按计划执行。"

            # 如果指定了端点，通过 LLMClient.switch_model 临时切换
            # 关键：必须使用 per-conversation override，避免污染全局状态
            # 否则 IM 通道、调度器等其他 LLM 调用会在 3 分钟内被错误地路由到该端点
            _endpoint_switched = False
            if endpoint_override:
                # 确保有 conversation_id —— 无 id 时生成临时 id，避免设置全局 override
                if not conversation_id:
                    conversation_id = f"_stream_{uuid.uuid4().hex[:12]}"
                llm_client = getattr(self._brain, "_llm_client", None)
                if llm_client and hasattr(llm_client, "switch_model"):
                    ok, msg = llm_client.switch_model(
                        endpoint_name=endpoint_override,
                        hours=0.05,  # ~3 分钟有效
                        reason=f"chat endpoint override: {endpoint_override}",
                        conversation_id=conversation_id,
                    )
                    if not ok:
                        yield {"type": "error", "message": f"端点切换失败: {msg}"}
                        yield {"type": "done"}
                        return
                    _endpoint_switched = True

            current_model = self._brain.model

            # ReAct multi-turn loop
            max_iterations = min(settings.max_iterations, 10)
            working_messages = list(messages)

            for _iteration in range(max_iterations):
                # Reason phase
                yield {"type": "thinking_start"}

                try:
                    decision = await self._reason(
                        working_messages,
                        system_prompt=effective_prompt,
                        tools=tools,
                        current_model=current_model,
                        conversation_id=conversation_id,
                    )
                except Exception as e:
                    yield {"type": "thinking_end"}
                    yield {"type": "error", "message": f"推理失败: {str(e)[:300]}"}
                    yield {"type": "done"}
                    return

                # Emit thinking content
                if decision.thinking_content:
                    yield {"type": "thinking_delta", "content": decision.thinking_content}
                yield {"type": "thinking_end"}

                # Final answer: stream text
                if decision.type == DecisionType.FINAL_ANSWER:
                    text = decision.text_content or ""
                    # Stream in chunks for better UX
                    chunk_size = 20
                    for i in range(0, len(text), chunk_size):
                        yield {"type": "text_delta", "content": text[i:i + chunk_size]}
                        await asyncio.sleep(0.01)  # Small delay for streaming effect
                    yield {"type": "done"}
                    return

                # Tool calls: emit each call
                if decision.type == DecisionType.TOOL_CALLS and decision.tool_calls:
                    # assistant_content 只追加一次（完整的 assistant turn 包含所有 tool_use）
                    working_messages.append({
                        "role": "assistant",
                        "content": decision.assistant_content or [{"type": "text", "text": ""}],
                    })

                    # ---- ask_user 拦截（Web Chat 模式） ----
                    ask_user_calls = [tc for tc in decision.tool_calls if tc.get("name") == "ask_user"]
                    other_tool_calls = [tc for tc in decision.tool_calls if tc.get("name") != "ask_user"]

                    if ask_user_calls:
                        # 先执行非 ask_user 的工具
                        tool_results_for_msg: list[dict] = []
                        for tc in other_tool_calls:
                            t_name = tc.get("name", "unknown")
                            t_args = tc.get("input", tc.get("arguments", {}))
                            t_id = tc.get("id", str(uuid.uuid4()))
                            yield {"type": "tool_call_start", "tool": t_name, "args": t_args, "id": t_id}
                            try:
                                r = await self._tool_executor.execute_tool(
                                    tool_name=t_name,
                                    tool_input=t_args if isinstance(t_args, dict) else {},
                                    session_id=conversation_id,
                                )
                                r = str(r) if r else ""
                            except Exception as e:
                                r = f"Tool error: {e}"
                            yield {"type": "tool_call_end", "tool": t_name, "result": r[:2000], "id": t_id}
                            tool_results_for_msg.append({
                                "type": "tool_result", "tool_use_id": t_id, "content": r[:4000],
                            })

                        # 提取 ask_user 问题并发送事件
                        ask_q = ask_user_calls[0].get("input", {}).get("question", "")
                        text_part = decision.text_content or ""
                        question_text = f"{text_part}\n\n{ask_q}".strip() if text_part else ask_q
                        yield {
                            "type": "ask_user",
                            "question": question_text,
                            "conversation_id": conversation_id,
                        }
                        # 中断流：前端收到 ask_user 后，用户回复将作为新一轮 chat 请求发送
                        yield {"type": "done"}
                        return

                    tool_results_for_msg: list[dict] = []
                    for tc in decision.tool_calls:
                        tool_name = tc.get("name", "unknown")
                        tool_args = tc.get("input", tc.get("arguments", {}))
                        tool_id = tc.get("id", str(uuid.uuid4()))

                        yield {"type": "tool_call_start", "tool": tool_name, "args": tool_args, "id": tool_id}

                        # Execute tool via ToolExecutor.execute_tool
                        # 传递 session_id 以确保 Plan 模式检查正常工作
                        try:
                            result_text = await self._tool_executor.execute_tool(
                                tool_name=tool_name,
                                tool_input=tool_args if isinstance(tool_args, dict) else {},
                                session_id=conversation_id,
                            )
                            result_text = str(result_text) if result_text else ""
                        except Exception as e:
                            result_text = f"Tool error: {e}"

                        yield {"type": "tool_call_end", "tool": tool_name, "result": result_text[:2000], "id": tool_id}

                        # Check for plan-related tools
                        if tool_name == "create_plan" and isinstance(tool_args, dict):
                            yield {"type": "plan_created", "plan": {
                                "id": str(uuid.uuid4()),
                                "taskSummary": tool_args.get("task_summary", ""),
                                "steps": [{"description": s, "status": "pending"} for s in tool_args.get("steps", [])],
                                "status": "in_progress",
                            }}
                        elif tool_name == "update_plan_step" and isinstance(tool_args, dict):
                            yield {"type": "plan_step_updated", "stepIdx": tool_args.get("step_index", 0), "status": tool_args.get("status", "completed")}

                        # Collect tool result for message
                        tool_results_for_msg.append({
                            "type": "tool_result",
                            "tool_use_id": tool_id,
                            "content": result_text[:4000],
                        })

                    # Append all tool results as a single user turn
                    working_messages.append({
                        "role": "user",
                        "content": tool_results_for_msg,
                    })

                    continue  # Next iteration

            yield {"type": "text_delta", "content": "\n\n（已达到最大迭代次数）"}
            yield {"type": "done"}

        except Exception as e:
            logger.error(f"reason_stream error: {e}", exc_info=True)
            yield {"type": "error", "message": str(e)[:500]}
            yield {"type": "done"}

        finally:
            # 清理 per-conversation endpoint override，避免残留
            if _endpoint_switched and conversation_id:
                llm_client = getattr(self._brain, "_llm_client", None)
                if llm_client and hasattr(llm_client, "restore_default"):
                    try:
                        llm_client.restore_default(conversation_id=conversation_id)
                    except Exception:
                        pass  # 清理失败不影响主流程

    # ==================== 推理阶段 ====================

    async def _reason(
        self,
        messages: list[dict],
        *,
        system_prompt: str,
        tools: list[dict],
        current_model: str,
        conversation_id: str | None = None,
    ) -> Decision:
        """
        推理阶段: 调用 LLM，返回结构化 Decision。
        """
        tracer = get_tracer()
        with tracer.llm_span(model=current_model) as span:
            response = await asyncio.to_thread(
                self._brain.messages_create,
                model=current_model,
                max_tokens=self._brain.max_tokens,
                system=system_prompt,
                tools=tools,
                messages=messages,
                conversation_id=conversation_id,
            )

            # 记录 token 使用
            if hasattr(response, "usage"):
                span.set_attribute("input_tokens", getattr(response.usage, "input_tokens", 0))
                span.set_attribute("output_tokens", getattr(response.usage, "output_tokens", 0))

            decision = self._parse_decision(response)
            span.set_attribute("decision_type", decision.type.value)
            span.set_attribute("tool_count", len(decision.tool_calls))
            return decision

    def _parse_decision(self, response: Any) -> Decision:
        """解析 LLM 响应为 Decision"""
        tool_calls = []
        text_content = ""
        thinking_content = ""
        assistant_content = []

        for block in response.content:
            if block.type == "thinking":
                thinking_text = block.thinking if hasattr(block, "thinking") else str(block)
                thinking_content += thinking_text if isinstance(thinking_text, str) else str(thinking_text)
                assistant_content.append({
                    "type": "thinking",
                    "thinking": thinking_text,
                })
            elif block.type == "text":
                text_content += block.text
                assistant_content.append({"type": "text", "text": block.text})
            elif block.type == "tool_use":
                tool_calls.append({
                    "id": block.id,
                    "name": block.name,
                    "input": block.input,
                })
                assistant_content.append({
                    "type": "tool_use",
                    "id": block.id,
                    "name": block.name,
                    "input": block.input,
                })

        decision_type = DecisionType.TOOL_CALLS if tool_calls else DecisionType.FINAL_ANSWER

        return Decision(
            type=decision_type,
            text_content=text_content,
            tool_calls=tool_calls,
            thinking_content=thinking_content,
            raw_response=response,
            stop_reason=getattr(response, "stop_reason", ""),
            assistant_content=assistant_content,
        )

    # ==================== 最终答案处理 ====================

    async def _handle_final_answer(
        self,
        *,
        decision: Decision,
        working_messages: list[dict],
        original_messages: list[dict],
        tools_executed_in_task: bool,
        executed_tool_names: list[str],
        delivery_receipts: list[dict],
        no_tool_call_count: int,
        verify_incomplete_count: int,
        no_confirmation_text_count: int,
        max_no_tool_retries: int,
        max_verify_retries: int,
        max_confirmation_text_retries: int,
        base_force_retries: int,
        conversation_id: str | None,
    ) -> str | tuple:
        """
        处理纯文本响应（无工具调用）。

        Returns:
            str: 最终答案
            tuple: (working_messages, no_tool_call_count, verify_incomplete_count,
                    no_confirmation_text_count, max_no_tool_retries) - 需要继续循环
        """
        if tools_executed_in_task:
            cleaned_text = strip_thinking_tags(decision.text_content)
            if cleaned_text and len(cleaned_text.strip()) > 0:
                # 任务完成度验证
                is_completed = await self._response_handler.verify_task_completion(
                    user_request=ResponseHandler.get_last_user_request(original_messages),
                    assistant_response=cleaned_text,
                    executed_tools=executed_tool_names,
                    delivery_receipts=delivery_receipts,
                    conversation_id=conversation_id,
                )

                if is_completed:
                    return cleaned_text

                verify_incomplete_count += 1

                # 检查活跃 Plan
                has_plan_pending = self._has_active_plan_pending(conversation_id)
                effective_max = max_verify_retries * 2 if has_plan_pending else max_verify_retries

                if verify_incomplete_count >= effective_max:
                    return cleaned_text

                # 继续循环
                working_messages.append({
                    "role": "assistant",
                    "content": [{"type": "text", "text": decision.text_content}],
                })

                if has_plan_pending:
                    working_messages.append({
                        "role": "user",
                        "content": (
                            "[系统提示] 当前 Plan 仍有未完成的步骤。"
                            "请立即继续执行下一个 pending 步骤。"
                        ),
                    })
                else:
                    working_messages.append({
                        "role": "user",
                        "content": (
                            "[系统提示] 根据复核判断，用户请求可能还有未完成的部分。"
                            "如果你认为已经完成，请直接给用户一个总结回复；"
                            "如果确实还有剩余步骤，请继续执行。"
                        ),
                    })
                return (working_messages, no_tool_call_count, verify_incomplete_count,
                        no_confirmation_text_count, max_no_tool_retries)
            else:
                # 无可见文本
                no_confirmation_text_count += 1
                if no_confirmation_text_count <= max_confirmation_text_retries:
                    working_messages.append({
                        "role": "user",
                        "content": (
                            "[系统] 你已执行过工具，但你刚才没有输出任何用户可见的文字确认。"
                            "请基于已产生的 tool_result 证据，给出最终答复。"
                        ),
                    })
                    return (working_messages, no_tool_call_count, verify_incomplete_count,
                            no_confirmation_text_count, max_no_tool_retries)

                return (
                    "⚠️ 大模型返回异常：工具已执行，但多次未返回任何可见文本确认，任务已中断。"
                    "请重试、或切换到更稳定的端点/模型后再继续。"
                )

        # 未执行过工具
        max_no_tool_retries = self._effective_force_retries(base_force_retries, conversation_id)
        no_tool_call_count += 1

        if no_tool_call_count <= max_no_tool_retries:
            if decision.text_content:
                working_messages.append({
                    "role": "assistant",
                    "content": [{"type": "text", "text": decision.text_content}],
                })
            working_messages.append({
                "role": "user",
                "content": "[系统] 若确实需要工具，请调用相应工具；若不需要工具，请直接回答。",
            })
            return (working_messages, no_tool_call_count, verify_incomplete_count,
                    no_confirmation_text_count, max_no_tool_retries)

        # 追问次数用尽
        cleaned_text = clean_llm_response(decision.text_content)
        return cleaned_text or (
            "⚠️ 大模型返回异常：未产生可用输出。任务已中断。"
            "请重试、或更换端点/模型后再执行。"
        )

    # ==================== 循环检测 ====================

    def _detect_loops(
        self,
        recent_signatures: list[str],
        consecutive_rounds: int,
        working_messages: list[dict],
        text_content: str,
        self_check_interval: int,
        extreme_threshold: int,
        conversation_id: str | None,
    ) -> str | None:
        """
        循环检测。

        Returns:
            "terminate" - 终止循环
            "disable_force" - 禁用 ForceToolCall
            None - 继续
        """
        # 签名重复检测
        if len(recent_signatures) >= 3:
            from collections import Counter
            sig_counts = Counter(recent_signatures)
            most_common_sig, most_common_count = sig_counts.most_common(1)[0]

            if most_common_count >= 3:
                logger.warning(
                    f"[LoopGuard] True loop: '{most_common_sig}' repeated {most_common_count} times"
                )
                working_messages.append({
                    "role": "user",
                    "content": (
                        "[系统提示] 你在最近几轮中用完全相同的参数重复调用了同一个工具。"
                        "请评估：1. 任务已完成则停止调用。2. 遇到困难则换方法。"
                    ),
                })

                if most_common_count >= 5:
                    logger.error(f"[LoopGuard] Dead loop ({most_common_count} repeats). Terminating.")
                    return "terminate"

        # 定期 LLM 自检
        if consecutive_rounds > 0 and consecutive_rounds % self_check_interval == 0:
            has_plan = self._has_active_plan_pending(conversation_id)
            if has_plan:
                working_messages.append({
                    "role": "user",
                    "content": (
                        f"[系统提示] 已连续执行 {consecutive_rounds} 轮，Plan 仍有未完成步骤。"
                        "如果遇到困难，请换一种方法继续推进。"
                    ),
                })
            else:
                working_messages.append({
                    "role": "user",
                    "content": (
                        f"[系统提示] 你已连续执行了 {consecutive_rounds} 轮工具调用。请自我评估：\n"
                        "1. 当前任务进度如何？\n"
                        "2. 是否陷入了循环？\n"
                        "3. 如果任务已完成，请停止工具调用，直接回复用户。"
                    ),
                })

        # 极端安全阈值
        if consecutive_rounds == extreme_threshold:
            logger.warning(f"[LoopGuard] Extreme safety threshold ({extreme_threshold})")
            working_messages.append({
                "role": "user",
                "content": (
                    f"[系统提示] 当前任务已连续执行了 {extreme_threshold} 轮。"
                    "请向用户汇报进度并询问是否继续。"
                ),
            })
            return "disable_force"

        return None

    # ==================== 模型切换 ====================

    def _check_model_switch(
        self,
        task_monitor: Any,
        state: TaskState,
        working_messages: list[dict],
        current_model: str,
    ) -> tuple[str, list[dict]] | None:
        """检查是否需要模型切换。返回 (new_model, new_messages) 或 None"""
        if not task_monitor or not task_monitor.should_switch_model:
            return None

        new_model = task_monitor.fallback_model
        self._switch_llm_endpoint(new_model, reason="task_monitor timeout fallback")
        task_monitor.switch_model(
            new_model,
            "任务超时后切换",
            reset_context=True,
        )

        try:
            llm_client = getattr(self._brain, "_llm_client", None)
            current = llm_client.get_current_model() if llm_client else None
            new_model = current.model if current else new_model
        except Exception:
            pass

        new_messages = list(state.original_user_messages)
        new_messages.append({
            "role": "user",
            "content": (
                "[系统提示] 发生模型切换：之前的 tool_use/tool_result 历史已清除。"
                "请从头开始处理用户请求。"
            ),
        })

        # 注意：_check_model_switch 不做状态转换，因为它不使用 continue，
        # 执行后自然走到主循环的 REASONING 转换逻辑。
        state.reset_for_model_switch()
        return new_model, new_messages

    def _handle_llm_error(
        self,
        error: Exception,
        task_monitor: Any,
        state: TaskState,
        working_messages: list[dict],
        current_model: str,
    ) -> str | tuple | None:
        """
        处理 LLM 调用错误。

        Returns:
            "retry" - 重试
            (new_model, new_messages) - 切换模型
            None - 重新抛出
        """
        if not task_monitor:
            return None

        should_retry = task_monitor.record_error(str(error))

        if should_retry:
            logger.info(f"[LLM] Will retry (attempt {task_monitor.retry_count})")
            return "retry"

        # 切换模型
        new_model = task_monitor.fallback_model
        self._switch_llm_endpoint(new_model, reason=f"LLM error fallback: {error}")
        task_monitor.switch_model(new_model, "LLM 调用失败后切换", reset_context=True)

        try:
            llm_client = getattr(self._brain, "_llm_client", None)
            current = llm_client.get_current_model() if llm_client else None
            new_model = current.model if current else new_model
        except Exception:
            pass

        new_messages = list(state.original_user_messages)
        new_messages.append({
            "role": "user",
            "content": (
                "[系统提示] 发生模型切换：之前的历史已清除。"
                "请从头开始处理用户请求。"
            ),
        })

        state.transition(TaskStatus.MODEL_SWITCHING)
        state.reset_for_model_switch()
        return new_model, new_messages

    def _switch_llm_endpoint(self, model_or_endpoint: str, reason: str = "") -> bool:
        """执行模型切换"""
        llm_client = getattr(self._brain, "_llm_client", None)
        if not llm_client:
            return False

        endpoint_name = self._resolve_endpoint_name(model_or_endpoint)
        if not endpoint_name:
            return False

        ok, msg = llm_client.switch_model(
            endpoint_name=endpoint_name,
            hours=0.05,
            reason=reason,
        )
        if not ok:
            return False

        try:
            current = llm_client.get_current_model()
            if current and current.model:
                self._brain.model = current.model
        except Exception:
            pass

        logger.info(f"[ModelSwitch] {msg}")
        return True

    def _resolve_endpoint_name(self, model_or_endpoint: str) -> str | None:
        """解析 endpoint 名称"""
        try:
            llm_client = getattr(self._brain, "_llm_client", None)
            if not llm_client:
                return None
            available = [m.name for m in llm_client.list_available_models()]
            if model_or_endpoint in available:
                return model_or_endpoint
            for m in llm_client.list_available_models():
                if m.model == model_or_endpoint:
                    return m.name
            return None
        except Exception:
            return None

    # ==================== 辅助方法 ====================

    @staticmethod
    def _is_human_user_message(msg: dict) -> bool:
        """判断是否为人类用户消息（排除 tool_result）"""
        if msg.get("role") != "user":
            return False
        content = msg.get("content")
        if isinstance(content, str):
            return True
        if isinstance(content, list):
            part_types = {
                part.get("type")
                for part in content
                if isinstance(part, dict) and part.get("type")
            }
            return "tool_result" not in part_types
        return False

    @staticmethod
    def _effective_force_retries(base_retries: int, conversation_id: str | None) -> int:
        """计算有效 ForceToolCall 重试次数"""
        retries = base_retries
        try:
            from ..tools.handlers.plan import has_active_plan, is_plan_required
            if conversation_id and (has_active_plan(conversation_id) or is_plan_required(conversation_id)):
                retries = max(retries, 1)
        except Exception:
            pass
        return max(0, int(retries))

    @staticmethod
    def _has_active_plan_pending(conversation_id: str | None) -> bool:
        """检查是否有活跃 Plan 且有未完成步骤"""
        try:
            from ..tools.handlers.plan import get_plan_handler_for_session, has_active_plan
            if conversation_id and has_active_plan(conversation_id):
                handler = get_plan_handler_for_session(conversation_id)
                if handler and handler.current_plan:
                    steps = handler.current_plan.get("steps", [])
                    pending = [s for s in steps if s.get("status") in ("pending", "in_progress")]
                    return bool(pending)
        except Exception:
            pass
        return False
