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

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

from loguru import logger

from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.context import ContextBuilder
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.wait import WaitForSubagentsTool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.visibility import sign_content, has_forged_marker, strip_all_hidden_markers
from nanobot.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
    """

    # Server-side context management: Anthropic preserves all thinking blocks
    # and clears old tool results only when approaching the 200k context limit.
    CONTEXT_MANAGEMENT = {
        "edits": [
            {
                "type": "clear_thinking_20251015",
                "keep": "all",  # Preserve all thinking blocks for cache reuse
            },
            {
                "type": "clear_tool_uses_20250919",
                # Raised from 80k to 195k to avoid premature cache invalidation.
                # For conversations with few tool uses (e.g., 18 uses over 182k tokens),
                # cache stability (saves 169k/turn) >> clearing benefit (13-26k one-time).
                # Leaves 5k headroom before hitting 200k standard context limit.
                "trigger": {"type": "input_tokens", "value": 195000},
                "keep": {"type": "tool_uses", "value": 5},
            },
        ]
    }
    
    def __init__(
        self,
        bus: MessageBus,
        provider: LLMProvider,
        workspace: Path,
        model: str | None = None,
        max_iterations: int = 20,
        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,
        enable_memory_tool: bool = True,
        mem0_config: dict[str, Any] | None = None,
        session_manager: SessionManager | None = None,
    ):
        from nanobot.config.schema import ExecToolConfig
        from nanobot.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.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.enable_memory_tool = enable_memory_tool
        self.mem0_config = mem0_config

        self.context = ContextBuilder(workspace, mem0_config=mem0_config)
        self.sessions = session_manager or SessionManager(workspace)
        self.tools = ToolRegistry()
        self.subagents = SubagentManager(
            provider=provider,
            workspace=workspace,
            bus=bus,
            brave_api_key=brave_api_key,
            exec_config=self.exec_config,
            restrict_to_workspace=restrict_to_workspace,
        )

        self._running = False
        self._quota_cache: dict[str, Any] = {}  # {model: str, cached_at: float}
        self._quota_cache_ttl: float = 300.0  # 5 minutes
        self._register_default_tools()
    
    def _register_default_tools(self) -> None:
        """Register the default set of tools."""
        # Import native tools
        from nanobot.agent.tools.anthropic import (
            BashTool20250124,
            EditTool20250728,
            ComputerTool20251124,
        )

        # 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))
        # Removed: replaced by EditTool20250728
        # self.tools.register(EditFileTool(allowed_dir=allowed_dir))
        self.tools.register(ListDirTool(allowed_dir=allowed_dir))

        # Removed: replaced by BashTool20250124
        # self.tools.register(ExecTool(
        #     working_dir=str(self.workspace),
        #     timeout=self.exec_config.timeout,
        #     restrict_to_workspace=self.restrict_to_workspace,
        # ))

        # Web tools
        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, sessions=self.sessions)
        self.tools.register(message_tool)

        # Spawn tool (for subagents)
        spawn_tool = SpawnTool(manager=self.subagents)
        self.tools.register(spawn_tool)
        self.tools.register(WaitForSubagentsTool(manager=self.subagents))

        # Cron tool (for scheduling)
        if self.cron_service:
            self.tools.register(CronTool(self.cron_service))
        if self.enable_memory_tool:
            from nanobot.agent.tools.anthropic import MemoryTool20250818
            self.tools.register(MemoryTool20250818(workspace=self.workspace))

        # Register native Anthropic tools
        self.tools.register(BashTool20250124())
        self.tools.register(EditTool20250728())
        # self.tools.register(ComputerTool20251124())  # Disabled - VM unavailable

        logger.info("Registered native Anthropic tools: bash, text_editor")

        # Register mem0 memory tools (if enabled)
        from nanobot.agent.memory_mem0 import HAS_MEM0
        if self.mem0_config and self.mem0_config.get("enabled") and HAS_MEM0:
            from nanobot.agent.memory_mem0 import Mem0MemoryStore
            from nanobot.agent.tools.memory_tools import (
                Mem0ToolContext, MemorySearchTool, MemoryListTool,
                MemoryAddTool, MemoryUpdateTool, MemoryDeleteTool,
                MemoryConsolidateTool,
            )
            store = Mem0MemoryStore(self.workspace, config=self.mem0_config)
            self._mem0_ctx = Mem0ToolContext(store, self._consolidate_memory)
            self.tools.register(MemorySearchTool(self._mem0_ctx))
            self.tools.register(MemoryListTool(self._mem0_ctx))
            self.tools.register(MemoryAddTool(self._mem0_ctx))
            self.tools.register(MemoryUpdateTool(self._mem0_ctx))
            self.tools.register(MemoryDeleteTool(self._mem0_ctx))
            self.tools.register(MemoryConsolidateTool(self._mem0_ctx))
            logger.info("Registered mem0 memory tools")
    
    async def run(self) -> None:
        """Run the agent loop, processing messages from the bus."""
        self._running = True
        logger.info("Agent loop started")
        
        while self._running:
            try:
                # Wait for next message
                msg = await asyncio.wait_for(
                    self.bus.consume_inbound(),
                    timeout=1.0
                )
                
                # Process it
                try:
                    response = await self._process_message(msg)
                    if response:
                        await self.bus.publish_outbound(response)
                except Exception as e:
                    logger.error(f"Error processing message: {e}")
                    # Send error response
                    await self.bus.publish_outbound(OutboundMessage(
                        channel=msg.channel,
                        chat_id=msg.chat_id,
                        content=f"Sorry, I encountered an error: {str(e)}",
                        metadata=msg.metadata or {},
                    ))
            except asyncio.TimeoutError:
                continue
    
    def stop(self) -> None:
        """Stop the agent loop."""
        self._running = False
        logger.info("Agent loop stopping")

    def _select_model_based_on_quota(self) -> str:
        """Select Opus or Sonnet based on rolling weekly quota burn rate."""
        # Check cache
        now = time.time()
        if self._quota_cache and (now - self._quota_cache.get("cached_at", 0)) < self._quota_cache_ttl:
            return self._quota_cache["model"]

        # Default models
        OPUS = "claude-opus-4-6"
        SONNET = "claude-sonnet-4-6"
        TOLERANCE = 1.17  # 17% overage triggers downgrade

        # Read rate limits
        rate_limits_path = self.workspace / "memory" / "rate_limits.json"
        if not rate_limits_path.exists():
            logger.warning("rate_limits.json not found, defaulting to Sonnet")
            return SONNET

        try:
            with open(rate_limits_path) as f:
                limits = json.load(f)

            actual_usage = limits.get("weekly_all_models")
            weekly_reset = limits.get("weekly_reset")

            if actual_usage is None or weekly_reset is None:
                logger.warning("Rate limit data incomplete, defaulting to Sonnet")
                return SONNET

            # Calculate expected usage
            actual_pct = actual_usage * 100
            week_start = weekly_reset - (168 * 3600)
            hours_elapsed = max(0, min((now - week_start) / 3600, 168))
            expected_pct = (hours_elapsed / 168) * 100
            threshold = expected_pct * TOLERANCE

            # Decision logic
            if actual_pct > threshold:
                model = SONNET
                logger.info(
                    f"Quota: {actual_pct:.1f}% used, expected {expected_pct:.1f}%, "
                    f"threshold {threshold:.1f}% → Sonnet"
                )
            else:
                model = OPUS
                logger.info(
                    f"Quota: {actual_pct:.1f}% used, expected {expected_pct:.1f}%, "
                    f"threshold {threshold:.1f}% → Opus"
                )

            # Cache decision
            self._quota_cache = {"model": model, "cached_at": now}
            return model

        except Exception as e:
            logger.error(f"Error checking quota: {e}, defaulting to Sonnet")
            return SONNET

    def _get_quota_status(self) -> str:
        """Return human-readable quota status."""
        rate_limits_path = self.workspace / "memory" / "rate_limits.json"
        if not rate_limits_path.exists():
            return "⚠️ No quota data available yet."

        try:
            with open(rate_limits_path) as f:
                limits = json.load(f)

            actual_pct = limits.get("weekly_all_models", 0) * 100
            reset_ts = limits.get("weekly_reset", 0)
            now = time.time()

            hours_until_reset = (reset_ts - now) / 3600
            week_start = reset_ts - (168 * 3600)
            hours_elapsed = max(0, (now - week_start) / 3600)
            expected_pct = (hours_elapsed / 168) * 100

            model = self._select_model_based_on_quota()

            return f"""📊 Quota Status:
