"""Agent loop: the core processing engine."""

from __future__ import annotations

import asyncio
import json
import os
import re
import uuid
import weakref
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable

from loguru import logger

from madpod_agent.agent.context import ContextBuilder
from madpod_agent.agent.investigation import (
    EvidenceLog,
    InvestigationFindings,
    InvestigationProfile,
    build_provisional_response,
    build_retry_instruction,
    build_turn_policy,
    classify_request,
    record_tool_event,
    should_auto_investigate,
    verify_investigation,
)
from madpod_agent.agent.memory import MemoryStore
from madpod_agent.agent.subagent import SubagentManager
from madpod_agent.agent.tools.browser import (
    NativePlaywrightBrowserBackend,
    register_browser_tools,
)
from madpod_agent.agent.tools.cron import CronTool
from madpod_agent.agent.tools.download import DownloadTool
from madpod_agent.agent.tools.extract import register_extract_tools
from madpod_agent.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool, ensure_memory_structure
from madpod_agent.agent.tools.message import MessageTool
from madpod_agent.agent.tools.pdf import PDFReadTool
from madpod_agent.agent.tools.python_exec import PythonExecTool
from madpod_agent.agent.tools.registry import ToolRegistry
from madpod_agent.agent.tools.selector import get_tools_for_context
from madpod_agent.agent.tools.shell import ExecTool
from madpod_agent.agent.tools.spawn import SpawnTool
from madpod_agent.agent.tools.archive import ArchiveTool
from madpod_agent.agent.tools.approval import RequestApprovalTool
from madpod_agent.agent.tools.ask_human import AskHumanTool
from madpod_agent.agent.tools.chart import ChartTool
from madpod_agent.agent.tools.diff import DiffTool
from madpod_agent.agent.tools.email_tool import EmailTool
from madpod_agent.agent.tools.http_request import HTTPRequestTool
from madpod_agent.agent.tools.notify import NotifyTool
from madpod_agent.agent.tools.spreadsheet import SpreadsheetTool
from madpod_agent.agent.tools.transcribe import TranscribeTool
from madpod_agent.agent.tools.vision import ImageAnalyzeTool, ScreenshotAnalyzeTool
from madpod_agent.agent.tools.web import WebFetchTool, WebSearchTool
from madpod_agent.agent.tools.web_crawl import WebCrawlTool
from madpod_agent.agent.tools.clipboard import ClipboardTool
from madpod_agent.agent.tools.file_convert import FileConvertTool
from madpod_agent.agent.tools.json_query import JsonQueryTool
from madpod_agent.agent.tools.regex_tool import RegexTool
from madpod_agent.agent.tools.sqlite_tool import SqliteTool
from madpod_agent.agent.tools.template_render import TemplateRenderTool
from madpod_agent.bus.events import InboundMessage, OutboundMessage
from madpod_agent.bus.queue import MessageBus
from madpod_agent.providers.base import LLMProvider
from madpod_agent.session.manager import Session, SessionManager

if TYPE_CHECKING:
    from madpod_agent.config.schema import (
        BrowserToolsConfig,
        ChannelsConfig,
        ExecToolConfig,
        WebSearchConfig,
    )
    from madpod_agent.cron.service import CronService


