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

import asyncio
import json
from pathlib import Path
from typing import Any

from loguru import logger

from kyber.bus.events import InboundMessage, OutboundMessage
from kyber.bus.queue import MessageBus
from kyber.providers.base import LLMProvider
from kyber.agent.context import ContextBuilder
from kyber.agent.tools.registry import ToolRegistry
from kyber.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from kyber.agent.tools.shell import ExecTool
from kyber.agent.tools.web import WebSearchTool, WebFetchTool
from kyber.agent.tools.message import MessageTool
from kyber.agent.tools.spawn import SpawnTool, make_start_receipt
from kyber.agent.tools.task_status import TaskStatusTool
from kyber.agent.subagent import SubagentManager
from kyber.session.manager import 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,
        brave_api_key: str | None = None,
        search_max_results: int = 5,
        exec_config: "ExecToolConfig | None" = None,
    ):
        from kyber.config.schema import ExecToolConfig
        self.bus = bus
        self.provider = provider
        self.workspace = workspace
        self.model = model or provider.get_default_model()
        self.max_iterations = max_iterations
        self.brave_api_key = brave_api_key
        self.search_max_results = search_max_results
        self.exec_config = exec_config or ExecToolConfig()
        
        self.context = ContextBuilder(workspace)
        self.sessions = SessionManager(workspace)
        self.tools = ToolRegistry()
        self.subagents = SubagentManager(
            provider=provider,
            workspace=workspace,
            bus=bus,
            model=self.model,
            brave_api_key=brave_api_key,
            exec_config=self.exec_config,
        )
        
        # Let the context builder see active tasks so the LLM knows what's in flight
        self.context.set_task_status_provider(self.subagents.get_all_status)
        
        self._running = False
        self._register_default_tools()
    
    def _register_default_tools(self) -> None:
        """Register the default set of tools."""
        # File tools
        self.tools.register(ReadFileTool())
        self.tools.register(WriteFileTool())
        self.tools.register(EditFileTool())
        self.tools.register(ListDirTool())
        
        # Shell tool
        self.tools.register(ExecTool(
            working_dir=str(self.workspace),
            timeout=self.exec_config.timeout,
            restrict_to_workspace=self.exec_config.restrict_to_workspace,
        ))
        
        # Web tools
        self.tools.register(WebSearchTool(api_key=self.brave_api_key, max_results=self.search_max_results))
        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)

        # Task status tool (instant subagent progress lookup)
        task_status_tool = TaskStatusTool(manager=self.subagents)
        self.tools.register(task_status_tool)


    
    async def run(self) -> None:
        """Run the agent loop, processing messages from the bus.

        Each inbound message is handled in its own asyncio task so the loop
        is never blocked — the user can always send new messages even while
        a long task is in progress.
        """
        self._running = True
        self._active_tasks: set[asyncio.Task[None]] = set()
        logger.info("Agent loop started")

        while self._running:
            try:
                msg = await asyncio.wait_for(
                    self.bus.consume_inbound(),
                    timeout=1.0,
                )
                task = asyncio.create_task(self._handle_message(msg))
                self._active_tasks.add(task)
                task.add_done_callback(self._active_tasks.discard)
            except asyncio.TimeoutError:
                continue

    async def _handle_message(self, msg: InboundMessage) -> None:
        """Handle a single message in its own task (fire-and-forget from run)."""
        try:
            interim_sent = False

            async def _on_first_tool_call() -> None:
                """Fire-and-forget an interim message via a separate LLM call.

                This runs in its own asyncio task so the main agent loop is
                never blocked — it keeps executing tools while the interim
                "second agent" generates and delivers the acknowledgment.
                """
                nonlocal interim_sent
                if interim_sent or msg.channel == "system":
                    return
                interim_sent = True

                # Spawn the interim generation as a detached task so the
                # main agent continues working immediately.
                asyncio.create_task(
                    self._send_interim_message(msg)
                )

            response = await self._process_message(msg, on_first_tool_call=_on_first_tool_call)
            if response:
                await self.bus.publish_outbound(response)
        except Exception as e:
            logger.error(f"Error processing message: {e}")
            await self.bus.publish_outbound(OutboundMessage(
                channel=msg.channel,
                chat_id=msg.chat_id,
                content=f"Sorry, I encountered an error: {str(e)}",
            ))


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

    async def _send_interim_message(self, msg: InboundMessage) -> None:
        """Generate and send an interim acknowledgment in a separate task.

        This acts as a "second agent" — a lightweight, independent LLM call
        that runs concurrently with the main agent loop so the user gets a
        quick acknowledgment without blocking the real work.

        If the LLM call fails, we silently skip — no canned fallback.
        The user will just get the real response when it's ready.
        """
        try:
            meta_prompt = self.context.build_meta_system_prompt()
            interim_messages = [
                {"role": "system", "content": meta_prompt},
                {"role": "user", "content": msg.content},
                {"role": "system", "content": (
                    "The assistant has started working on this request in the background. "
                    "Write a SHORT (1 sentence) in-character acknowledgment that: "
                    "1) briefly references what the user asked for, "
                    "2) lets them know you're working on it in the background, "
                    "3) tells them they can keep chatting or ask for status updates. "
                    "Start with a natural tone. Do NOT use tool calls. Just the message."
                )},
            ]
            interim_response = await self.provider.chat(
                messages=interim_messages,
                tools=None,
                model=self.model,
                max_tokens=150,
                temperature=0.7,
            )
            interim_text = (interim_response.content or "").strip()
            if not interim_text or "error" in interim_text.lower()[:20]:
                logger.warning(f"Interim LLM returned empty/error: {interim_text!r}")
                return
        except Exception as exc:
            logger.warning(f"Interim LLM call failed: {exc}")
            return

        await self.bus.publish_outbound(OutboundMessage(
            channel=msg.channel,
            chat_id=msg.chat_id,
            content=interim_text,
        ))

    
    async def _generate_fallback(self, user_message: str, tool_results: list[str]) -> str:
        """Generate an in-character fallback when the LLM didn't produce a final response.
        
        Uses a lightweight LLM call with the persona prompt to summarize
        what was done, rather than returning a canned message.
        """
        try:
            meta_prompt = self.context.build_meta_system_prompt()
            results_summary = "\n".join(r[:300] for r in tool_results[-3:])
            fallback_messages = [
                {"role": "system", "content": meta_prompt},
                {"role": "user", "content": user_message},
                {"role": "system", "content": (
                    "You completed the user's request using tools. Here are the recent tool results:\n\n"
                    f"{results_summary}\n\n"
                    "Write a SHORT (1-2 sentence) in-character summary of what you did. "
                    "Do NOT use tool calls. Just the message."
                )},
            ]
            response = await self.provider.chat(
                messages=fallback_messages,
                tools=None,
                model=self.model,
                max_tokens=200,
                temperature=0.7,
            )
            text = (response.content or "").strip()
            if text and not text.startswith("Error"):
                return text
        except Exception:
            pass
        return "Done — let me know if you need anything else."

    # Phrases that strongly imply the agent took action.  If the response
    # contains any of these but zero tools were called, the honesty gate
    # kicks in and forces the agent to actually do the work.
    _ACTION_PHRASES = [
        "sending it off", "kicked it off", "started it", "on it",
        "working on it", "in progress", "running now", "deploying",
        "publishing", "building", "installing", "executing",
        "i've started", "i've kicked", "i've begun", "i've launched",
        "i've initiated", "i've queued", "i've triggered",
        "spawned", "subagent", "background task",
    ]

    async def _verify_or_retry(
        self,
        msg: InboundMessage,
        messages: list[dict[str, Any]],
        candidate: str,
    ) -> str:
        """Honesty gate: verify the agent isn't claiming actions it never took.

        If the candidate response contains action-claiming language but no
        tools were executed during this turn, we inject a correction and
        force the model to either use tools or give an honest response.

        Also catches the LLM claiming work is "started" or "in progress"
        without a valid start receipt (⚡ token).

        Returns the (possibly replaced) final content.
        """
        lower = candidate.lower()
        is_claiming_action = any(p in lower for p in self._ACTION_PHRASES)

        if not is_claiming_action:
            return candidate

        # If claiming action but no receipt present, that's a violation
        has_start_receipt = "⚡" in candidate
        if is_claiming_action and has_start_receipt:
            # Has a receipt — the claim is backed up, let it through
            return candidate

        logger.warning(
            "Honesty gate triggered: agent claimed action without tool use or receipt. "
            f"Candidate: {candidate!r}"
        )

        # Re-run with a stern correction appended
        messages.append({"role": "assistant", "content": candidate})
        messages.append({
            "role": "user",
            "content": (
                "STOP. You just claimed you started or completed an action, "
                "but you did NOT call any tools and you have no receipt/reference "
                "string to prove it. You must ACTUALLY call the appropriate tool "
                "(spawn, exec, etc.) to do the work. Do it now — do not just "
                "describe what you would do."
            ),
        })

        # Give the model another chance with tools available
        for _ in range(self.max_iterations):
            response = await self.provider.chat(
                messages=messages,
                tools=self.tools.get_definitions(),
                model=self.model,
            )

            if response.has_tool_calls:
                # Good — it's actually doing work now
                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
                )
                for tool_call in response.tool_calls:
                    result = await self.tools.execute(
                        tool_call.name, tool_call.arguments
                    )
                    messages = self.context.add_tool_result(
                        messages, tool_call.id, tool_call.name, result
                    )
            else:
                text = (response.content or "").strip()
                if text:
                    return text
                break

        # If we got here, generate a fallback
        return await self._generate_fallback(msg.content, [])

    
    def _enforce_start_receipts(
        self,
        content: str,
        receipts: list[str],
    ) -> str:
        """Ensure all issued start receipts appear in the response.

        The LLM is NOT allowed to claim a job has started without the
        corresponding ⚡ receipt string.  If any are missing, we append
        them so the user always gets the reference.
        """
        if not receipts:
            return content

        missing = [r for r in receipts if r not in content]
        if not missing:
            return content

        logger.warning(
            f"Start receipt enforcement: {len(missing)} receipt(s) missing from "
            f"LLM response, appending. Missing: {missing}"
        )
        refs = " ".join(missing)
        return f"{content}\n\nRef: {refs}"

    @staticmethod
    def _extract_completion_receipt(announce_content: str) -> str | None:
        """Extract the ✅ completion receipt from a subagent announce message."""
        for line in announce_content.splitlines():
            stripped = line.strip()
            if stripped.startswith("Completion receipt:"):
                token = stripped.split(":", 1)[1].strip()
                if token.startswith("✅"):
                    return token
        return None

    def _enforce_completion_receipt(
        self,
        content: str,
        completion_receipt: str | None,
    ) -> str:
        """Ensure the completion receipt appears in the response.

        The LLM is NOT allowed to claim a task is done without the ✅
        completion receipt that was generated by the subagent upon actual
        task completion.  If missing, we append it.
        """
        if not completion_receipt:
            return content

        if completion_receipt in content:
            return content

        logger.warning(
            f"Completion receipt enforcement: receipt {completion_receipt} missing "
            f"from LLM response, appending."
        )
        return f"{content}\n\nCompleted: {completion_receipt}"

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

        Every request that requires tool calls is automatically delegated to
        a subagent.  The main agent's job is to:
        1. Determine if tools are needed (first LLM call)
        2. If yes → spawn a subagent, respond with the start receipt
        3. If no  → respond directly (pure conversation)

        When the subagent finishes, it announces back via the bus with a
        completion receipt.  The main agent cannot claim the task is done
        without that receipt.
        
        Args:
            msg: The inbound message to process.
            on_first_tool_call: Optional async callback (kept for API compat, unused now).
        
        Returns:
            The response message, or None if no response needed.
        """
        # Handle system messages (subagent announces)
        if msg.channel == "system":
            return await self._process_system_message(msg)
        
        logger.info(f"Processing message from {msg.channel}:{msg.sender_id}")
        
        # Get or create session
        session = self.sessions.get_or_create(msg.session_key)
        
        # Update tool contexts
        message_tool = self.tools.get("message")
        if isinstance(message_tool, MessageTool):
            message_tool.set_context(msg.channel, msg.chat_id)
        
        spawn_tool = self.tools.get("spawn")
        if isinstance(spawn_tool, SpawnTool):
            spawn_tool.set_context(msg.channel, msg.chat_id)
        
        # Build initial messages
        messages = self.context.build_messages(
            history=session.get_history(),
            current_message=msg.content,
            media=msg.media if msg.media else None,
        )
        
        # ── First LLM call: decide if tools are needed ────────────────
        llm_error_retries = 0
        max_llm_error_retries = 3
        response = None

        while llm_error_retries <= max_llm_error_retries:
            response = await self.provider.chat(
                messages=messages,
                tools=self.tools.get_definitions(),
                model=self.model,
            )
            if response.finish_reason != "error":
                break
            llm_error_retries += 1
            logger.warning(
                f"LLM error (attempt {llm_error_retries}/{max_llm_error_retries}): "
                f"{response.content}"
            )
            await asyncio.sleep(min(2 ** (llm_error_retries - 1), 4))

        if response is None or response.finish_reason == "error":
            final_content = (
                "Sorry, I'm having trouble generating a response right now. "
                "Please try again in a moment."
            )
            session.add_message("user", msg.content)
            session.add_message("assistant", final_content)
            self.sessions.save(session)
            return OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id, content=final_content
            )

        # ── No tool calls → pure conversation, respond directly ───────
        if not response.has_tool_calls:
            final_content = (response.content or "").strip()
            if not final_content:
                final_content = (
                    "Sorry, I'm having trouble generating a response right now. "
                    "Please try again in a moment."
                )

            # Honesty gate — catch hallucinated action claims
            final_content = await self._verify_or_retry(msg, messages, final_content)

            session.add_message("user", msg.content)
            session.add_message("assistant", final_content)
            self.sessions.save(session)
            return OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id, content=final_content
            )

        # ── Tool calls detected → check if the LLM already called spawn ─
        explicitly_spawned = any(tc.name == "spawn" for tc in response.tool_calls)

        if explicitly_spawned:
            # The LLM chose to spawn itself — execute the spawn tool call(s)
            # and let it write the acknowledgment naturally.
            return await self._execute_explicit_spawn(msg, messages, response, session)

        # ── Auto-delegate: LLM wanted tools but didn't spawn ──────────
        # We intercept, spawn a subagent on its behalf, and respond with
        # the start receipt immediately.
        return await self._auto_delegate_to_subagent(msg, messages, response, session)

    async def _execute_explicit_spawn(
        self,
        msg: InboundMessage,
        messages: list[dict[str, Any]],
        response: Any,
        session: Any,
    ) -> OutboundMessage:
        """Handle the case where the LLM explicitly called the spawn tool."""
        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
        )

        for tool_call in response.tool_calls:
            result = await self.tools.execute(tool_call.name, tool_call.arguments)
            messages = self.context.add_tool_result(
                messages, tool_call.id, tool_call.name, result
            )

        # Collect the start receipts that were issued by the spawn tool
        spawn_tool = self.tools.get("spawn")
        issued_receipts: list[str] = []
        if isinstance(spawn_tool, SpawnTool):
            issued_receipts = list(spawn_tool.pending_receipts)

        # Let the LLM write a follow-up with the receipt
        follow_up = await self.provider.chat(
            messages=messages,
            tools=None,  # No more tools — just write the acknowledgment
            model=self.model,
            max_tokens=300,
            temperature=0.7,
        )
        final_content = (follow_up.content or "").strip()
        if not final_content:
            final_content = await self._generate_fallback(msg.content, [])

        # ── Start receipt enforcement ─────────────────────────────────
        # The LLM is NOT allowed to claim a job has started without
        # including the start receipt in its response.
        final_content = self._enforce_start_receipts(final_content, issued_receipts)

        # Clear spawn receipts
        if isinstance(spawn_tool, SpawnTool):
            spawn_tool.pending_receipts.clear()

        session.add_message("user", msg.content)
        session.add_message("assistant", final_content)
        self.sessions.save(session)
        return OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id, content=final_content
        )

    async def _auto_delegate_to_subagent(
        self,
        msg: InboundMessage,
        messages: list[dict[str, Any]],
        response: Any,
        session: Any,
    ) -> OutboundMessage:
        """Auto-delegate a tool-calling request to a subagent.

        The LLM wanted to use tools but didn't call spawn.  We intercept,
        spawn a subagent with the user's original request, generate a start
        receipt, and respond immediately.
        """
        start_receipt = make_start_receipt()

        # Build a task description from the user's message
        task_description = msg.content

        # Spawn the subagent
        spawn_result = await self.subagents.spawn(
            task=task_description,
            label=task_description[:40] + ("..." if len(task_description) > 40 else ""),
            origin_channel=msg.channel,
            origin_chat_id=msg.chat_id,
        )

        logger.info(f"Auto-delegated to subagent: {spawn_result} | receipt: {start_receipt}")

        # Generate a natural acknowledgment using the LLM
        # Include what the LLM was going to do (from its first response) for context
        intended_tools = ", ".join(tc.name for tc in response.tool_calls)
        llm_thinking = (response.content or "").strip()

        ack_messages = [
            {"role": "system", "content": self.context.build_meta_system_prompt()},
            {"role": "user", "content": msg.content},
            {"role": "system", "content": (
                f"You are about to handle this request in the background. "
                f"A subagent has been dispatched (tools it will use: {intended_tools}). "
                f"Write a SHORT (1-2 sentence) in-character acknowledgment that: "
                f"1) briefly references what the user asked for, "
                f"2) lets them know it's being handled, "
                f"3) includes the start reference: {start_receipt} "
                f"Keep it natural and concise. The reference string must appear in your response."
            )},
        ]

        try:
            ack_response = await self.provider.chat(
                messages=ack_messages,
                tools=None,
                model=self.model,
                max_tokens=200,
                temperature=0.7,
            )
            final_content = (ack_response.content or "").strip()
        except Exception:
            final_content = ""

        # ── Start receipt enforcement ─────────────────────────────────
        # The LLM is NOT allowed to claim a job has started without the
        # start receipt in its response.
        if not final_content:
            final_content = f"On it — working on that now. Ref: {start_receipt}"
        else:
            final_content = self._enforce_start_receipts(final_content, [start_receipt])

        session.add_message("user", msg.content)
        session.add_message("assistant", final_content)
        self.sessions.save(session)
        return OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id, content=final_content
        )
    
    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.

        Enforces that the completion receipt (✅ token) from the subagent
        appears in the final response — the LLM cannot claim the task is
        done without it.
        """
        logger.info(f"Processing system message from {msg.sender_id}")

        # Extract the completion receipt from the announce before the LLM sees it
        completion_receipt = self._extract_completion_receipt(msg.content)
        if completion_receipt:
            logger.info(f"Completion receipt extracted from announce: {completion_receipt}")
        
        # 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
        
        # Use the origin session for context
        session_key = f"{origin_channel}:{origin_chat_id}"
        session = self.sessions.get_or_create(session_key)
        
        # Update tool contexts
        message_tool = self.tools.get("message")
        if isinstance(message_tool, MessageTool):
            message_tool.set_context(origin_channel, origin_chat_id)
        
        spawn_tool = self.tools.get("spawn")
        if isinstance(spawn_tool, SpawnTool):
            spawn_tool.set_context(origin_channel, origin_chat_id)
        
        # Build messages with the announce content
        messages = self.context.build_messages(
            history=session.get_history(),
            current_message=msg.content
        )
        
        # Agent loop (limited for announce handling)
        iteration = 0
        final_content = None
        empty_response_retries = 0
        max_empty_response_retries = 2
        llm_error_retries = 0
        max_llm_error_retries = 3
        tool_calls_executed = False
        last_tool_results: list[str] = []
        
        while iteration < self.max_iterations:
            iteration += 1
            
            response = await self.provider.chat(
                messages=messages,
                tools=self.tools.get_definitions(),
                model=self.model
            )
            
            # Check for LLM-level errors
            if response.finish_reason == "error":
                llm_error_retries += 1
                logger.warning(
                    f"LLM error in system handler (attempt {llm_error_retries}/"
                    f"{max_llm_error_retries}): {response.content}"
                )
                if llm_error_retries <= max_llm_error_retries:
                    await asyncio.sleep(min(2 ** (llm_error_retries - 1), 4))
                    continue
                logger.error("LLM errors exhausted in system handler, using fallback")
                break
            
            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
                ]
                status_messages = messages.copy()
                messages = self.context.add_assistant_message(
                    messages, response.content, tool_call_dicts
                )
                
                last_tool_results.clear()
                for tool_call in response.tool_calls:
                    args_str = json.dumps(tool_call.arguments)
                    logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
                    result = await self.tools.execute(tool_call.name, tool_call.arguments)
                    last_tool_results.append(result)
                    messages = self.context.add_tool_result(
                        messages, tool_call.id, tool_call.name, result
                    )
                tool_calls_executed = True
                llm_error_retries = 0
                empty_response_retries = 0
            else:
                final_content = (response.content or "").strip()
                if final_content.startswith("Error calling LLM:"):
                    logger.warning(f"LLM returned error as content (system): {final_content}")
                    final_content = ""
                if not final_content:
                    empty_response_retries += 1
                    logger.warning(
                        f"Empty LLM response (system); retry {empty_response_retries}/"
                        f"{max_empty_response_retries} (finish_reason={response.finish_reason})."
                    )
                    if empty_response_retries <= max_empty_response_retries:
                        if tool_calls_executed:
                            messages.append({
                                "role": "user",
                                "content": (
                                    "You executed tools and got results. Now please "
                                    "summarize the results and respond to the user."
                                )
                            })
                        else:
                            messages.append({
                                "role": "user",
                                "content": (
                                    "Please provide your response to the user's message."
                                )
                            })
                        continue
                break
        
        if not final_content or not final_content.strip():
            if tool_calls_executed and last_tool_results:
                logger.warning("No final LLM content after tool calls (system); generating fallback")
                final_content = await self._generate_fallback(msg.content, last_tool_results)
            else:
                logger.error("Empty LLM response after all retries (system)")
                final_content = (
                    "Sorry, I'm having trouble generating a response right now. "
                    "Please try again in a moment."
                )
        
        # ── Completion receipt enforcement ───────────────────────────
        # The LLM cannot claim the task is done without the ✅ receipt
        # that was generated by the subagent upon actual completion.
        if final_content:
            final_content = self._enforce_completion_receipt(
                final_content, completion_receipt
            )

        # Save to session (mark as system message in history)
        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 process_direct(self, content: str, session_key: str = "cli:direct") -> str:
        """
        Process a message directly (for CLI usage).
        
        Args:
            content: The message content.
            session_key: Session identifier.
        
        Returns:
            The agent's response.
        """
        msg = InboundMessage(
            channel="cli",
            sender_id="user",
            chat_id="direct",
            content=content
        )
        
        response = await self._process_message(msg)
        return response.content if response else ""
