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

from __future__ import annotations

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

from loguru import logger

from asedclaw.agent.context import ContextBuilder
from asedclaw.agent.guardrails import RoundGuardrails
from asedclaw.agent.memory import MemoryStore
from asedclaw.agent.subagent import SubagentManager
from asedclaw.agent.tools.cron import CronTool
from asedclaw.agent.tools.filesystem import FilesystemToolkit
from asedclaw.agent.tools.memory import MemoryToolkit
from asedclaw.agent.tools.message import MessageTool
from asedclaw.agent.tools.send_file import SendFileTool
from asedclaw.agent.tools.registry import ToolRegistry
from asedclaw.agent.tools.shell import ExecTool
from asedclaw.agent.tools.spawn import SpawnTool
from asedclaw.agent.tools.web import WebToolkit
from asedclaw.bus.events import InboundMessage, OutboundMessage
from asedclaw.bus.queue import MessageBus
from asedclaw.config.paths import get_media_dir
from asedclaw.media.dedupe import dedupe_pending_media, is_text_duplicate
from asedclaw.media.parse import extract_media_from_tool_result, strip_media_tags
from asedclaw.media.validate import validate_media_path
from asedclaw.providers.base import LLMProvider
from asedclaw.session.manager import Session, SessionManager

if TYPE_CHECKING:
    from asedclaw.config.schema import ExecToolConfig, MemoryFlushConfig
    from asedclaw.cron.service import CronService