class AgentLoop:
    """
    The agent loop is the core processing engine.

    It:
    1. Receives messages from the bus
    2. Builds context with history, memory, skills
    3. Calls the LLM
    4. Executes tool calls
    5. Sends responses back
    """

    _TOOL_RESULT_MAX_CHARS = 500

    @staticmethod
    def _env_flag(name: str) -> bool:
        return str(os.environ.get(name, "")).strip().lower() in {"1", "true", "yes", "on"}

    def __init__(
        self,
        bus: MessageBus,
        provider: LLMProvider,
        workspace: Path,
        model: str | None = None,
        max_iterations: int = 40,
        temperature: float = 0.1,
        max_tokens: int = 4096,
        memory_window: int = 100,
        reasoning_effort: str | None = None,
        brave_api_key: str | None = None,
        web_proxy: str | None = None,
        web_search_config: WebSearchConfig | None = None,
        browser_config: BrowserToolsConfig | None = None,
        exec_config: ExecToolConfig | None = None,
        cron_service: CronService | None = None,
        restrict_to_workspace: bool = False,
        session_manager: SessionManager | None = None,
        mcp_servers: dict | None = None,
        channels_config: ChannelsConfig | None = None,
        executor_provider: LLMProvider | None = None,
        executor_model: str | None = None,
        executor_max_tokens: int | None = None,
        executor_reasoning_effort: str | None = None,
        summary_provider: LLMProvider | None = None,
        summary_model: str | None = None,
    ):
        from madpod_agent.config.schema import BrowserToolsConfig, ExecToolConfig
        self.bus = bus
        self.channels_config = channels_config
        self.provider = provider
        self.workspace = workspace
        self.model = model or provider.get_default_model()
        self.max_iterations = max_iterations
        self.temperature = temperature
        self.max_tokens = max_tokens
        self.memory_window = memory_window
        self.reasoning_effort = reasoning_effort
        self.brave_api_key = brave_api_key
        self.web_proxy = web_proxy
        self.web_search_config = web_search_config
        self.browser_config = browser_config or BrowserToolsConfig()
        self.exec_config = exec_config or ExecToolConfig()
        self.cron_service = cron_service
        self.restrict_to_workspace = restrict_to_workspace
        self.summary_provider = summary_provider or provider
        self.summary_model = summary_model

        self.context = ContextBuilder(workspace)
        self.sessions = session_manager or SessionManager(workspace)
        self.tools = ToolRegistry()
        self.subagents = SubagentManager(
            provider=executor_provider or provider,
            workspace=workspace,
            bus=bus,
            model=executor_model or self.model,
            temperature=self.temperature,
            max_tokens=executor_max_tokens or self.max_tokens,
            reasoning_effort=executor_reasoning_effort if executor_reasoning_effort is not None else reasoning_effort,
            brave_api_key=brave_api_key,
            web_proxy=web_proxy,
            web_search_config=web_search_config,
            summary_provider=self.summary_provider,
            summary_model=summary_model,
            browser_config=browser_config,
            exec_config=self.exec_config,
            restrict_to_workspace=restrict_to_workspace,
            mcp_servers=mcp_servers or {},
        )

        self._running = False
        self._mcp_servers = mcp_servers or {}
        self._mcp_stack: AsyncExitStack | None = None
        self._mcp_connected = False
        self._mcp_connecting = False
        self._consolidating: set[str] = set()  # Session keys with consolidation in progress
        self._consolidation_tasks: set[asyncio.Task] = set()  # Strong refs to in-flight tasks
        self._consolidation_tasks_by_session: dict[str, set[asyncio.Task]] = {}
        self._session_processing_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
        self._consolidation_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
        self._active_tasks: dict[str, list[asyncio.Task]] = {}  # session_key -> tasks
        self._register_default_tools()

    def _register_default_tools(self) -> None:
        """Register the default set of tools."""
        allowed_dir = self.workspace if self.restrict_to_workspace else None
        disable_filesystem_tools = self._env_flag("MADPOD_AGENT_DISABLE_FILESYSTEM_TOOLS")
        disable_exec_tool = self._env_flag("MADPOD_AGENT_DISABLE_EXEC_TOOL")
        disable_browser_tools = self._env_flag("MADPOD_AGENT_DISABLE_BROWSER_TOOLS")
        disable_message_tool = self._env_flag("MADPOD_AGENT_DISABLE_MESSAGE_TOOL")
        disable_spawn_tool = self._env_flag("MADPOD_AGENT_DISABLE_SPAWN_TOOL")
        disable_cron_tool = self._env_flag("MADPOD_AGENT_DISABLE_CRON_TOOL")
        if not disable_filesystem_tools:
            for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool):
                self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
        if not disable_exec_tool:
            self.tools.register(ExecTool(
                working_dir=str(self.workspace),
                timeout=self.exec_config.timeout,
                restrict_to_workspace=self.restrict_to_workspace,
                path_append=self.exec_config.path_append,
            ))
        if not disable_browser_tools:
            register_browser_tools(
                self.tools,
                workspace=self.workspace,
                config=self.browser_config,
            )
            # Register extraction tools that piggyback on the browser backend
            browser_nav = self.tools.get("browser_navigate")
            if browser_nav is not None:
                register_extract_tools(self.tools, browser_nav.backend)
                # Vision tools need the browser backend + an LLM provider
                vision_model = self.summary_model or self.model
                self.tools.register(ScreenshotAnalyzeTool(
                    backend=browser_nav.backend,
                    provider=self.summary_provider,
                    vision_model=vision_model,
                    workspace=self.workspace,
                ))
            self.tools.register(ImageAnalyzeTool(
                provider=self.summary_provider,
                vision_model=self.summary_model or self.model,
                workspace=self.workspace,
                allowed_dir=allowed_dir,
            ))
        self.tools.register(WebSearchTool(
            api_key=self.brave_api_key,
            proxy=self.web_proxy,
            provider=self.summary_provider,
            workspace=self.workspace,
            search_config=self.web_search_config,
            summary_model=self.summary_model,
        ))
        self.tools.register(WebFetchTool(
            proxy=self.web_proxy,
            workspace=self.workspace,
            search_config=self.web_search_config,
        ))
        # Download, PDF, transcription, and Python tools
        self.tools.register(DownloadTool(
            workspace=self.workspace,
            proxy=self.web_proxy,
        ))
        self.tools.register(PDFReadTool(
            workspace=self.workspace,
            allowed_dir=allowed_dir,
        ))
        self.tools.register(TranscribeTool(
            workspace=self.workspace,
            allowed_dir=allowed_dir,
        ))
        self.tools.register(PythonExecTool(
            workspace=self.workspace,
            timeout=self.exec_config.timeout,
            allowed_dir=allowed_dir,
        ))
        # HTTP, crawling, spreadsheet, chart, diff, archive tools
        self.tools.register(HTTPRequestTool(proxy=self.web_proxy))
        self.tools.register(WebCrawlTool(proxy=self.web_proxy))
        self.tools.register(SpreadsheetTool(workspace=self.workspace, allowed_dir=allowed_dir))
        self.tools.register(ChartTool(workspace=self.workspace))
        self.tools.register(DiffTool(workspace=self.workspace, allowed_dir=allowed_dir))
        self.tools.register(ArchiveTool(workspace=self.workspace, allowed_dir=allowed_dir))
        # Wave-3: standalone tools
        self.tools.register(SqliteTool(workspace=self.workspace, allowed_dir=allowed_dir))
        self.tools.register(TemplateRenderTool(workspace=self.workspace, allowed_dir=allowed_dir))
        self.tools.register(FileConvertTool(workspace=self.workspace, allowed_dir=allowed_dir))
        self.tools.register(RegexTool())
        self.tools.register(JsonQueryTool(workspace=self.workspace))
        self.tools.register(ClipboardTool())
        # Email tool (configured via env vars)
        self.tools.register(EmailTool(
            workspace=self.workspace,
            smtp_host=os.environ.get("SMTP_HOST", ""),
            smtp_port=int(os.environ.get("SMTP_PORT", "587")),
            imap_host=os.environ.get("IMAP_HOST", ""),
            imap_port=int(os.environ.get("IMAP_PORT", "993")),
            username=os.environ.get("EMAIL_USERNAME", ""),
            password=os.environ.get("EMAIL_PASSWORD", ""),
        ))
        if not disable_message_tool:
            self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
            self.tools.register(AskHumanTool(send_callback=self.bus.publish_outbound))
            self.tools.register(RequestApprovalTool(send_callback=self.bus.publish_outbound))
            self.tools.register(NotifyTool(send_callback=self.bus.publish_outbound))
        if not disable_spawn_tool:
            self.tools.register(SpawnTool(manager=self.subagents))
        if self.cron_service and not disable_cron_tool:
            self.tools.register(CronTool(self.cron_service))

    async def _connect_mcp(self) -> None:
        """Connect to configured MCP servers (one-time, lazy)."""
        if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
            return
        self._mcp_connecting = True
        from madpod_agent.agent.tools.mcp import connect_mcp_servers
        try:
            self._mcp_stack = AsyncExitStack()
            await self._mcp_stack.__aenter__()
            await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
            self._mcp_connected = True
        except Exception as e:
            logger.error("Failed to connect MCP servers (will retry next message): {}", e)
            if self._mcp_stack:
                try:
                    await self._mcp_stack.aclose()
                except Exception:
                    pass
                self._mcp_stack = None
        finally:
            self._mcp_connecting = False

    def _set_tool_context(
        self,
        channel: str,
        chat_id: str,
        message_id: str | None = None,
        metadata: dict[str, Any] | None = None,
        session_key: str | None = None,
    ) -> None:
        """Update context for all tools that need routing info."""
        for name in ("message", "spawn", "cron", "ask_human", "request_approval", "notify"):
            if tool := self.tools.get(name):
                if hasattr(tool, "set_context"):
                    if name == "message":
                        tool.set_context(channel, chat_id, message_id, metadata)
                    elif name == "spawn":
                        tool.set_context(
                            channel,
                            chat_id,
                            message_id,
                            metadata,
                            session_key=session_key,
                        )
                    else:
                        tool.set_context(channel, chat_id)

    def _get_session_processing_lock(self, session_key: str) -> asyncio.Lock:
        """Return the per-session processing lock, creating it lazily."""
        lock = self._session_processing_locks.get(session_key)
        if lock is None:
            lock = asyncio.Lock()
            self._session_processing_locks[session_key] = lock
        return lock

    def _track_consolidation_task(self, session_key: str, task: asyncio.Task) -> None:
        """Keep strong refs to in-flight consolidation tasks and bucket them by session."""
        self._consolidation_tasks.add(task)
        self._consolidation_tasks_by_session.setdefault(session_key, set()).add(task)

        def _cleanup(_: asyncio.Task) -> None:
            self._consolidation_tasks.discard(task)
            if tasks := self._consolidation_tasks_by_session.get(session_key):
                tasks.discard(task)
                if not tasks:
                    self._consolidation_tasks_by_session.pop(session_key, None)

        task.add_done_callback(_cleanup)

    async def _cancel_consolidation_tasks_for_session(self, session_key: str) -> int:
        """Cancel any background consolidation work tied to a specific session."""
        tasks = list(self._consolidation_tasks_by_session.pop(session_key, set()))
        pending = [task for task in tasks if not task.done()]
        for task in pending:
            task.cancel()
        if pending:
            await asyncio.gather(*pending, return_exceptions=True)
        for task in tasks:
            self._consolidation_tasks.discard(task)
        return len(pending)

    @staticmethod
    def _build_correlated_outbound_metadata(
        source_metadata: dict[str, Any] | None,
        *,
        progress: bool,
        message_kind: str | None = None,
        tool_hint: bool = False,
    ) -> dict[str, Any]:
        source = dict(source_metadata or {})
        assignment_message_id = str(
            source.get("assignment_message_id")
            or source.get("directive_id")
            or source.get("message_id")
            or ""
        ).strip() or None
        upstream_in_reply_to = str(source.get("in_reply_to") or "").strip() or None

        metadata: dict[str, Any] = {
            "message_id": uuid.uuid4().hex,
        }
        for key in ("round_id", "subtask_id"):
            value = source.get(key)
            if value:
                metadata[key] = value
        if assignment_message_id:
            metadata["in_reply_to"] = assignment_message_id
            metadata["directive_id"] = str(source.get("directive_id") or assignment_message_id)
        elif upstream_in_reply_to:
            metadata["in_reply_to"] = upstream_in_reply_to
        if upstream_in_reply_to and upstream_in_reply_to != metadata.get("in_reply_to"):
            metadata["origin_in_reply_to"] = upstream_in_reply_to
        if message_kind:
            metadata["message_kind"] = message_kind
        elif progress:
            metadata["_progress"] = True
        if tool_hint:
            metadata["_tool_hint"] = True
        return metadata

    @staticmethod
    def _strip_think(text: str | None) -> str | None:
        """Remove <think>…</think> blocks that some models embed in content."""
        if not text:
            return None
        return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None

    @staticmethod
    def _tool_hint(tool_calls: list) -> str:
        """Format tool calls as concise hint, e.g. 'web_search("query")'."""
        def _fmt(tc):
            args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {}
            val = next(iter(args.values()), None) if isinstance(args, dict) else None
            if not isinstance(val, str):
                return tc.name
            return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
        return ", ".join(_fmt(tc) for tc in tool_calls)

    @classmethod
    def _visible_reasoning(cls, response: Any) -> str | None:
        return cls._strip_think(getattr(response, "reasoning_content", None))

    @staticmethod
    def _format_investigation_context(findings: InvestigationFindings) -> str:
        """Render pre-collected investigation findings for the main prompt."""
        parts = ["Pre-collected findings from a bounded investigation pass:"]
        parts.append(findings.summary.strip())
        evidence_lines = findings.evidence.summary_lines(limit=3)
        if evidence_lines:
            parts.append("")
            parts.append("Collected evidence:")
            parts.extend(f"- {line}" for line in evidence_lines)
        return "\n".join(part for part in parts if part).strip()

    async def _run_pre_investigation(
        self,
        *,
        message: str,
        profile: InvestigationProfile,
    ) -> InvestigationFindings | None:
        """Use a bounded subagent pass to gather context for broad investigative turns."""
        if self._env_flag("MADPOD_AGENT_DISABLE_PRE_INVESTIGATION"):
            return None
        if not should_auto_investigate(profile, message):
            return None
        try:
            return await self.subagents.investigate(message)
        except Exception as exc:
            logger.warning("Pre-investigation subagent failed: {}", exc)
            return None

    def _read_todo_md(self) -> str | None:
        """Read workspace/todo.md for attention injection (Manus-style)."""
        todo_path = self.workspace / "todo.md"
        if not todo_path.exists():
            return None
        try:
            content = todo_path.read_text(encoding="utf-8").strip()
            return content if content else None
        except Exception:
            return None

    async def _run_agent_loop(
        self,
        initial_messages: list[dict],
        on_progress: Callable[..., Awaitable[None]] | None = None,
        investigation_profile: InvestigationProfile | None = None,
        evidence: EvidenceLog | None = None,
        role_tag: str | None = None,
        task_type: str | None = None,
    ) -> tuple[str | None, list[str], list[dict], EvidenceLog]:
        """Run the agent iteration loop. Returns (final_content, tools_used, messages, evidence)."""
        messages = initial_messages
        iteration = 0
        final_content = None
        tools_used: list[str] = []
        evidence_log = evidence or EvidenceLog()
        forced_tool_retry = False
        verification_retry = False

        # Resolve tool filter based on role and task type
        tool_filter = get_tools_for_context(
            role_tag=role_tag,
            task_type=task_type,
            all_tool_names=self.tools.tool_names,
        )
        available_tool_names = sorted(tool_filter) if tool_filter is not None else sorted(self.tools.tool_names)
        tool_definitions = self.tools.get_filtered_definitions(include_names=tool_filter)

        while iteration < self.max_iterations:
            iteration += 1

            response = await self.provider.chat(
                messages=messages,
                tools=tool_definitions,
                model=self.model,
                temperature=self.temperature,
                max_tokens=self.max_tokens,
                reasoning_effort=self.reasoning_effort,
            )

            if response.has_tool_calls:
                if on_progress:
                    reasoning = self._visible_reasoning(response)
                    if reasoning:
                        await on_progress(reasoning)
                    thought = self._strip_think(response.content)
                    if thought and thought != reasoning:
                        await on_progress(thought)
                    await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)

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

                for tool_call in response.tool_calls:
                    args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
                    logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
                    if not self.tools.has(tool_call.name):
                        messages = self.context.add_tool_result(
                            messages,
                            tool_call.id,
                            tool_call.name,
                            (
                                f"Error: Tool '{tool_call.name}' not found. "
                                f"Use one of these exact tools instead: {', '.join(available_tool_names)}"
                            ),
                        )
                        messages.append(
                            {
                                "role": "user",
                                "content": (
                                    "[Runtime enforcement]\n"
                                    f"The tool '{tool_call.name}' is not available. "
                                    "Do not invent tool names.\n"
                                    f"Your next response must call one of these exact tools: {', '.join(available_tool_names)}"
                                ),
                            }
                        )
                        continue

                    tools_used.append(tool_call.name)
                    result = await self.tools.execute(tool_call.name, tool_call.arguments)
                    record_tool_event(
                        evidence_log,
                        tool_name=tool_call.name,
                        arguments=tool_call.arguments,
                        result=result,
                    )
                    messages = self.context.add_tool_result(
                        messages, tool_call.id, tool_call.name, result
                    )
                    if tool_call.name == "message":
                        message_tool = self.tools.get("message")
                        if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
                            return None, tools_used, messages, evidence_log

                # --- Todo.md attention hack (Phase 2) ---
                # Inject current todo.md at context tail after tool calls
                # to exploit LLM recency bias and keep agent focused
                if iteration % 3 == 0 or iteration <= 2:
                    todo_content = self._read_todo_md()
                    if todo_content:
                        messages.append({
                            "role": "user",
                            "content": (
                                "<current_todo>\n"
                                f"{todo_content}\n"
                                "</current_todo>\n"
                                "Continue with the next uncompleted item in your todo list."
                            ),
                        })
            else:
                clean = self._strip_think(response.content)
                if investigation_profile and investigation_profile.is_investigative:
                    if not tools_used and not evidence_log.tool_summaries and not forced_tool_retry:
                        forced_tool_retry = True
                        messages.append({
                            "role": "user",
                            "content": build_retry_instruction(
                                investigation_profile,
                                missing=["gather concrete evidence before answering"],
                                evidence=evidence_log,
                            ),
                        })
                        continue

                    verified, missing = verify_investigation(investigation_profile, evidence_log)
                    if not verified and not verification_retry:
                        verification_retry = True
                        messages.append({
                            "role": "user",
                            "content": build_retry_instruction(
                                investigation_profile,
                                missing=missing,
                                evidence=evidence_log,
                            ),
                        })
                        continue
                    if not verified:
                        clean = build_provisional_response(
                            clean,
                            missing=missing,
                            evidence=evidence_log,
                        )

                if on_progress:
                    reasoning = self._visible_reasoning(response)
                    if reasoning:
                        await on_progress(reasoning)
                    elif clean and iteration > 1:
                        await on_progress(clean)
                # Don't persist error responses to session history — they can
                # poison the context and cause permanent 400 loops (#1303).
                if response.finish_reason == "error":
                    logger.error("LLM returned error: {}", (clean or "")[:200])
                    final_content = clean or "Sorry, I encountered an error calling the AI model."
                    break
                messages = self.context.add_assistant_message(
                    messages, clean, reasoning_content=response.reasoning_content,
                    thinking_blocks=response.thinking_blocks,
                )
                final_content = clean
                break

        if final_content is None and iteration >= self.max_iterations:
            logger.warning("Max iterations ({}) reached", self.max_iterations)
            final_content = (
                f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
                "without completing the task. You can try breaking the task into smaller steps."
            )

        return final_content, tools_used, messages, evidence_log

    async def run(self) -> None:
        """Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
        self._running = True
        ensure_memory_structure(self.workspace)
        await self._connect_mcp()
        logger.info("Agent loop started")

        while self._running:
            try:
                msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
            except asyncio.TimeoutError:
                continue

            if msg.content.strip().lower() == "/stop":
                await self._handle_stop(msg)
            else:
                task = asyncio.create_task(self._dispatch(msg))
                self._active_tasks.setdefault(msg.session_key, []).append(task)
                task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)

    async def _handle_stop(self, msg: InboundMessage) -> None:
        """Cancel all active tasks and subagents for the session."""
        tasks = self._active_tasks.pop(msg.session_key, [])
        cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
        for t in tasks:
            try:
                await t
            except (asyncio.CancelledError, Exception):
                pass
        sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
        consolidation_cancelled = await self._cancel_consolidation_tasks_for_session(msg.session_key)
        total = cancelled + sub_cancelled + consolidation_cancelled
        content = f"⏹ Stopped {total} task(s)." if total else "No active task to stop."
        await self.bus.publish_outbound(OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id, content=content,
        ))

    async def _dispatch(self, msg: InboundMessage) -> None:
        """Process a message under the session lock."""
        async with self._get_session_processing_lock(msg.session_key):
            try:
                response = await self._process_message(msg)
                if response is not None:
                    await self.bus.publish_outbound(response)
                elif msg.channel == "cli":
                    await self.bus.publish_outbound(OutboundMessage(
                        channel=msg.channel, chat_id=msg.chat_id,
                        content="", metadata=msg.metadata or {},
                    ))
            except asyncio.CancelledError:
                logger.info("Task cancelled for session {}", msg.session_key)
                raise
            except Exception:
                logger.exception("Error processing message for session {}", msg.session_key)
                await self.bus.publish_outbound(OutboundMessage(
                    channel=msg.channel, chat_id=msg.chat_id,
                    content="Sorry, I encountered an error.",
                ))

    async def close_mcp(self) -> None:
        """Close MCP connections."""
        if self._mcp_stack:
            try:
                await self._mcp_stack.aclose()
            except (RuntimeError, BaseExceptionGroup):
                pass  # MCP SDK cancel scope cleanup is noisy but harmless
            self._mcp_stack = None

    def stop(self) -> None:
        """Stop the agent loop."""
        self._running = False
        logger.info("Agent loop stopping")

    async def _process_message(
        self,
        msg: InboundMessage,
        session_key: str | None = None,
        on_progress: Callable[[str], Awaitable[None]] | None = None,
    ) -> OutboundMessage | None:
        """Process a single inbound message and return the response."""
        # System messages: parse origin from chat_id ("channel:chat_id")
        if msg.channel == "system":
            channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
                                else ("cli", msg.chat_id))
            logger.info("Processing system message from {}", msg.sender_id)
            key = session_key or msg.session_key
            session = self.sessions.get_or_create(key)
            self._set_tool_context(
                channel,
                chat_id,
                msg.metadata.get("message_id"),
                msg.metadata,
                session_key=key,
            )
            if message_tool := self.tools.get("message"):
                if isinstance(message_tool, MessageTool):
                    message_tool.start_turn()
            history = session.get_history(max_messages=self.memory_window)
            messages = self.context.build_messages(
                history=history,
                current_message=msg.content, channel=channel, chat_id=chat_id,
            )
            final_content, _, all_msgs, _ = await self._run_agent_loop(messages)
            final_message_kind: str | None = None
            if final_content is None:
                final_content = "Background task completed."
                final_message_kind = "intervention"
            else:
                final_message_kind = "deliverable"
            self._save_turn(session, all_msgs, 1 + len(history))
            self.sessions.save(session)
            if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
                return None
            return OutboundMessage(
                channel=channel,
                chat_id=chat_id,
                content=final_content,
                metadata=self._build_correlated_outbound_metadata(
                    msg.metadata,
                    progress=False,
                    message_kind=final_message_kind,
                ),
            )

        preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
        logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)

        key = session_key or msg.session_key
        session = self.sessions.get_or_create(key)

        # Slash commands
        cmd = msg.content.strip().lower()
        if cmd == "/new":
            lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())
            self._consolidating.add(session.key)
            try:
                async with lock:
                    snapshot = session.messages[session.last_consolidated:]
                    if snapshot:
                        temp = Session(key=session.key)
                        temp.messages = list(snapshot)
                        if not await self._consolidate_memory(temp, archive_all=True):
                            return OutboundMessage(
                                channel=msg.channel, chat_id=msg.chat_id,
                                content="Memory archival failed, session not cleared. Please try again.",
                            )
            except Exception:
                logger.exception("/new archival failed for {}", session.key)
                return OutboundMessage(
                    channel=msg.channel, chat_id=msg.chat_id,
                    content="Memory archival failed, session not cleared. Please try again.",
                )
            finally:
                self._consolidating.discard(session.key)

            session.clear()
            self.sessions.save(session)
            self.sessions.invalidate(session.key)
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
                                  content="New session started.")
        if cmd == "/help":
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
                                  content="🐈 madpod-agent commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")

        unconsolidated = len(session.messages) - session.last_consolidated
        if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
            self._consolidating.add(session.key)
            lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())

            async def _consolidate_and_unlock():
                try:
                    async with lock:
                        await self._consolidate_memory(session)
                finally:
                    self._consolidating.discard(session.key)
                    _task = asyncio.current_task()
                    if _task is not None:
                        self._consolidation_tasks.discard(_task)
                        if tasks := self._consolidation_tasks_by_session.get(session.key):
                            tasks.discard(_task)
                            if not tasks:
                                self._consolidation_tasks_by_session.pop(session.key, None)

            _task = asyncio.create_task(_consolidate_and_unlock())
            self._track_consolidation_task(session.key, _task)

        self._set_tool_context(
            msg.channel,
            msg.chat_id,
            msg.metadata.get("message_id"),
            msg.metadata,
            session_key=key,
        )
        if message_tool := self.tools.get("message"):
            if isinstance(message_tool, MessageTool):
                message_tool.start_turn()

        history = session.get_history(max_messages=self.memory_window)
        investigation_profile = classify_request(msg.content)
        pre_investigation = await self._run_pre_investigation(
            message=msg.content,
            profile=investigation_profile,
        )
        turn_context = (
            self._format_investigation_context(pre_investigation)
            if pre_investigation is not None
            else None
        )
        # Extract objectives from directive metadata for lost-in-the-middle fix
        _meta = msg.metadata or {}
        _obj_task = str(_meta.get("task_summary") or _meta.get("task_brief") or "").strip() or None
        _obj_goal = str(_meta.get("round_goal") or _meta.get("goal") or "").strip() or None
        _obj_raw = _meta.get("remaining_items") or _meta.get("expected_output") or _meta.get("acceptance_criteria")
        if isinstance(_obj_raw, list):
            _obj_remaining = [str(i).strip() for i in _obj_raw if str(i).strip()] or None
        elif isinstance(_obj_raw, str) and _obj_raw.strip():
            _obj_remaining = [_obj_raw.strip()]
        else:
            _obj_remaining = None

        initial_messages = self.context.build_messages(
            history=history,
            current_message=msg.content,
            media=msg.media if msg.media else None,
            channel=msg.channel,
            chat_id=msg.chat_id,
            execution_policy=build_turn_policy(investigation_profile),
            turn_context=turn_context,
            task_summary=_obj_task,
            round_goal=_obj_goal,
            remaining_items=_obj_remaining,
        )

        async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
            if msg.channel == "madpod" and not tool_hint:
                return
            meta = self._build_correlated_outbound_metadata(
                msg.metadata,
                progress=msg.channel != "madpod",
                message_kind="activity" if msg.channel == "madpod" else None,
                tool_hint=tool_hint,
            )
            if msg.channel == "madpod":
                meta["action"] = "tool_hint" if tool_hint else "status_update"
                meta["summary"] = content
                if not tool_hint:
                    meta["thought"] = content
            await self.bus.publish_outbound(OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
            ))

        final_content, _, all_msgs, _ = await self._run_agent_loop(
            initial_messages,
            on_progress=on_progress or _bus_progress,
            investigation_profile=investigation_profile,
            evidence=pre_investigation.evidence if pre_investigation is not None else None,
        )

        final_message_kind: str | None = None
        if final_content is None:
            final_content = "I could not produce a usable deliverable for this subtask."
            if msg.channel == "madpod":
                final_message_kind = "intervention"
        elif msg.channel == "madpod":
            final_message_kind = "deliverable"

        self._save_turn(session, all_msgs, 1 + len(history))
        self.sessions.save(session)

        if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
            return None

        preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
        logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
        return OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id, content=final_content,
            metadata=self._build_correlated_outbound_metadata(
                msg.metadata,
                progress=False,
                message_kind=final_message_kind,
            ),
        )

    def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
        """Save new-turn messages into session, truncating large tool results."""
        from datetime import datetime
        for m in messages[skip:]:
            entry = dict(m)
            role, content = entry.get("role"), entry.get("content")
            if role == "assistant" and not content and not entry.get("tool_calls"):
                continue  # skip empty assistant messages — they poison session context
            if role == "tool" and isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS:
                entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
            elif role == "user":
                if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
                    # Strip the runtime-context prefix, keep only the user text.
                    parts = content.split("\n\n", 1)
                    if len(parts) > 1 and parts[1].strip():
                        entry["content"] = parts[1]
                    else:
                        continue
                if isinstance(content, list):
                    filtered = []
                    for c in content:
                        if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
                            continue  # Strip runtime context from multimodal messages
                        if (c.get("type") == "image_url"
                                and c.get("image_url", {}).get("url", "").startswith("data:image/")):
                            filtered.append({"type": "text", "text": "[image]"})
                        else:
                            filtered.append(c)
                    if not filtered:
                        continue
                    entry["content"] = filtered
            entry.setdefault("timestamp", datetime.now().isoformat())
            session.messages.append(entry)
        session.updated_at = datetime.now()

    async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
        """Delegate to MemoryStore.consolidate(). Returns True on success."""
        return await MemoryStore(self.workspace).consolidate(
            session, self.provider, self.model,
            archive_all=archive_all, memory_window=self.memory_window,
        )

    async def process_direct(
        self,
        content: str,
        session_key: str = "cli:direct",
        channel: str = "cli",
        chat_id: str = "direct",
        on_progress: Callable[[str], Awaitable[None]] | None = None,
    ) -> str:
        """Process a message directly (for CLI or cron usage)."""
        await self._connect_mcp()
        msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
        response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
        return response.content if response else ""