• Used: {actual_pct:.1f}% (expected {expected_pct:.1f}%)
• Resets in: {hours_until_reset:.1f}h
• Current model: {model}
• Burn rate: {actual_pct / max(expected_pct, 0.01):.2f}x target"""

        except Exception as e:
            return f"⚠️ Error reading quota: {e}"

    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.
        """
        # Handle system messages (subagent announces)
        # The chat_id contains the original "channel:chat_id" to route back to
        if msg.channel == "system":
            return await self._process_system_message(msg)
        
        preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
        logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
        
        # Get or create session
        key = session_key or msg.session_key
        session = self.sessions.get_or_create(key)
        
        # Handle slash commands
        cmd = msg.content.strip().lower()
        if cmd == "/new":
            await self._consolidate_memory(session, archive_all=True)
            session.clear()
            self.sessions.save(session)
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
                                  content="🐈 New session started. Memory consolidated.",
                                  metadata=msg.metadata or {})
        if cmd == "/help":
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
                                  content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status",
                                  metadata=msg.metadata or {})
        if cmd == "/quota":
            status = self._get_quota_status()
            return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status,
                                  metadata=msg.metadata or {})

        # Update tool contexts
        message_tool = self.tools.get("message")
        if isinstance(message_tool, MessageTool):
            message_tool.set_context(msg.channel, msg.chat_id)
            message_tool.start_turn()

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

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

        if hasattr(self, '_mem0_ctx'):
            self._mem0_ctx.set_context(msg.channel, msg.chat_id, session)

        # Track media for this turn (screenshots from computer tool)
        media_paths_for_turn: list[str] = []

        # Prepend current time + optional time-gap notice to every user message
        now_dt = datetime.now()
        tz = time.strftime("%Z") or "UTC"
        time_str = now_dt.strftime("%Y-%m-%d %H:%M (%A)")
        current_message = f"[Current time: {time_str} {tz}]\n{msg.content}"

        # Prefix hook messages so the agent can identify them
        hook_source = msg.metadata.get("hook_source") if msg.metadata else None
        if hook_source:
            current_message = f'[HOOK MESSAGE from "{hook_source}"]\n{current_message}'

        last_user_ts = None
        for m in reversed(session.messages):
            if m.get("role") == "user":
                last_user_ts = m.get("timestamp")
                break
        if last_user_ts:
            try:
                last_dt = datetime.fromisoformat(last_user_ts)
                elapsed_seconds = (now_dt - last_dt).total_seconds()
                if elapsed_seconds > 300:  # 5 minutes
                    if elapsed_seconds < 3600:
                        gap_str = f"{int(elapsed_seconds // 60)} minutes"
                    elif elapsed_seconds < 86400:
                        gap_str = f"{int(elapsed_seconds // 3600)} hours"
                    else:
                        gap_str = f"{int(elapsed_seconds // 86400)} days"
                    current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user. Ask about it if appropriate]\n\n{current_message}"
            except (ValueError, TypeError):
                pass  # Malformed timestamp — skip silently

        # Build initial messages (use get_history for LLM-formatted messages)
        messages = self.context.build_messages(
            history=session.get_history(),
            current_message=current_message,
            media=msg.media if msg.media else None,
            channel=msg.channel,
            chat_id=msg.chat_id,
        )
        # Mark where the current turn starts so we can slice the tool chain for storage
        turn_start = len(messages)

        # Select model based on quota
        selected_model = self._select_model_based_on_quota()

        # Agent loop
        iteration = 0
        final_content = None
        final_reasoning = None

        while iteration < self.max_iterations:
            iteration += 1

            # Call LLM
            logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
            response = await self.provider.chat(
                messages=messages,
                tools=self.tools.get_tools(),  # Pass tool objects for beta flag extraction
                model=selected_model,
                context_management=self.CONTEXT_MANAGEMENT,
            )

            # Handle tool calls
            if response.has_tool_calls:
                # Add assistant message with tool calls
                tool_call_dicts = [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.name,
                            "arguments": json.dumps(tc.arguments)  # Must be JSON string
                        }
                    }
                    for tc in response.tool_calls
                ]
                messages = self.context.add_assistant_message(
                    messages, response.content, tool_call_dicts,
                    reasoning_content=response.reasoning_content,
                )

                # Execute tools
                for tool_call in response.tool_calls:
                    args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
                    logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
                    result = await self.tools.execute(tool_call.name, tool_call.arguments)

                    # Handle different result types
                    if isinstance(result, ToolResult):
                        # Native Anthropic tool result
                        content_parts = []

                        # Add text content
                        if result.output:
                            text_content = result.output
                        elif result.error:
                            text_content = f"Error: {result.error}"
                        else:
                            text_content = ""

                        # If both output and error, combine them
                        if result.output and result.error:
                            text_content = f"{result.output}\n\nError: {result.error}"

                        # If there's an image, use multipart content
                        if result.base64_image:
                            # Save screenshot to disk for user
                            import base64
                            media_dir = Path.home() / ".nanobot" / "media"
                            media_dir.mkdir(parents=True, exist_ok=True)
                            screenshot_path = media_dir / f"screenshot_{int(time.time() * 1000)}.png"
                            screenshot_path.write_bytes(base64.b64decode(result.base64_image))
                            media_paths_for_turn.append(str(screenshot_path))
                            logger.info(f"Saved screenshot to {screenshot_path}")

                            # Include in tool result for model to see
                            content_parts = [
                                {"type": "text", "text": text_content},
                                {
                                    "type": "image",
                                    "source": {
                                        "type": "base64",
                                        "media_type": "image/png",
                                        "data": result.base64_image,
                                    }
                                }
                            ]
                            tool_content = content_parts
                        else:
                            tool_content = text_content

                    elif isinstance(result, CLIResult):
                        # CLI-style tool result (text editor)
                        tool_content = result.output

                    else:
                        # Legacy string result from function tools
                        tool_content = result

                    messages = self.context.add_tool_result(
                        messages, tool_call.id, tool_call.name, tool_content
                    )
                # Interleaved CoT: reflect before next action (skip when thinking is active)
                if not getattr(self.provider, 'thinking_budget', 0):
                    messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
            else:
                # No tool calls
                final_content = response.content
                final_reasoning = response.reasoning_content

                # Check for forged signatures if in suppress mode
                suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
                if suppress_output and has_forged_marker(final_content):
                    # Initialize retry counter if needed
                    if not hasattr(self, '_forge_retry_count'):
                        self._forge_retry_count = 0

                    if self._forge_retry_count < 1:
                        # First offense: reject and retry with correction
                        self._forge_retry_count += 1
                        logger.warning("Model attempted to forge visibility marker, rejecting output")
                        messages.append({
                            "role": "user",
                            "content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
                        })
                        continue  # Back to while loop, will retry LLM call
                    else:
                        # Second offense: strip and log error (fallback)
                        logger.error("Model persisted in forging markers despite correction, stripping")
                        final_content = strip_all_hidden_markers(final_content)

                # Reset retry counter on successful completion
                if hasattr(self, '_forge_retry_count'):
                    self._forge_retry_count = 0

                break

        if final_content is None:
            if iteration >= self.max_iterations:
                final_content = f"Reached {self.max_iterations} iterations without completion."
            else:
                final_content = "I've completed processing but have no response to give."

        # Check if message tool already sent to same target (suppress final reply)
        message_tool = self.tools.get("message")
        if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
            logger.info(f"Suppressing final reply to {msg.channel}:{msg.chat_id} (message tool already sent)")
            return None

        # Log response preview
        preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
        logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")

        # Check for suppress mode BEFORE adding to session
        suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False

        if suppress_output:
            # Sign content with our secret key (forgery detection happens in loop above)
            final_content_for_session = sign_content(final_content)
            # Mark as suppressed for channel handler
            outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
        else:
            final_content_for_session = final_content
            outbound_metadata = msg.metadata or {}

        # Append final assistant response to messages so it's captured in the tool chain slice
        # Use the prefixed version for session storage
        messages = self.context.add_assistant_message(
            messages, final_content_for_session, None,
            reasoning_content=final_reasoning,
        )

        # Save to session: mem0 context (if present) + user message + full tool chain
        # Store current_message (not msg.content) so the time prefix is preserved
        # Include sender_id to distinguish real user messages from system-generated ones

        # Find and save mem0 injection (appears just before current user message)
        # build_messages returns: [...history, mem0_user, mem0_asst, current_user]
        # turn_start = len(messages), so mem0 is at turn_start-3 and turn_start-2
        # This makes mem0 part of immutable history, stabilizing cache across turns
        if turn_start >= 3:
            potential_mem0_user = messages[turn_start - 3]
            potential_mem0_asst = messages[turn_start - 2]
            if (potential_mem0_user.get("role") == "user" and
                potential_mem0_user.get("content") == "[Memory context]" and
                potential_mem0_asst.get("role") == "assistant"):
                session.add_raw_message(potential_mem0_user)
                session.add_raw_message(potential_mem0_asst)

        session.add_message("user", current_message, sender_id=msg.sender_id)
        for chain_msg in messages[turn_start:]:
            session.add_raw_message(chain_msg)
        self.sessions.save(session)

        # Deferred trim: if memory_consolidate ran mid-turn, it set a checkpoint
        # marking where to trim. Now that the turn's tool chain is fully saved,
        # we can safely trim to that checkpoint.
        checkpoint = getattr(session, '_trim_checkpoint', None)
        if checkpoint is not None:
            old_size = len(session.messages)
            session.messages = session.messages[checkpoint:]
            session._trim_checkpoint = None
            self.sessions.save(session)
            logger.info(f"Deferred trim applied: {old_size} -> {len(session.messages)} messages (checkpoint={checkpoint})")

        return OutboundMessage(
            channel=msg.channel,
            chat_id=msg.chat_id,
            content=final_content_for_session,
            metadata=outbound_metadata,
            media=media_paths_for_turn if media_paths_for_turn else None,
        )
    
    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
        
        # 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, msg.metadata)

        cron_tool = self.tools.get("cron")
        if isinstance(cron_tool, CronTool):
            cron_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,
            channel=origin_channel,
            chat_id=origin_chat_id,
        )
        turn_start = len(messages)

        # Agent loop (limited for announce handling)
        iteration = 0
        final_content = None
        final_reasoning = None

        # Select model based on quota
        selected_model = self._select_model_based_on_quota()

        while iteration < self.max_iterations:
            iteration += 1

            response = await self.provider.chat(
                messages=messages,
                tools=self.tools.get_tools(),  # Pass tool objects for beta flag extraction
                model=selected_model,
                context_management=self.CONTEXT_MANAGEMENT,
            )

            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,
                )

                for tool_call in response.tool_calls:
                    args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
                    logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
                    result = await self.tools.execute(tool_call.name, tool_call.arguments)

                    # Handle different result types (same logic as main handler)
                    if isinstance(result, ToolResult):
                        # Native Anthropic tool result
                        # Add text content
                        if result.output:
                            text_content = result.output
                        elif result.error:
                            text_content = f"Error: {result.error}"
                        else:
                            text_content = ""

                        # If both output and error, combine them
                        if result.output and result.error:
                            text_content = f"{result.output}\n\nError: {result.error}"

                        # Note: Image handling for system messages not needed
                        # (system messages don't render images to users)
                        # But we should still log if present
                        if result.base64_image:
                            logger.warning(
                                f"Tool {tool_call.name} returned image in system message context - "
                                "images not supported here"
                            )

                        tool_content = text_content

                    elif isinstance(result, CLIResult):
                        # CLI-style tool result (text editor)
                        tool_content = result.output

                    else:
                        # Legacy string result from function tools
                        tool_content = result

                    messages = self.context.add_tool_result(
                        messages, tool_call.id, tool_call.name, tool_content
                    )
                # Interleaved CoT: reflect before next action (skip when thinking is active)
                if not getattr(self.provider, 'thinking_budget', 0):
                    messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
            else:
                # No tool calls
                final_content = response.content
                final_reasoning = response.reasoning_content

                # Check for forged signatures if in suppress mode
                suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
                if suppress_output and has_forged_marker(final_content):
                    # Initialize retry counter if needed
                    if not hasattr(self, '_forge_retry_count_system'):
                        self._forge_retry_count_system = 0

                    if self._forge_retry_count_system < 1:
                        # First offense: reject and retry with correction
                        self._forge_retry_count_system += 1
                        logger.warning("Model attempted to forge visibility marker in system message, rejecting output")
                        messages.append({
                            "role": "user",
                            "content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
                        })
                        continue  # Back to while loop, will retry LLM call
                    else:
                        # Second offense: strip and log error (fallback)
                        logger.error("Model persisted in forging markers despite correction, stripping")
                        final_content = strip_all_hidden_markers(final_content)

                # Reset retry counter on successful completion
                if hasattr(self, '_forge_retry_count_system'):
                    self._forge_retry_count_system = 0

                break

        if final_content is None:
            final_content = "Background task completed."

        # Check for suppress mode BEFORE adding to session
        suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False

        if suppress_output:
            # Sign content with our secret key (forgery detection happens in loop above)
            final_content_for_session = sign_content(final_content)
            # Mark as suppressed for channel handler
            outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
        else:
            final_content_for_session = final_content
            outbound_metadata = msg.metadata or {}

        # Append final assistant response to messages (use signed version for session)
        messages = self.context.add_assistant_message(
            messages, final_content_for_session, None,
            reasoning_content=final_reasoning,
        )

        # Save to session: mem0 (if present) + user message + full tool chain
        # Find and save mem0 injection for cache stability
        if turn_start >= 3:
            potential_mem0_user = messages[turn_start - 3]
            potential_mem0_asst = messages[turn_start - 2]
            if (potential_mem0_user.get("role") == "user" and
                potential_mem0_user.get("content") == "[Memory context]" and
                potential_mem0_asst.get("role") == "assistant"):
                session.add_raw_message(potential_mem0_user)
                session.add_raw_message(potential_mem0_asst)

        session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
        for chain_msg in messages[turn_start:]:
            session.add_raw_message(chain_msg)
        self.sessions.save(session)

        # Deferred trim: same logic as _process_message
        # System messages (including subagents) can trigger consolidation
        checkpoint = getattr(session, '_trim_checkpoint', None)
        if checkpoint is not None:
            old_size = len(session.messages)
            session.messages = session.messages[checkpoint:]
            session._trim_checkpoint = None
            self.sessions.save(session)
            logger.info(f"Deferred trim applied: {old_size} -> {len(session.messages)} messages (checkpoint={checkpoint})")

        # Return original content (not signed) for outbound, but with suppressed metadata
        return OutboundMessage(
            channel=origin_channel,
            chat_id=origin_chat_id,
            content=final_content,
            metadata=outbound_metadata,
        )
    
    @staticmethod
    def _find_clean_boundary_before(messages: list[dict], target_pos: int) -> int:
        """Find a clean user message boundary at or before target position.

        Returns the index of a user message at or before target_pos,
        or target_pos if no user message is found.
        """
        if not messages or target_pos <= 0:
            return 0
        if target_pos >= len(messages):
            return len(messages)

        # Walk backward from target to find a user message
        for i in range(target_pos, -1, -1):
            if messages[i].get("role") == "user":
                return i

        # No user message found, return target position
        return target_pos

    @staticmethod
    def _trim_to_clean_boundary(messages: list[dict], keep_count: int) -> list[dict]:
        """Trim messages to approximately keep_count, starting at a user message boundary.

        Naive slicing (messages[-keep_count:]) can cut into a tool chain, leaving
        orphaned tool_result messages at the start. This finds the nearest user
        message (role="user") at or before the cut point and trims there.
        """
        if not messages or keep_count <= 0:
            return []
        if keep_count >= len(messages):
            return messages

        cut = len(messages) - keep_count
        # Walk forward from cut to find a "user" role message (start of a turn)
        # that isn't a tool result. Tool results have role="tool", user messages
        # have role="user" — but after conversion, tool results ARE user messages.
        # In session storage, they're still role="tool", so we look for role="user".
        for i in range(cut, len(messages)):
            if messages[i].get("role") == "user":
                return messages[i:]
        # If no user message found after cut, try walking backward
        for i in range(cut - 1, -1, -1):
            if messages[i].get("role") == "user":
                return messages[i:]
        # Fallback: return everything (shouldn't happen in practice)
        return messages

    async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
        """Consolidate session into MEMORY.md + HISTORY.md.

        Context window management is now handled server-side via context_management.
        This only runs on /new to write long-term facts and searchable history.
        """
        if not session.messages:
            return

        # Choose memory backend
        from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0

        if self.mem0_config and self.mem0_config.get("enabled") and HAS_MEM0:
            memory = Mem0MemoryStore(self.workspace, config=self.mem0_config)
            logger.debug("Using mem0 for memory consolidation")
            # Mem0 has its own consolidation logic (feeds messages to mem0 for extraction)
            await memory.consolidate(
                session,
                self.provider,
                self.model,
                archive_all=archive_all,
                memory_window=self.memory_window,
            )
            # archive_all (/new) runs at a turn boundary — safe to trim now.
            # Mid-turn (memory_consolidate tool) — defer trim to end of turn
            # to avoid orphaning tool_use IDs in the active tool chain.
            if archive_all:
                session.messages = []
                self.sessions.save(session)
                logger.info("Mem0 consolidation done, session cleared (archive_all)")
            else:
                keep_count = min(10, max(2, self.memory_window // 2))
                # Set checkpoint at current session size minus keep_count
                # This preserves the intended trim point regardless of messages added later
                checkpoint = max(0, len(session.messages) - keep_count)
                # Find clean boundary at or before checkpoint
                checkpoint = self._find_clean_boundary_before(session.messages, checkpoint)
                session._trim_checkpoint = checkpoint
                logger.info(f"Mem0 consolidation done, trim deferred (checkpoint={checkpoint}, current_size={len(session.messages)})")
            return
        else:
            memory = MemoryStore(self.workspace)
            logger.debug("Using MemoryStore for memory consolidation")
        if archive_all:
            old_messages = session.messages
            keep_count = 0
        else:
            # Only write truly old messages; keep the recent ones
            keep_count = min(10, max(2, self.memory_window // 2))
            old_messages = session.messages[:-keep_count]
        if not old_messages:
            return
        logger.info(f"Memory consolidation: archiving {len(old_messages)} messages, keeping {keep_count}")

        # Format messages for LLM — handle full tool chain format
        lines = []
        for m in old_messages:
            role = m.get("role", "?")
            content = m.get("content")
            timestamp = m.get("timestamp", "?")[:16]

            if role == "tool":
                result = str(content or "")[:200]
                lines.append(f"[{timestamp}] TOOL_RESULT({m.get('name', '?')}): {result}")
                continue

            # Skip internal reflect prompts
            if role == "user" and content == "Reflect on the results and decide next steps.":
                continue

            # Extract text from content (may be list of blocks)
            if isinstance(content, list):
                text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
                content_str = " ".join(text_parts)
            elif isinstance(content, str):
                content_str = content
            else:
                content_str = ""

            # Get tool names from tool_calls or legacy tools_used
            tool_names = []
            if m.get("tool_calls"):
                tool_names = [tc.get("function", {}).get("name", "?") for tc in m["tool_calls"]]
            elif m.get("tools_used"):
                tool_names = m["tools_used"]

            if not content_str and not tool_names:
                continue

            tools_str = f" [tools: {', '.join(tool_names)}]" if tool_names else ""
            lines.append(f"[{timestamp}] {role.upper()}{tools_str}: {content_str}")
        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="claude-haiku-4-5",
                thinking_budget=0,
                max_tokens=16384,
            )
            text = (response.content or "").strip()
            if text.startswith("```"):
                text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            result = json.loads(text)

            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)

            session.messages = self._trim_to_clean_boundary(session.messages, keep_count) if keep_count else []
            self.sessions.save(session)
            logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
        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",
        metadata: dict[str, Any] | None = None,
    ) -> 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).
            metadata: Optional metadata to pass through (for suppress mode, etc.).

        Returns:
            The agent's response.
        """
        msg = InboundMessage(
            channel=channel,
            sender_id="user",
            chat_id=chat_id,
            content=content,
            metadata=metadata or {},
        )

        response = await self._process_message(msg, session_key=session_key)
        return response.content if response else ""
