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

import asyncio
from contextlib import AsyncExitStack
import json
import json_repair
from pathlib import Path
from typing import Any

from loguru import logger

from miu_bot.bus.events import InboundMessage, OutboundMessage
from miu_bot.bus.queue import MessageBus
from miu_bot.providers.base import LLMProvider
from miu_bot.agent.context import ContextBuilder
from miu_bot.agent.tools.registry import ToolRegistry
from miu_bot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from miu_bot.agent.tools.shell import ExecTool
from miu_bot.agent.tools.web import WebSearchTool, WebFetchTool
from miu_bot.agent.tools.message import MessageTool
from miu_bot.agent.tools.spawn import SpawnTool
from miu_bot.agent.tools.cron import CronTool
from miu_bot.agent.tools.zalo import ZaloTool
from miu_bot.agent.memory import MemoryStore
from miu_bot.agent.subagent import SubagentManager
from miu_bot.session.manager import Session, SessionManager


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
    """

    def __init__(
        self,
        bus: MessageBus,
        provider: LLMProvider,
        workspace: Path,
        model: str | None = None,
        max_iterations: int = 20,
        temperature: float = 0.7,
        max_tokens: int = 4096,
        memory_window: int = 50,
        brave_api_key: str | 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,
        claude_code_config: "ClaudeCodeConfig | None" = None,
        backend: "MemoryBackend | None" = None,
        resolver: "WorkspaceResolver | None" = None,
    ):
        from miu_bot.config.schema import ExecToolConfig, ClaudeCodeConfig
        from miu_bot.cron.service import CronService
        self.bus = bus
        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.brave_api_key = brave_api_key
        self.exec_config = exec_config or ExecToolConfig()
        self.cron_service = cron_service
        self.restrict_to_workspace = restrict_to_workspace
        self.claude_code_config = claude_code_config or ClaudeCodeConfig()

        self.context = ContextBuilder(workspace)
        self.sessions = session_manager or SessionManager(workspace)
        self.tools = ToolRegistry()
        self.subagents = SubagentManager(
            provider=provider,
            workspace=workspace,
            bus=bus,
            model=self.model,
            temperature=self.temperature,
            max_tokens=self.max_tokens,
            brave_api_key=brave_api_key,
            exec_config=self.exec_config,
            restrict_to_workspace=restrict_to_workspace,
            claude_code_config=self.claude_code_config,
        )
        
        self.backend = backend
        self.resolver = resolver

        self._running = False
        self._mcp_servers = mcp_servers or {}
        self._mcp_stack: AsyncExitStack | None = None
        self._mcp_connected = False
        # Per-session processing: one concurrent task per session key
        self._session_tasks: dict[str, asyncio.Task] = {}
        self._session_queues: dict[str, asyncio.Queue] = {}
        # Per-session locks for tool context — shared tools have mutable routing state
        self._session_locks: dict[str, asyncio.Lock] = {}
        self._register_default_tools()

    def _get_session_lock(self, session_key: str) -> asyncio.Lock:
        """Get or create a per-session lock for tool context."""
        if session_key not in self._session_locks:
            self._session_locks[session_key] = asyncio.Lock()
        return self._session_locks[session_key]

    def _register_default_tools(self) -> None:
        """Register the default set of tools."""
        # File tools (restrict to workspace if configured)
        allowed_dir = self.workspace if self.restrict_to_workspace else None
        self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
        self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
        self.tools.register(EditFileTool(allowed_dir=allowed_dir))
        self.tools.register(ListDirTool(allowed_dir=allowed_dir))
        
        # Shell tool
        self.tools.register(ExecTool(
            working_dir=str(self.workspace),
            timeout=self.exec_config.timeout,
            restrict_to_workspace=self.restrict_to_workspace,
        ))
        
        # Web tools (only register search if API key is available)
        if self.brave_api_key:
            self.tools.register(WebSearchTool(api_key=self.brave_api_key))
        self.tools.register(WebFetchTool())
        
        # Message tool
        message_tool = MessageTool(send_callback=self.bus.publish_outbound)
        self.tools.register(message_tool)
        
        # Spawn tool (for subagents)
        spawn_tool = SpawnTool(manager=self.subagents)
        self.tools.register(spawn_tool)
        
        # Cron tool (for scheduling)
        if self.cron_service:
            self.tools.register(CronTool(self.cron_service))

        # Claude Code tool
        if self.claude_code_config.enabled:
            from miu_bot.agent.tools.claude_code import ClaudeCodeTool
            self.tools.register(ClaudeCodeTool(
                config=self.claude_code_config,
                workspace=str(self.workspace),
            ))
    
    async def _connect_mcp(self) -> None:
        """Connect to configured MCP servers (one-time, lazy)."""
        if self._mcp_connected or not self._mcp_servers:
            return
        self._mcp_connected = True
        from miu_bot.agent.tools.mcp import connect_mcp_servers
        self._mcp_stack = AsyncExitStack()
        await self._mcp_stack.__aenter__()
        await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)

    def _set_tool_context(self, channel: str, chat_id: str) -> None:
        """Update context for all tools that need routing info."""
        if message_tool := self.tools.get("message"):
            if isinstance(message_tool, MessageTool):
                message_tool.set_context(channel, chat_id)

        if spawn_tool := self.tools.get("spawn"):
            if isinstance(spawn_tool, SpawnTool):
                spawn_tool.set_context(channel, chat_id)

        if cron_tool := self.tools.get("cron"):
            if isinstance(cron_tool, CronTool):
                cron_tool.set_context(channel, chat_id)

        if zalo_tool := self.tools.get("zalo"):
            if isinstance(zalo_tool, ZaloTool):
                zalo_tool.set_context(channel, chat_id)

    async def _run_agent_loop(
        self, initial_messages: list[dict], channel: str = "",
        chat_id: str = "", session_key: str = "",
    ) -> tuple[str | None, list[str]]:
        """
        Run the agent iteration loop.

        Args:
            initial_messages: Starting messages for the LLM conversation.
            channel: Source channel for tool routing context.
            chat_id: Source chat ID for tool routing context.

        Returns:
            Tuple of (final_content, list_of_tools_used).
        """
        messages = initial_messages
        iteration = 0
        final_content = None
        tools_used: list[str] = []

        while iteration < self.max_iterations:
            iteration += 1

            try:
                response = await asyncio.wait_for(
                    self.provider.chat(
                        messages=messages,
                        tools=self.tools.get_definitions(),
                        model=self.model,
                        temperature=self.temperature,
                        max_tokens=self.max_tokens,
                    ),
                    timeout=180,
                )
            except asyncio.TimeoutError:
                logger.warning("LLM call timed out after 180s")
                final_content = "Sorry, the response took too long. Please try again."
                break

            # Log LLM response metadata
            if response.usage:
                logger.debug(f"LLM usage: {response.usage}")
            logger.debug(f"LLM finish_reason: {response.finish_reason}")
            if response.reasoning_content:
                preview = response.reasoning_content[:500]
                logger.debug(f"LLM reasoning: {preview}")

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

                # Per-session lock to prevent race on mutable tool routing state
                lock = self._get_session_lock(session_key or f"{channel}:{chat_id}")
                async with lock:
                    self._set_tool_context(channel, chat_id)
                    for tool_call in response.tool_calls:
                        tools_used.append(tool_call.name)
                        args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
                        logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
                        try:
                            result = await self.tools.execute(tool_call.name, tool_call.arguments)
                        except asyncio.CancelledError:
                            logger.warning(f"Tool '{tool_call.name}' cancelled")
                            raise  # Propagate so session worker can handle cleanup
                        except Exception as e:
                            logger.warning(f"Tool '{tool_call.name}' failed: {e}")
                            result = f"Error: tool execution failed: {e}"
                        result_preview = result[:300] if result else "(empty)"
                        logger.debug(f"Tool result [{tool_call.name}]: {result_preview}")
                        messages = self.context.add_tool_result(
                            messages, tool_call.id, tool_call.name, result
                        )
                messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
            else:
                final_content = response.content
                break

        return final_content, tools_used

    async def run(self) -> None:
        """Run the agent loop, dispatching messages to per-session workers."""
        self._running = True
        await self._connect_mcp()
        logger.info("Agent loop started (concurrent per-session)")

        try:
            while self._running:
                try:
                    msg = await asyncio.wait_for(
                        self.bus.consume_inbound(),
                        timeout=1.0
                    )
                except asyncio.TimeoutError:
                    # Clean up finished session workers
                    for k in list(self._session_tasks):
                        if self._session_tasks[k].done():
                            del self._session_tasks[k]
                            del self._session_queues[k]
                    continue
                except asyncio.CancelledError:
                    # Always honour cancellation — swallowing it causes infinite loops
                    raise

                key = msg.session_key
                # Create per-session queue and worker if needed (or recreate if stale)
                if key not in self._session_tasks or self._session_tasks[key].done():
                    self._session_queues[key] = asyncio.Queue()
                    self._session_tasks[key] = asyncio.create_task(
                        self._session_worker(key)
                    )
                await self._session_queues[key].put(msg)
        except asyncio.CancelledError:
            logger.info("Agent loop cancelled (shutdown)")
        finally:
            # Ensure cleanup: give in-progress workers a grace period, then stop
            self._running = False
            active = [t for t in self._session_tasks.values() if not t.done()]
            if active:
                logger.info(f"Waiting for {len(active)} session workers to finish (30s grace)...")
                _, pending = await asyncio.wait(active, timeout=30)
                for t in pending:
                    t.cancel()
            self._session_tasks.clear()
            self._session_queues.clear()
            logger.info("Agent loop stopped")

    async def _session_worker(self, key: str) -> None:
        """Process messages for a single session sequentially."""
        queue = self._session_queues[key]
        idle_timeout = 300  # Clean up worker after 5 min idle
        try:
            while self._running:
                try:
                    msg = await asyncio.wait_for(queue.get(), timeout=idle_timeout)
                except asyncio.TimeoutError:
                    logger.debug(f"Session worker '{key}' idle, stopping")
                    break
                except asyncio.CancelledError:
                    break
                try:
                    response = await self._process_message(msg)
                    if response:
                        await self.bus.publish_outbound(response)
                except asyncio.CancelledError:
                    logger.info(f"Session '{key}' processing cancelled")
                    try:
                        await asyncio.shield(self.bus.publish_outbound(OutboundMessage(
                            channel=msg.channel,
                            chat_id=msg.chat_id,
                            content="Sorry, my previous processing was interrupted. Please send your message again.",
                            metadata=msg.metadata or {},
                        )))
                    except (Exception, asyncio.CancelledError):
                        pass
                    break
                except Exception as e:
                    logger.error(f"Error processing message in session '{key}': {e}")
                    short_error = str(e).split('\n')[0][:200]
                    await self.bus.publish_outbound(OutboundMessage(
                        channel=msg.channel,
                        chat_id=msg.chat_id,
                        content=f"Sorry, I encountered an error: {short_error}",
                        metadata=msg.metadata or {},
                    ))
        finally:
            # Clean up per-session lock when worker exits
            self._session_locks.pop(key, None)
    
    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 and all session workers."""
        self._running = False
        for task in self._session_tasks.values():
            task.cancel()
        self._session_tasks.clear()
        self._session_queues.clear()
        self._session_locks.clear()
        logger.info("Agent loop stopping")
    
    async def _process_multitenant(self, msg: InboundMessage) -> OutboundMessage | None:
        """Process message in multi-tenant mode using MemoryBackend."""
        from miu_bot.workspace.identity import parse_identity, render_system_prompt

        content = msg.content if isinstance(msg.content, str) else str(msg.content)

        # Resolve workspace
        workspace_id = msg.workspace_id
        if not workspace_id and self.resolver:
            workspace_id = await self.resolver.resolve(msg.channel, msg.chat_id)
        if not workspace_id:
            logger.warning(f"No workspace for {msg.channel}:{msg.chat_id} — dropping message")
            return None

        ws = await self.backend.get_workspace(workspace_id)
        if not ws or ws.status != "active":
            logger.warning(f"Workspace {workspace_id} inactive — skipping")
            return None

        # Session
        session = await self.backend.get_or_create_session(workspace_id, msg.channel, msg.chat_id)

        # Observe-only
        if msg.observe_only:
            sender_name = (msg.metadata or {}).get("sender_name", msg.sender_id)
            await self.backend.save_message(
                session.id, "user", f"[{sender_name} (userId: {msg.sender_id})]: {content}"
            )
            return None

        # Slash commands
        cmd = content.strip().lower()
        if cmd == "/new":
            return OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id,
                content="New session started.", metadata=msg.metadata or {},
            )
        if cmd == "/help":
            return OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id,
                content="miu_bot commands:\n/new — Start a new conversation\n/help — Show commands",
                metadata=msg.metadata or {},
            )

        # Load context
        history_msgs = await self.backend.get_messages(session.id, limit=self.memory_window)
        history = [{"role": m.role, "content": m.content} for m in history_msgs]
        memories_list = await self.backend.get_memories(workspace_id)
        memories_text = "\n".join(m.content for m in memories_list)

        identity = parse_identity(ws.identity)

        # Group message prefix
        is_group = (msg.metadata or {}).get("is_group", False)
        if is_group:
            sender_name = (msg.metadata or {}).get("sender_name", msg.sender_id)
            current_message = f"[{sender_name} (userId: {msg.sender_id})]: {content}"
        else:
            current_message = content

        initial_messages = self.context.build_workspace_messages(
            identity=identity, memories=memories_text, history=history,
            current_message=current_message, channel=msg.channel, chat_id=msg.chat_id,
            media=msg.media if msg.media else None,
        )

        final_content, tools_used = await self._run_agent_loop(
            initial_messages, channel=msg.channel, chat_id=msg.chat_id,
            session_key=f"{workspace_id}:{msg.channel}:{msg.chat_id}",
        )

        if final_content is None:
            final_content = "I've completed processing but have no response to give."

        # Save to backend
        await self.backend.save_message(session.id, "user", current_message, msg.metadata)
        await self.backend.save_message(
            session.id, "assistant", final_content,
            {"tools_used": tools_used} if tools_used else None,
        )

        return OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id,
            content=final_content, metadata=msg.metadata or {},
        )

    async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
        """
        Process a single inbound message.

        Args:
            msg: The inbound message to process.
            session_key: Override session key (used by process_direct).

        Returns:
            The response message, or None if no response needed.
        """
        # Multi-tenant mode: delegate to backend-aware processing
        if self.backend and not session_key:
            return await self._process_multitenant(msg)

        # System messages route back via chat_id ("channel:chat_id")
        if msg.channel == "system":
            return await self._process_system_message(msg)

        # Ensure content is a string (defensive against non-string content)
        content = msg.content if isinstance(msg.content, str) else str(msg.content)
        
        preview = content[:80] + "..." if len(content) > 80 else content
        logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")

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

        # Observe-only: add to history for context but don't respond
        if msg.observe_only:
            sender_name = (msg.metadata or {}).get("sender_name", msg.sender_id)
            session.add_message("user", f"[{sender_name} (userId: {msg.sender_id})]: {content}")
            self.sessions.save(session)
            logger.debug(f"Observed message from {sender_name} (no response)")
            return None

        # Handle slash commands
        cmd = content.strip().lower()
        if cmd == "/new":
            # Capture messages before clearing (avoid race condition with background task)
            messages_to_archive = session.messages.copy()
            session.clear()
            self.sessions.save(session)
            self.sessions.invalidate(session.key)

            async def _consolidate_and_cleanup():
                temp_session = Session(key=session.key)
                temp_session.messages = messages_to_archive
                await self._consolidate_memory(temp_session, archive_all=True)

            asyncio.create_task(_consolidate_and_cleanup())
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
                                  content="New session started. Memory consolidation in progress.",
                                  metadata=msg.metadata or {})
        if cmd == "/help":
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
                                  content="🐈 miu_bot commands:\n/new — Start a new conversation\n/help — Show available commands",
                                  metadata=msg.metadata or {})
        
        if len(session.messages) > self.memory_window:
            asyncio.create_task(self._consolidate_memory(session))

        # In groups, prefix message with sender identity so LLM knows who's talking
        is_group = (msg.metadata or {}).get("is_group", False)
        if is_group:
            sender_name = (msg.metadata or {}).get("sender_name", msg.sender_id)
            current_message = f"[{sender_name} (userId: {msg.sender_id})]: {content}"
        else:
            current_message = content

        initial_messages = self.context.build_messages(
            history=session.get_history(max_messages=self.memory_window),
            current_message=current_message,
            media=msg.media if msg.media else None,
            channel=msg.channel,
            chat_id=msg.chat_id,
        )
        final_content, tools_used = await self._run_agent_loop(
            initial_messages, channel=msg.channel, chat_id=msg.chat_id,
            session_key=key,
        )

        if final_content is None:
            final_content = "I've completed processing but have no response to give."

        preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
        logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
        
        session.add_message("user", current_message)
        session.add_message("assistant", final_content,
                            tools_used=tools_used if tools_used else None)
        self.sessions.save(session)
        
        return OutboundMessage(
            channel=msg.channel,
            chat_id=msg.chat_id,
            content=final_content,
            metadata=msg.metadata or {},  # Pass through for channel-specific needs (e.g. Slack thread_ts)
        )
    
    async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
        """
        Process a system message (e.g., subagent announce).
        
        The chat_id field contains "original_channel:original_chat_id" to route
        the response back to the correct destination.
        """
        logger.info(f"Processing system message from {msg.sender_id}")
        
        # Parse origin from chat_id (format: "channel:chat_id")
        if ":" in msg.chat_id:
            parts = msg.chat_id.split(":", 1)
            origin_channel = parts[0]
            origin_chat_id = parts[1]
        else:
            # Fallback
            origin_channel = "cli"
            origin_chat_id = msg.chat_id
        
        session_key = f"{origin_channel}:{origin_chat_id}"
        session = self.sessions.get_or_create(session_key)
        initial_messages = self.context.build_messages(
            history=session.get_history(max_messages=self.memory_window),
            current_message=msg.content,
            channel=origin_channel,
            chat_id=origin_chat_id,
        )
        final_content, _ = await self._run_agent_loop(
            initial_messages, channel=origin_channel, chat_id=origin_chat_id,
            session_key=session_key,
        )

        if final_content is None:
            final_content = "Background task completed."
        
        session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
        session.add_message("assistant", final_content)
        self.sessions.save(session)
        
        return OutboundMessage(
            channel=origin_channel,
            chat_id=origin_chat_id,
            content=final_content
        )
    
    async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
        """Consolidate old messages into MEMORY.md + HISTORY.md.

        Args:
            archive_all: If True, clear all messages and reset session (for /new command).
                       If False, only write to files without modifying session.
        """
        memory = MemoryStore(self.workspace)

        if archive_all:
            old_messages = session.messages
            keep_count = 0
            logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived")
        else:
            keep_count = self.memory_window // 2
            if len(session.messages) <= keep_count:
                logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})")
                return

            messages_to_process = len(session.messages) - session.last_consolidated
            if messages_to_process <= 0:
                logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})")
                return

            old_messages = session.messages[session.last_consolidated:-keep_count]
            if not old_messages:
                return
            logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep")

        lines = []
        for m in old_messages:
            if not m.get("content"):
                continue
            tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
            lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
        conversation = "\n".join(lines)
        current_memory = memory.read_long_term()

        prompt = f"""You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys:

1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.

2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.

## Current Long-term Memory
{current_memory or "(empty)"}

## Conversation to Process
{conversation}

Respond with ONLY valid JSON, no markdown fences."""

        try:
            response = await self.provider.chat(
                messages=[
                    {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
                    {"role": "user", "content": prompt},
                ],
                model=self.model,
            )
            text = (response.content or "").strip()
            if not text:
                logger.warning("Memory consolidation: LLM returned empty response, skipping")
                return
            if text.startswith("```"):
                text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            result = json_repair.loads(text)
            if not isinstance(result, dict):
                logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}")
                return

            if entry := result.get("history_entry"):
                memory.append_history(entry)
            if update := result.get("memory_update"):
                if update != current_memory:
                    memory.write_long_term(update)

            if archive_all:
                session.last_consolidated = 0
            else:
                session.last_consolidated = len(session.messages) - keep_count
            logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}")
        except Exception as e:
            logger.error(f"Memory consolidation failed: {e}")

    async def process_direct(
        self,
        content: str,
        session_key: str = "cli:direct",
        channel: str = "cli",
        chat_id: str = "direct",
    ) -> str:
        """
        Process a message directly (for CLI or cron usage).
        
        Args:
            content: The message content.
            session_key: Session identifier (overrides channel:chat_id for session lookup).
            channel: Source channel (for tool context routing).
            chat_id: Source chat ID (for tool context routing).
        
        Returns:
            The agent's response.
        """
        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)
        return response.content if response else ""