class AgentLoop:
    """Core processing engine: receive → context → LLM → tools → respond."""

    _TOOL_RESULT_MAX_CHARS = 500

    def __init__(
        self,
        bus: MessageBus,
        provider: LLMProvider,
        workspace: Path,
        model: str | None = None,
        temperature: float = 0.1,
        max_tokens: int = 4096,
        memory_window: int = 100,
        reasoning_effort: str | None = None,
        brave_api_key: str | None = None,
        web_proxy: str | None = None,
        exec_config: ExecToolConfig | None = None,
        cron_service: CronService | None = None,
        restrict_to_workspace: bool = False,
        session_manager: SessionManager | None = None,
        mcp_servers: dict | None = None,
        channels_config: Any | None = None,
        max_iterations: int = 40,
        memory_flush_config: MemoryFlushConfig | None = None,
        embedding_config: Any | None = None,
        guardrails_config: Any | None = None,
    ):
        from asedclaw.config.schema import ExecToolConfig

        self.bus = bus
        self.channels_config = channels_config
        self.provider = provider
        self.workspace = workspace
        self.model = model or provider.get_default_model()
        self.temperature = temperature
        self.memory_window = memory_window
        self.reasoning_effort = reasoning_effort
        self.max_iterations = max_iterations
        self.brave_api_key = brave_api_key
        self.web_proxy = web_proxy
        self.exec_config = exec_config or ExecToolConfig()
        self.cron_service = cron_service
        self.restrict_to_workspace = restrict_to_workspace

        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=max_tokens,
            reasoning_effort=reasoning_effort,
            brave_api_key=brave_api_key,
            web_proxy=web_proxy,
            exec_config=self.exec_config,
            restrict_to_workspace=restrict_to_workspace,
        )

        # Memory flush config
        if memory_flush_config is None:
            from asedclaw.config.schema import MemoryFlushConfig as _MFC
            memory_flush_config = _MFC()
        self.memory_flush_config = memory_flush_config
        self._flush_done_for_cycle: dict[str, bool] = {}
        self._embedding_config = embedding_config
        self._guardrails_config = guardrails_config

        # Memory index for hybrid search
        self._memory_index = self._create_memory_index()
        self._index_synced_sessions: set[str] = set()

        self._running = False
        self._consolidating: set[str] = set()
        self._consolidation_tasks: set[asyncio.Task] = set()
        self._consolidation_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
        self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
        self._active_tasks: dict[str, list[asyncio.Task]] = {}
        self._register_default_tools()

    # ── Memory index ───────────────────────────────────────────────────

    def _create_memory_index(self):
        """Create MemoryIndexManager using embedding config."""
        try:
            from asedclaw.agent.memory_index import MemoryIndexManager

            emb_cfg = getattr(self, "_embedding_config", None)
            if emb_cfg and emb_cfg.api_base:
                return MemoryIndexManager(
                    workspace=self.workspace,
                    embedding_api_key=emb_cfg.api_key,
                    embedding_api_base=emb_cfg.api_base,
                    embedding_model=emb_cfg.model,
                    embedding_dimensions=emb_cfg.dimensions,
                    embedding_task=emb_cfg.task,
                )
            # No embedding config — still create index for BM25-only search
            return MemoryIndexManager(workspace=self.workspace)
        except Exception:
            logger.debug("Memory index unavailable", exc_info=True)
            return None

    # ── Tool registration ──────────────────────────────────────────────

    def _register_default_tools(self) -> None:
        allowed_dir = self.workspace if self.restrict_to_workspace else None
        self.tools.register_many(
            FilesystemToolkit(workspace=self.workspace, allowed_dir=allowed_dir),
            MemoryToolkit(workspace=self.workspace, index=self._memory_index),
            ExecTool(
                working_dir=str(self.workspace),
                timeout=self.exec_config.timeout,
                restrict_to_workspace=self.restrict_to_workspace,
                path_append=self.exec_config.path_append,
            ),
            WebToolkit(api_key=self.brave_api_key, proxy=self.web_proxy),
            # MessageTool(send_callback=self.bus.publish_outbound, workspace=self.workspace),
            SpawnTool(manager=self.subagents),
            SendFileTool(send_callback=self.bus.publish_outbound, workspace=self.workspace),
        )
        if self.cron_service:
            self.tools.register(CronTool(self.cron_service))

    
    # ── Agent run ──────────────────────────────────────────────────────

    async def _run_agent(
        self,
        initial_messages: list[dict],
        on_progress: Callable[..., Awaitable[None]] | None = None,
    ) -> tuple[str | None, list[str], list[dict], list[str]]:
        """Run a single round. Returns (content, tools_used, messages, pending_media)."""
        try:
            from agno.agent import Agent, RunEvent
            from agno.models.openai import OpenAILike
        except Exception:
            logger.exception("agno import failed")
            fallback = "Error: agno runtime unavailable. Please verify agno dependency and model config."
            msgs = self.context.add_assistant_message(list(initial_messages), fallback)
            return fallback, [], msgs, []

        try:
            system_prompt = next(
                (m["content"] for m in initial_messages
                 if m.get("role") == "system" and isinstance(m.get("content"), str)),
                "",
            )
            message_tool = self.tools.get("message")

            model_kwargs: dict[str, Any] = {"id": self.model, "reasoning_effort": self.reasoning_effort}
            if getattr(self.provider, "api_key", None):
                model_kwargs["api_key"] = self.provider.api_key
            if getattr(self.provider, "api_base", None):
                model_kwargs["base_url"] = self.provider.api_base
            if self.temperature is not None:
                model_kwargs["temperature"] = self.temperature

            agent = Agent(
                model=OpenAILike(**model_kwargs),
                tools=self.tools.get_agno_tool_instances(),
                instructions=system_prompt,
                markdown=True,
                stream=True,
                stream_events=True,
                debug_mode=True,
            )
            prompt = self._history_to_prompt(initial_messages)
            final_content = ""
            tools_used: list[str] = []
            pending_media: list[str] = []
            gc = self._guardrails_config
            guardrails_kwargs: dict[str, Any] = {}
            default_max_tool_calls = getattr(self, "max_iterations", 40)
            if gc:
                guardrails_kwargs["max_total_tool_calls"] = getattr(gc, "max_total_tool_calls", default_max_tool_calls)
                guardrails_kwargs["max_same_error_retries"] = getattr(gc, "max_same_error_retries", 3)
                if hasattr(gc, "tool_budget") and gc.tool_budget:
                    guardrails_kwargs["tool_budget"] = dict(gc.tool_budget)
            else:
                guardrails_kwargs["max_total_tool_calls"] = default_max_tool_calls
            guardrails = RoundGuardrails(**guardrails_kwargs)
            reasoning_emitted = 0
            event_counts: dict[str, int] = {}
            answer_delta_count = 0
            answer_delta_chars = 0
            reasoning_delta_count = 0
            reasoning_delta_chars = 0

            # Backward/forward compatibility across agno versions where some RunEvent
            # members may be missing.
            ev_run_content = getattr(RunEvent, "run_content", "RunContent")
            ev_run_intermediate = getattr(RunEvent, "run_intermediate_content", "RunIntermediateContent")
            ev_reasoning_delta = getattr(RunEvent, "reasoning_content_delta", "ReasoningContentDelta")
            ev_tool_started = getattr(RunEvent, "tool_call_started", "ToolCallStarted")
            ev_tool_completed = getattr(RunEvent, "tool_call_completed", "ToolCallCompleted")
            ev_tool_error = getattr(RunEvent, "tool_call_error", "ToolCallError")
            ev_run_completed = getattr(RunEvent, "run_completed", "RunCompleted")
            stream_log_every = 20

            def _preview_text(v: Any, max_len: int = 120) -> str:
                if not isinstance(v, str):
                    return ""
                s = " ".join(v.split())
                return (s[: max_len - 1] + "…") if len(s) > max_len else s

            async for chunk in agent.arun(prompt, stream=True):
                event = getattr(chunk, "event", None)
                event_name = str(event)
                event_counts[event_name] = event_counts.get(event_name, 0) + 1
                if event == ev_run_content:
                    delta = chunk.content
                    reasoning_delta = getattr(chunk, "reasoning_content", None)
                    if isinstance(reasoning_delta, str) and reasoning_delta and on_progress:
                        reasoning_emitted += 1
                        reasoning_delta_count += 1
                        reasoning_delta_chars += len(reasoning_delta)
                        if reasoning_delta_count == 1 or reasoning_delta_count % stream_log_every == 0:
                            logger.debug(
                                "chunk={} reasoning#{} total_chars={} preview={}",
                                event_name,
                                reasoning_delta_count,
                                reasoning_delta_chars,
                                _preview_text(reasoning_delta),
                            )
                        try:
                            await on_progress(reasoning_delta, tool_hint=False, item_type="reasoning_content")
                        except TypeError:
                            await on_progress(reasoning_delta, tool_hint=False)
                    if isinstance(delta, str) and delta:
                        final_content += delta
                        answer_delta_count += 1
                        answer_delta_chars += len(delta)
                        if answer_delta_count == 1 or answer_delta_count % stream_log_every == 0:
                            logger.debug(
                                "chunk={} answer#{} total_chars={} preview={}",
                                event_name,
                                answer_delta_count,
                                answer_delta_chars,
                                _preview_text(delta),
                            )
                        if on_progress:
                            try:
                                await on_progress(delta, tool_hint=False, item_type="answer_text")
                            except TypeError:
                                await on_progress(delta, tool_hint=False)

                elif event == ev_run_intermediate:
                    delta = chunk.content
                    if isinstance(delta, str) and delta and on_progress:
                        answer_delta_count += 1
                        answer_delta_chars += len(delta)
                        if answer_delta_count == 1 or answer_delta_count % stream_log_every == 0:
                            logger.debug(
                                "chunk={} answer#{} total_chars={} preview={}",
                                event_name,
                                answer_delta_count,
                                answer_delta_chars,
                                _preview_text(delta),
                            )
                        try:
                            await on_progress(delta, tool_hint=False, item_type="answer_text")
                        except TypeError:
                            await on_progress(delta, tool_hint=False)

                elif event == ev_reasoning_delta:
                    reasoning_delta = getattr(chunk, "reasoning_content", None) or getattr(chunk, "content", None)
                    if isinstance(reasoning_delta, str) and reasoning_delta and on_progress:
                        reasoning_emitted += 1
                        reasoning_delta_count += 1
                        reasoning_delta_chars += len(reasoning_delta)
                        if reasoning_delta_count == 1 or reasoning_delta_count % stream_log_every == 0:
                            logger.debug(
                                "chunk={} reasoning#{} total_chars={} preview={}",
                                event_name,
                                reasoning_delta_count,
                                reasoning_delta_chars,
                                _preview_text(reasoning_delta),
                            )
                        try:
                            await on_progress(reasoning_delta, tool_hint=False, item_type="reasoning_content")
                        except TypeError:
                            await on_progress(reasoning_delta, tool_hint=False)

                elif event == ev_tool_started:
                    tool_name = chunk.tool.tool_name
                    tool_call_id_start = getattr(chunk.tool, "tool_call_id", None)
                    tool_args_raw = chunk.tool.tool_args or {}
                    if (
                        tool_name == "message"
                        and isinstance(message_tool, MessageTool)
                        and message_tool._calls_in_turn >= 4
                    ):
                        logger.warning("Excessive message tool calls; stopping run.")
                        break
                    # Guardrail check
                    block_reason = guardrails.check_tool_allowed(tool_name, tool_args_raw)
                    if block_reason:
                        logger.warning("Guardrail blocked {}: {}", tool_name, block_reason)
                        break
                    if tool_name:
                        tools_used.append(tool_name)
                    if on_progress and tool_name:
                        tool_args_start = chunk.tool.tool_args or {}
                        hint = self._format_tool_hint(tool_name, tool_args_start)
                        logger.debug("tool_hint: {}", hint)
                        try:
                            await on_progress(
                                hint,
                                tool_hint=True,
                                item_type="tool_call",
                                tool_status="running",
                                tool_args=tool_args_start,
                                tool_id=tool_call_id_start,
                            )
                        except TypeError:
                            await on_progress(hint, tool_hint=True)

                elif event == ev_tool_completed:
                    tc = getattr(chunk, "tool", None)
                    tool_name_done = getattr(tc, "tool_name", None) if tc else None
                    result_text = getattr(tc, "result", "") if tc else ""
                    tool_args_done = getattr(tc, "tool_args", {}) if tc else {}
                    tool_call_id = getattr(tc, "tool_call_id", None) if tc else None
                    # Record outcome for guardrails
                    is_err = isinstance(result_text, str) and result_text.lstrip().startswith("Error")
                    if tool_name_done:
                        guardrails.record_tool_result(tool_name_done, tool_args_done, result_text, is_err)
                    if isinstance(result_text, str):
                        for p in extract_media_from_tool_result(result_text):
                            if validate_media_path(p, workspace=self.workspace):
                                pending_media.append(p)
                            else:
                                logger.warning("Blocked media path: {}", p)
                    # Push tool-done hint so the frontend card updates to done + result
                    if on_progress and tool_name_done:
                        hint = self._format_tool_hint(tool_name_done, tool_args_done)
                        result_preview = (result_text or "")[:300].strip() if result_text else None
                        try:
                            await on_progress(
                                hint,
                                tool_hint=True,
                                item_type="tool_call",
                                tool_status="done",
                                tool_args=tool_args_done,
                                tool_result=result_preview,
                                tool_id=tool_call_id,
                            )
                        except TypeError:
                            await on_progress(hint, tool_hint=True)

                elif event == ev_tool_error:
                    tc = getattr(chunk, "tool", None)
                    tool_name_err = getattr(tc, "tool_name", None) if tc else None
                    tool_args_err = getattr(tc, "tool_args", {}) if tc else {}
                    tool_call_id_err = getattr(tc, "tool_call_id", None) if tc else None
                    tool_error = getattr(chunk, "error", None)
                    # Record error for guardrails
                    if tool_name_err:
                        guardrails.record_tool_result(
                            tool_name_err, tool_args_err,
                            tool_error if isinstance(tool_error, str) else str(tool_error),
                            is_error=True,
                        )
                    if on_progress and tool_name_err:
                        hint = self._format_tool_hint(tool_name_err, tool_args_err)
                        try:
                            await on_progress(
                                hint,
                                tool_hint=True,
                                item_type="tool_call",
                                tool_status="failed",
                                tool_args=tool_args_err,
                                tool_result=(tool_error[:300].strip() if isinstance(tool_error, str) else None),
                                tool_id=tool_call_id_err,
                            )
                        except TypeError:
                            await on_progress(hint, tool_hint=True)

                elif event == ev_run_completed:
                    if isinstance(chunk.content, str) and chunk.content.strip():
                        final_content = chunk.content

            logger.info(
                "run chunk summary events={} answer_deltas={} answer_chars={} reasoning_deltas={} reasoning_chars={}",
                event_counts,
                answer_delta_count,
                answer_delta_chars,
                reasoning_delta_count,
                reasoning_delta_chars,
            )

            if guardrails._failures:
                logger.info("guardrails stats={}", guardrails.stats)
                hint = guardrails.get_recovery_hint()
                if hint:
                    logger.info("guardrails recovery hint: {}", hint)

            if self.reasoning_effort and reasoning_emitted == 0:
                logger.info(
                    "No reasoning stream emitted in this run. model='{}' provider='{}' reasoning_effort='{}' "
                    "(provider/model may not expose reasoning deltas in OpenAI-compatible stream).",
                    self.model,
                    type(self.provider).__name__,
                    self.reasoning_effort,
                )

            clean = self._strip_think(final_content)
            all_messages = self.context.add_assistant_message(list(initial_messages), clean)
            return clean, tools_used, all_messages, pending_media

        except Exception:
            logger.exception("agno run failed")
            fallback = "Error: agent execution failed."
            msgs = self.context.add_assistant_message(list(initial_messages), fallback)
            return fallback, [], msgs, []

    # ── Helpers ─────────────────────────────────────────────────────────

    @staticmethod
    def _strip_think(text: str | None) -> str | None:
        if not text:
            return None
        return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None

    @staticmethod
    def _history_to_prompt(messages: list[dict[str, Any]]) -> str:
        parts = []
        for m in messages:
            if m.get("role") == "system":
                continue
            content = m.get("content")
            text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
            parts.append(f"{m.get('role')}: {text}")
        return "Conversation so far:\n\n" + "\n\n".join(parts)

    @staticmethod
    def _format_tool_hint(name: str, args: Any) -> str:
        hint_val = None
        if isinstance(args, dict) and args:
            hint_val = next(iter(args.values()))
        if isinstance(hint_val, str):
            return f'{name}("{hint_val[:40]}…")' if len(hint_val) > 40 else f'{name}("{hint_val}")'
        return name

    # ── Main loop ──────────────────────────────────────────────────────

    async def run(self) -> None:
        self._running = True
        logger.info("Agent loop started")
        msg_count = 0

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

            msg_count += 1
            if msg_count % 20 == 0:
                self._cleanup_staged_media()

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

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

    # ── Dispatch & process ─────────────────────────────────────────────

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

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

    async def _process_message(
        self,
        msg: InboundMessage,
        session_key: str | None = None,
        on_progress: Callable[[str], Awaitable[None]] | None = None,
    ) -> OutboundMessage | None:
        if msg.channel == "system":
            return await self._process_system_message(msg)

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

        # Unique identifier for this agent task — all progress + final output share it
        task_id = uuid.uuid4().hex

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

        # Slash commands
        cmd = msg.content.strip().lower()
        if cmd == "/new":
            return await self._handle_new_session(msg, session)
        if cmd == "/help":
            return OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id,
                content="asedclaw commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands",
            )

        self._maybe_consolidate(session)
        await self._maybe_memory_flush(session)
        await self._maybe_sync_index(session)

        # Inject per-turn context (channel/chat) into tools that require it
        for tool_name in ("message", "send_file", "spawn", "cron"):
            tool = self.tools.get(tool_name)
            if tool is None:
                continue
            if hasattr(tool, "set_context"):
                if tool_name == "message":
                    tool.set_context(
                        msg.channel, msg.chat_id,
                        msg.metadata.get("message_id") if msg.metadata else None,
                    )
                else:
                    tool.set_context(msg.channel, msg.chat_id)
            if hasattr(tool, "start_turn"):
                tool.start_turn()

        mt = self.tools.get("message")  # used by _assemble_reply

        history = session.get_history(max_messages=self.memory_window)
        initial_messages = self.context.build_messages(
            history=history,
            current_message=msg.content,
            media=msg.media or None,
            attachments=msg.attachments or None,
            channel=msg.channel, chat_id=msg.chat_id,
        )

        async def _bus_progress(
            content: str,
            *,
            tool_hint: bool = False,
            item_type: str = "answer_text",
            tool_status: str = "running",
            tool_args: dict | None = None,
            tool_result: str | None = None,
            tool_id: str | None = None,
        ) -> None:
            meta = dict(msg.metadata or {})
            meta["_progress"] = True
            meta["_tool_hint"] = tool_hint
            meta["_item_type"] = item_type
            meta["_task_id"] = task_id
            if tool_hint:
                meta["_tool_status"] = tool_status
                if tool_args:
                    meta["_tool_input"] = tool_args
                if tool_result is not None:
                    meta["_tool_result"] = tool_result
                if tool_id is not None:
                    meta["_tool_id"] = tool_id
            await self.bus.publish_outbound(OutboundMessage(
                channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
                task_id=task_id,
            ))

        final_content, _, all_msgs, pending_media = await self._run_agent(
            initial_messages, on_progress=on_progress or _bus_progress,
        )

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

        reply = self._assemble_reply(msg, final_content, pending_media, mt)
        if reply is not None:
            reply.metadata.setdefault("_task_id", task_id)
            reply.task_id = task_id
        return reply

    # ── Sub-handlers extracted from _process_message ───────────────────

    async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage:
        channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id))
        logger.info("Processing system message from {}", msg.sender_id)
        session = self.sessions.get_or_create(f"{channel}:{chat_id}")
        history = session.get_history(max_messages=self.memory_window)
        messages = self.context.build_messages(
            history=history, current_message=msg.content, channel=channel, chat_id=chat_id,
        )
        final_content, _, all_msgs, pending_media = await self._run_agent(messages)
        self._save_turn(session, all_msgs, 1 + len(history))
        self.sessions.save(session)
        return OutboundMessage(
            channel=channel, chat_id=chat_id,
            content=final_content or "Background task completed.",
            media=pending_media,
        )

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

        session.clear()
        self.sessions.save(session)
        self.sessions.invalidate(session.key)
        return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="New session started.")

    def _maybe_consolidate(self, session: Session) -> None:
        unconsolidated = len(session.messages) - session.last_consolidated
        if unconsolidated < self.memory_window or session.key in self._consolidating:
            return
        self._consolidating.add(session.key)
        lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())

        async def _do():
            try:
                async with lock:
                    await self._consolidate_memory(session)
                    # Reset flush flag after successful consolidation cycle
                    self._flush_done_for_cycle.pop(session.key, None)
            finally:
                self._consolidating.discard(session.key)
                t = asyncio.current_task()
                if t is not None:
                    self._consolidation_tasks.discard(t)

        task = asyncio.create_task(_do())
        self._consolidation_tasks.add(task)

    async def _maybe_memory_flush(self, session: Session) -> None:
        """Silently flush memory if token or transcript thresholds are exceeded."""
        from asedclaw.agent.memory_flush import (
            run_memory_flush,
            should_flush,
            should_force_flush_by_size,
        )
        from asedclaw.agent.token_utils import estimate_messages_tokens

        cfg = self.memory_flush_config
        flush_done = self._flush_done_for_cycle.get(session.key, False)

        need_flush = False
        if not flush_done and cfg.enabled:
            total_tokens = estimate_messages_tokens(session.messages)
            if should_flush(total_tokens, cfg, flush_done):
                logger.info("Memory flush triggered (tokens={}, threshold={})",
                            total_tokens, cfg.context_window - cfg.reserve_tokens_floor - cfg.soft_threshold_tokens)
                need_flush = True
            elif should_force_flush_by_size(session.transcript_bytes, cfg, flush_done):
                logger.info("Memory flush triggered (transcript_bytes={})", session.transcript_bytes)
                need_flush = True

        if need_flush:
            success = await run_memory_flush(session, self.provider, self.model, self.workspace, cfg)
            self._flush_done_for_cycle[session.key] = success

    async def _maybe_sync_index(self, session: Session) -> None:
        """Sync memory index on first access per session."""
        if self._memory_index is None:
            return
        if session.key in self._index_synced_sessions:
            return
        self._index_synced_sessions.add(session.key)
        try:
            await self._memory_index.sync()
        except Exception:
            logger.debug("Memory index sync failed", exc_info=True)

    def _assemble_reply(
        self,
        msg: InboundMessage,
        final_content: str | None,
        pending_media: list[str],
        mt: Any,
    ) -> OutboundMessage | None:
        sent_media = mt.sent_media_paths if isinstance(mt, MessageTool) else set()
        sent_texts = mt.sent_texts if isinstance(mt, MessageTool) else []
        deduped_media = dedupe_pending_media(pending_media, sent_media)

        if final_content and is_text_duplicate(final_content, sent_texts):
            final_content = None
        if final_content:
            final_content = strip_media_tags(final_content)

        if isinstance(mt, MessageTool) and mt._sent_in_turn and not deduped_media and not final_content:
            return None
        if final_content is None and not deduped_media:
            final_content = "I've completed processing but have no response to give."

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

    # ── Session persistence ────────────────────────────────────────────

    def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
        from datetime import datetime
        for m in messages[skip:]:
            entry = dict(m)
            role, content = entry.get("role"), entry.get("content")
            if role == "assistant" and not content and not entry.get("tool_calls"):
                continue
            if role == "tool" and isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS:
                entry["content"] = content[: self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
            elif role == "user":
                entry = self._clean_user_entry(entry, content)
                if entry is None:
                    continue
            entry.setdefault("timestamp", datetime.now().isoformat())
            session.messages.append(entry)
        session.updated_at = datetime.now()

    @staticmethod
    def _clean_user_entry(entry: dict, content: Any) -> dict | None:
        tag = ContextBuilder._RUNTIME_CONTEXT_TAG
        if isinstance(content, str) and content.startswith(tag):
            parts = content.split("\n\n", 1)
            if len(parts) > 1 and parts[1].strip():
                entry["content"] = parts[1]
            else:
                return None
        if isinstance(content, list):
            filtered = []
            for c in content:
                if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(tag):
                    continue
                if c.get("type") == "image_url" and c.get("image_url", {}).get("url", "").startswith("data:image/"):
                    filtered.append({"type": "text", "text": "[image]"})
                else:
                    filtered.append(c)
            if not filtered:
                return None
            entry["content"] = filtered
        return entry

    async def _consolidate_memory(self, session: Session, archive_all: bool = False) -> bool:
        result = await MemoryStore(self.workspace).consolidate(
            session, self.provider, self.model,
            archive_all=archive_all, memory_window=self.memory_window,
        )
        if result and self._memory_index is not None:
            asyncio.create_task(self._memory_index.sync())
        return result

    def _cleanup_staged_media(self) -> None:
        try:
            from asedclaw.media.store import MediaStore
            MediaStore(get_media_dir()).cleanup()
            MediaStore(get_media_dir("ased")).cleanup()
        except Exception:
            pass

    # ── Public API ─────────────────────────────────────────────────────

    async def process_direct(
        self,
        content: str,
        session_key: str = "cli:direct",
        channel: str = "cli",
        chat_id: str = "direct",
        on_progress: Callable[[str], Awaitable[None]] | None = None,
    ) -> str:
        msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
        response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
        return response.content if response else ""
