"""Agent runtime loop."""

from __future__ import annotations

from dataclasses import dataclass
import json
import logging
from typing import Any, Callable

from memnixa.config import AppConfig
from memnixa.context import ContextBuilder
from memnixa.context_window import (
    ContextWindowResolution,
    estimate_message_tokens,
    estimate_prompt_tokens,
    is_context_overflow_error_text,
    resolve_context_window,
)
from memnixa.gateway.messages import InboundMessageContext
from memnixa.i18n import t
from memnixa.memory import (
    ALWAYS_INJECT_MEMORY_TYPES,
    MemoryCandidate,
    build_memory_context,
    build_memory_extraction_messages,
    collect_memory_review_items,
    is_likely_volatile_memory,
    parse_memory_candidates,
    resolve_allowed_memory_scopes,
    should_extract_memory,
)
from memnixa.models import LLMResponse, RunTurnResult
from memnixa.providers.manager import ProviderManager
from memnixa.providers.registry import get_provider_spec
from memnixa.runtime_env import detect_runtime_execution
from memnixa.session import SessionState, SessionStore
from memnixa.skills import SkillEntry, build_skills_prompt, discover_skills
from memnixa.tools.base import ToolRegistry
from memnixa.tools.memory import GetMemoryTool, SearchMemoryTool
from memnixa.tools.skills import ListSkillsTool, ReadSkillTool

LOGGER = logging.getLogger(__name__)
RuntimeEventCallback = Callable[[dict[str, Any]], None]


@dataclass(frozen=True, slots=True)
class PreparedContext:
    """Resolved prompt context for one turn."""

    messages: list[dict[str, Any]]
    provider_summary: str
    skills_prompt: str | None
    limits: ContextWindowResolution
    estimated_prompt_tokens: int


@dataclass(slots=True)
class AgentRuntime:
    """Session-aware runtime."""

    config: AppConfig
    providers: ProviderManager
    session: SessionStore
    tools: ToolRegistry
    context_builder: ContextBuilder

    def run_turn(
        self,
        inbound: InboundMessageContext,
        *,
        event_callback: RuntimeEventCallback | None = None,
    ) -> RunTurnResult:
        """Run one user turn through the tool loop."""
        session_state = self.session.ensure_session(
            inbound.session_id,
            provider_id=self.config.default_provider,
        )
        session_state = self.session.update_session_state(
            inbound.session_id,
            provider_id=session_state.provider_id,
            metadata=self._build_session_metadata(inbound),
        )
        command_result = self._handle_control_command(inbound, session_state)
        if command_result is not None:
            return command_result

        active_provider_id = session_state.provider_id or self.config.default_provider
        provider_profile = self.providers.get_profile(active_provider_id)
        provider_spec = get_provider_spec(provider_profile.preset)
        limits = resolve_context_window(self.config, provider_profile, provider_spec)
        self.session.update_context_metrics(
            inbound.session_id,
            resolved_context_window_tokens=limits.context_window_tokens,
            max_output_tokens=limits.max_output_tokens,
            input_budget_tokens=limits.input_budget_tokens,
        )

        provider_description = self.providers.describe(active_provider_id)
        provider_summary = (
            f"{provider_description['id']} / "
            f"{provider_description['label']} / "
            f"{provider_description['model']}"
        )
        skill_entries = discover_skills(self.config.workspace)
        skills_prompt = build_skills_prompt(skill_entries)
        turn_tools = self._build_turn_tools(inbound, skill_entries=skill_entries)
        tool_summaries = turn_tools.summaries()
        provider = self.providers.build(active_provider_id)
        prepared = self._prepare_context(
            inbound=inbound,
            provider_id=active_provider_id,
            provider=provider,
            provider_summary=provider_summary,
            skills_prompt=skills_prompt,
            limits=limits,
            tool_summaries=tool_summaries,
            tools=turn_tools,
        )

        self.session.append(
            {
                "role": "user",
                "content": inbound.body_for_agent,
                "run_id": inbound.run_id,
                "message_id": inbound.message_id,
                "raw_body": inbound.raw_body,
                "session_id": inbound.session_id,
                "provider_id": active_provider_id,
            },
            session_id=inbound.session_id,
        )

        tool_events: list[dict[str, Any]] = []
        runtime_events: list[dict[str, Any]] = []
        final_reply = "No reply produced."
        iteration = 0
        messages = [dict(item) for item in prepared.messages]
        overflow_retries = 0

        while iteration < self.config.max_iterations:
            iteration += 1
            estimated_for_iteration = self._estimate_prompt_tokens(
                provider,
                messages,
                turn_tools.schemas(),
            )
            self.session.update_context_metrics(
                inbound.session_id,
                estimated_prompt_tokens=estimated_for_iteration,
                needs_compaction=(
                    estimated_for_iteration >= prepared.limits.compact_threshold_tokens
                ),
            )

            response = provider.chat(messages=messages, tools=turn_tools.schemas())
            actual_prompt_tokens = self._extract_actual_prompt_tokens(response)
            self.session.update_context_metrics(
                inbound.session_id,
                actual_prompt_tokens=actual_prompt_tokens,
                needs_compaction=(
                    actual_prompt_tokens is not None
                    and actual_prompt_tokens >= prepared.limits.compact_threshold_tokens
                ),
            )

            if response.finish_reason == "error":
                if (
                    overflow_retries < 2
                    and is_context_overflow_error_text(response.content)
                    and self._compact_session(
                        inbound=inbound,
                        provider=provider,
                        provider_id=active_provider_id,
                        provider_summary=prepared.provider_summary,
                        tool_summaries=tool_summaries,
                        limits=prepared.limits,
                        aggressive=True,
                    )
                ):
                    overflow_retries += 1
                    messages = self._rebuild_messages(
                        inbound=inbound,
                        provider_summary=prepared.provider_summary,
                        skills_prompt=prepared.skills_prompt,
                        tool_summaries=tool_summaries,
                    )
                    continue
                final_reply = response.content or final_reply
                break

            assistant_message = {
                "role": "assistant",
                "content": response.content,
                "provider_id": active_provider_id,
                "model_name": provider.model_name,
            }
            if response.has_tool_calls:
                assistant_message["tool_calls"] = [
                    tool_call.as_openai_payload() for tool_call in response.tool_calls
                ]
            messages.append(assistant_message)
            self.session.append(assistant_message, session_id=inbound.session_id)

            if not response.has_tool_calls:
                final_reply = response.content or final_reply
                break

            for tool_call in response.tool_calls:
                result = turn_tools.execute(tool_call.name, tool_call.arguments)
                tool_event = {
                    "tool_name": tool_call.name,
                    "arguments": tool_call.arguments,
                    "result": result.as_dict(
                        name=tool_call.name,
                        arguments=tool_call.arguments,
                    ),
                    "run_id": inbound.run_id,
                }
                tool_events.append(tool_event)
                self._emit_runtime_event(
                    runtime_events,
                    event_callback,
                    {
                        "type": "tool",
                        "tool_index": len(tool_events),
                        **tool_event,
                    },
                )
                tool_message = {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "name": tool_call.name,
                    "run_id": inbound.run_id,
                    "provider_id": active_provider_id,
                    "content": turn_tools.stringify_result(
                        tool_call.name,
                        tool_call.arguments,
                        result,
                    ),
                }
                messages.append(tool_message)
                self.session.append(tool_message, session_id=inbound.session_id)
        else:
            final_reply = (
                f"Stopped after reaching max_iterations={self.config.max_iterations}."
            )

        try:
            self._extract_and_store_memory(
                inbound=inbound,
                reply=final_reply,
                tool_events=tool_events,
                runtime_events=runtime_events,
                event_callback=event_callback,
            )
        except Exception as exc:
            LOGGER.warning("memory extraction skipped due to error: %s", exc)
            self._emit_runtime_event(
                runtime_events,
                event_callback,
                {
                    "type": "memory",
                    "phase": "error",
                    "message": str(exc),
                    "error_type": type(exc).__name__,
                },
            )

        return RunTurnResult(
            reply=final_reply,
            iterations=iteration,
            tool_events=tool_events,
            runtime_events=runtime_events,
            provider_id=provider_description["id"],
            provider_label=provider_description["label"],
            model_name=provider.model_name,
        )

    @staticmethod
    def _build_session_metadata(inbound: InboundMessageContext) -> dict[str, Any]:
        execution = detect_runtime_execution(
            entrypoint_path=inbound.entrypoint_path,
            python_executable=inbound.python_executable,
        )
        metadata: dict[str, Any] = {}
        for key, value in {
            "source_mode": inbound.source_mode,
            "channel": inbound.channel,
            "launch_cwd": inbound.launch_cwd,
            "entrypoint_path": inbound.entrypoint_path,
            "python_executable": inbound.python_executable,
            "runtime_mode": execution.runtime_mode,
            "install_mode": execution.install_mode,
            "actor_identity_status": inbound.actor_identity_status,
            "actor_user_id": inbound.actor_user_id,
            "actor_role": inbound.actor_role,
            "actor_external_id": inbound.actor_external_id,
        }.items():
            if value:
                metadata[key] = value
        metadata["actor_is_owner"] = inbound.actor_is_owner
        if inbound.capabilities:
            metadata["channel_capabilities"] = list(inbound.capabilities)
        return metadata

    def _prepare_context(
        self,
        *,
        inbound: InboundMessageContext,
        provider_id: str,
        provider: Any,
        provider_summary: str,
        skills_prompt: str | None,
        limits: ContextWindowResolution,
        tool_summaries: list[str],
        tools: ToolRegistry,
    ) -> PreparedContext:
        rounds = 0
        messages = self._rebuild_messages(
            inbound=inbound,
            provider_summary=provider_summary,
            skills_prompt=skills_prompt,
            tool_summaries=tool_summaries,
        )
        estimated_tokens = self._estimate_prompt_tokens(
            provider,
            messages,
            tools.schemas(),
        )
        self.session.update_context_metrics(
            inbound.session_id,
            estimated_prompt_tokens=estimated_tokens,
            needs_compaction=estimated_tokens >= limits.compact_threshold_tokens,
        )

        while (
            estimated_tokens >= limits.compact_threshold_tokens
            and rounds < self.config.context_compaction_max_rounds
        ):
            did_compact = self._compact_session(
                inbound=inbound,
                provider=provider,
                provider_id=provider_id,
                provider_summary=provider_summary,
                tool_summaries=tool_summaries,
                limits=limits,
                aggressive=False,
            )
            if not did_compact:
                break
            rounds += 1
            messages = self._rebuild_messages(
                inbound=inbound,
                provider_summary=provider_summary,
                skills_prompt=skills_prompt,
                tool_summaries=tool_summaries,
            )
            estimated_tokens = self._estimate_prompt_tokens(
                provider,
                messages,
                tools.schemas(),
            )
            self.session.update_context_metrics(
                inbound.session_id,
                estimated_prompt_tokens=estimated_tokens,
                needs_compaction=estimated_tokens >= limits.compact_threshold_tokens,
            )

        return PreparedContext(
            messages=messages,
            provider_summary=provider_summary,
            skills_prompt=skills_prompt,
            limits=limits,
            estimated_prompt_tokens=estimated_tokens,
        )

    def _build_turn_tools(
        self,
        inbound: InboundMessageContext,
        *,
        skill_entries: list[SkillEntry],
    ) -> ToolRegistry:
        registry = self.tools.copy()
        if skill_entries:
            registry.register(ListSkillsTool(skill_entries))
            registry.register(ReadSkillTool(skill_entries))
        if self.config.memory.enabled:
            registry.register(
                SearchMemoryTool(
                    session=self.session,
                    config=self.config,
                    inbound=inbound,
                )
            )
            registry.register(GetMemoryTool(session=self.session))
        return registry

    def _rebuild_messages(
        self,
        *,
        inbound: InboundMessageContext,
        provider_summary: str,
        skills_prompt: str | None,
        tool_summaries: list[str],
    ) -> list[dict[str, Any]]:
        history = self.session.load_active_history(session_id=inbound.session_id)
        compaction_summary = self.session.load_compaction_summary(inbound.session_id)
        memory_context = self._build_memory_context(inbound)
        return self.context_builder.build_messages(
            history,
            inbound,
            tool_summaries,
            provider_summary,
            compaction_summary=compaction_summary,
            memory_context=memory_context,
            skills_prompt=skills_prompt,
        )

    def _build_memory_context(self, inbound: InboundMessageContext) -> str | None:
        if not self.config.memory.enabled:
            return None
        scopes = resolve_allowed_memory_scopes(inbound=inbound, config=self.config)
        stable_items = self.session.list_memory_items(
            scopes=scopes,
            memory_types=sorted(ALWAYS_INJECT_MEMORY_TYPES),
            limit=6,
        )
        relevant_items = self.session.search_memory(
            inbound.body_for_agent,
            scopes=scopes,
            limit=self.config.memory.retrieval_limit,
        )
        stable_ids = {item.id for item in stable_items}
        filtered_relevant = [
            item for item in relevant_items if int(item.get("id") or 0) not in stable_ids
        ]
        return build_memory_context(
            stable_items=stable_items,
            relevant_items=filtered_relevant,
            max_chars=self.config.memory.max_injected_chars,
        )

    def _extract_and_store_memory(
        self,
        *,
        inbound: InboundMessageContext,
        reply: str,
        tool_events: list[dict[str, Any]],
        runtime_events: list[dict[str, Any]],
        event_callback: RuntimeEventCallback | None,
    ) -> None:
        if not self.config.memory.enabled:
            return
        if not should_extract_memory(inbound, reply):
            return
        extractor_provider_id = self.config.memory.extraction_provider_id
        if not extractor_provider_id:
            return
        allowed_scopes = resolve_allowed_memory_scopes(inbound=inbound, config=self.config)
        existing_items = self._collect_existing_memory_review_items(
            inbound=inbound,
            reply=reply,
            allowed_scopes=allowed_scopes,
        )
        extractor = self.providers.build(extractor_provider_id)
        if hasattr(extractor, "timeout"):
            extractor.timeout = self.config.memory.extraction_timeout_seconds
        self._emit_runtime_event(
            runtime_events,
            event_callback,
            {
                "type": "memory",
                "phase": "start",
                "provider_id": extractor_provider_id,
                "model_name": getattr(extractor, "model_name", None),
                "timeout_seconds": self.config.memory.extraction_timeout_seconds,
                "message": (
                    "Starting memory extraction with provider "
                    f"{extractor_provider_id}"
                    + (
                        f" / {extractor.model_name}"
                        if getattr(extractor, "model_name", None)
                        else ""
                    )
                    + (
                        f" (timeout={self.config.memory.extraction_timeout_seconds}s)."
                    )
                ),
            },
        )
        extraction_messages = build_memory_extraction_messages(
            inbound=inbound,
            reply=reply,
            tool_events=tool_events,
            allowed_scopes=allowed_scopes,
            existing_items=existing_items,
        )
        response = extractor.chat(messages=extraction_messages, tools=None)
        if response.finish_reason == "error":
            LOGGER.warning(
                "memory extraction provider %s returned error: %s",
                extractor_provider_id,
                response.content or "(empty error)",
            )
            self._emit_runtime_event(
                runtime_events,
                event_callback,
                {
                    "type": "memory",
                    "phase": "error",
                    "provider_id": extractor_provider_id,
                    "model_name": getattr(extractor, "model_name", None),
                    "timeout_seconds": self.config.memory.extraction_timeout_seconds,
                    "message": response.content or "Memory extraction failed.",
                },
            )
            return
        candidates = parse_memory_candidates(
            response,
            allowed_scope_ids=set(allowed_scopes),
            existing_memory_ids={item.id for item in existing_items if int(item.id) > 0},
        )
        stored_count = 0
        ignored_count = 0
        for candidate in candidates:
            if candidate.action != "upsert":
                ignored_count += 1
                continue
            if not self._should_store_memory_candidate(candidate):
                ignored_count += 1
                continue
            metadata = dict(candidate.metadata or {})
            metadata["extractor_provider_id"] = extractor_provider_id
            if inbound.actor_user_id:
                metadata["actor_user_id"] = inbound.actor_user_id
            if candidate.reason:
                metadata["review_reason"] = candidate.reason
            self.session.upsert_memory_item(
                existing_id=candidate.existing_id,
                scope_type=candidate.scope_type,
                scope_id=candidate.scope_id,
                memory_type=candidate.memory_type,
                content=candidate.content,
                conflict_key=candidate.conflict_key,
                source_session_id=inbound.session_id,
                source_message_id=inbound.message_id,
                confidence=candidate.confidence,
                metadata=metadata,
            )
            stored_count += 1
        self._emit_runtime_event(
            runtime_events,
            event_callback,
            {
                "type": "memory",
                "phase": "complete",
                "provider_id": extractor_provider_id,
                "model_name": getattr(extractor, "model_name", None),
                "candidate_count": len(candidates),
                "stored_count": stored_count,
                "ignored_count": ignored_count,
                "message": (
                    "Memory extraction finished: "
                    f"{stored_count} stored / {ignored_count} ignored / {len(candidates)} candidate(s)."
                ),
            },
        )

    def _collect_existing_memory_review_items(
        self,
        *,
        inbound: InboundMessageContext,
        reply: str,
        allowed_scopes: list[tuple[str, str]],
    ) -> list[Any]:
        recent_items = self.session.list_memory_items(
            scopes=allowed_scopes,
            limit=8,
        )
        search_queries = [inbound.body_for_agent.strip(), (reply or "").strip()]
        searched_items: list[dict[str, Any]] = []
        for query in search_queries:
            if not query:
                continue
            searched_items.extend(
                self.session.search_memory(
                    query[:240],
                    scopes=allowed_scopes,
                    limit=6,
                    mark_recalled=False,
                )
            )
        return collect_memory_review_items(
            recent_items=recent_items,
            searched_items=searched_items,
        )

    @staticmethod
    def _should_store_memory_candidate(candidate: MemoryCandidate) -> bool:
        if candidate.action != "upsert":
            return False
        if candidate.confidence < 0.35:
            return False
        lowered = candidate.content.lower()
        sensitive_markers = (
            "api key",
            "token",
            "password",
            "cookie",
            "secret",
            "access_key",
            "authorization:",
        )
        if any(marker in lowered for marker in sensitive_markers):
            return False
        if is_likely_volatile_memory(candidate.content, candidate.metadata):
            return False
        return True

    @staticmethod
    def _emit_runtime_event(
        runtime_events: list[dict[str, Any]],
        event_callback: RuntimeEventCallback | None,
        event: dict[str, Any],
    ) -> dict[str, Any]:
        runtime_events.append(event)
        if event_callback is not None:
            event_callback(event)
        return event

    def _compact_session(
        self,
        *,
        inbound: InboundMessageContext,
        provider: Any,
        provider_id: str,
        provider_summary: str,
        tool_summaries: list[str],
        limits: ContextWindowResolution,
        aggressive: bool,
    ) -> bool:
        candidates = self.session.list_compaction_candidates(session_id=inbound.session_id)
        chunk = self._pick_compaction_chunk(
            candidates,
            limits=limits,
            aggressive=aggressive,
        )
        if not chunk:
            return False

        previous_summary = self.session.load_compaction_summary(inbound.session_id)
        summary_messages = self._build_compaction_prompt(previous_summary, chunk)
        summary_response = provider.chat(messages=summary_messages, tools=None)
        if summary_response.finish_reason == "error":
            return False
        summary_text = (summary_response.content or "").strip()
        if not summary_text:
            return False

        self.session.record_compaction(
            session_id=inbound.session_id,
            start_message_id=chunk[0]["_message_id"],
            end_message_id=chunk[-1]["_message_id"],
            summary=summary_text,
            message_count=len(chunk),
            estimated_tokens=sum(
                estimate_message_tokens(message)
                for message in [
                    converted
                    for converted in (
                        self.session._to_llm_message(row) for row in chunk
                    )
                    if converted is not None
                ]
            ),
            provider_id=provider_id,
            model_name=getattr(provider, "model_name", None),
        )
        self.session.update_context_metrics(
            inbound.session_id,
            needs_compaction=False,
            input_budget_tokens=limits.input_budget_tokens,
        )
        return True

    def _pick_compaction_chunk(
        self,
        candidates: list[dict[str, Any]],
        *,
        limits: ContextWindowResolution,
        aggressive: bool,
    ) -> list[dict[str, Any]]:
        visible = [row for row in candidates if row.get("role") in {"user", "assistant", "tool"}]
        if not visible:
            return []
        preserve_turns = self._resolve_preserve_recent_turns(aggressive=aggressive)
        user_indexes = [
            index for index, row in enumerate(visible) if row.get("role") == "user"
        ]
        if len(user_indexes) <= preserve_turns:
            return []
        preserve_start = user_indexes[-preserve_turns]
        max_chunk_tokens = max(1024, limits.input_budget_tokens // 2)
        chunk: list[dict[str, Any]] = []
        accumulated_tokens = 0
        for row in visible[:preserve_start]:
            converted = self.session._to_llm_message(row)
            estimated = estimate_message_tokens(converted) if converted is not None else 64
            if chunk and accumulated_tokens + estimated > max_chunk_tokens:
                break
            chunk.append(row)
            accumulated_tokens += estimated
        while chunk and chunk[-1].get("role") != "assistant":
            chunk = chunk[:-1]
        return chunk

    def _resolve_preserve_recent_turns(self, *, aggressive: bool) -> int:
        base = max(1, self.config.history_limit // 4)
        capped = min(6, base)
        if aggressive:
            return max(1, min(2, capped))
        return max(2, capped)

    @staticmethod
    def _build_compaction_prompt(
        previous_summary: str | None,
        chunk: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        lines = []
        for row in chunk:
            role = str(row.get("role") or "unknown").upper()
            content = row.get("content")
            rendered = content if isinstance(content, str) else json.dumps(
                content, ensure_ascii=False
            )
            if row.get("tool_calls"):
                rendered = (
                    f"{rendered}\nTool Calls: "
                    f"{json.dumps(row['tool_calls'], ensure_ascii=False)}"
                )
            if row.get("tool_call_id"):
                rendered = (
                    f"{rendered}\nTool Call ID: {row.get('tool_call_id')}\n"
                    f"Tool Name: {row.get('name')}"
                )
            lines.append(f"[message_id={row['_message_id']}] {role}: {rendered}")

        prior = previous_summary.strip() if previous_summary else "(none)"
        prompt = (
            "Merge the older conversation history into one compact continuation summary.\n"
            "Preserve:\n"
            "- the latest user goals and pending asks\n"
            "- important decisions and constraints\n"
            "- exact file paths, command names, IDs, model names, and channel details\n"
            "- unresolved tool results or failures\n"
            "- active implementation status\n\n"
            "Be concise but do not lose durable context.\n\n"
            "## Previous Summary\n"
            f"{prior}\n\n"
            "## Messages To Compact\n"
            f"{chr(10).join(lines)}"
        )
        return [
            {
                "role": "system",
                "content": (
                    "You summarize older agent conversation history so the active "
                    "context window can stay small without losing continuity."
                ),
            },
            {"role": "user", "content": prompt},
        ]

    @staticmethod
    def _estimate_prompt_tokens(
        provider: Any,
        messages: list[dict[str, Any]],
        tools: list[dict[str, Any]] | None,
    ) -> int:
        estimator = getattr(provider, "estimate_prompt_tokens", None)
        if callable(estimator):
            try:
                estimated = estimator(messages, tools)
                if isinstance(estimated, tuple):
                    tokens = estimated[0]
                else:
                    tokens = estimated
                if isinstance(tokens, int) and tokens > 0:
                    return tokens
            except Exception:
                pass
        return estimate_prompt_tokens(messages, tools)

    @staticmethod
    def _extract_actual_prompt_tokens(response: LLMResponse) -> int | None:
        usage = response.usage
        if not isinstance(usage, dict):
            return None
        for key in (
            "prompt_tokens",
            "input_tokens",
            "promptTokens",
            "input",
        ):
            value = usage.get(key)
            if isinstance(value, int) and value > 0:
                return value
        return None

    def _handle_control_command(
        self,
        inbound: InboundMessageContext,
        session_state: SessionState,
    ) -> RunTurnResult | None:
        """Handle built-in slash commands without calling the model."""
        text = inbound.body_for_agent.strip()
        if text == "/models":
            description = self._format_models(session_state.provider_id)
            self._record_control_command(
                inbound,
                command_text=text,
                reply_text=description,
                provider_id=session_state.provider_id or self.config.default_provider,
            )
            provider_description = self.providers.describe(session_state.provider_id)
            return RunTurnResult(
                reply=description,
                iterations=0,
                provider_id=provider_description["id"],
                provider_label=provider_description["label"],
                model_name=provider_description["model"],
            )

        if text == "/runtime":
            description = self._format_runtime(inbound)
            provider_description = self.providers.describe(session_state.provider_id)
            self._record_control_command(
                inbound,
                command_text=text,
                reply_text=description,
                provider_id=provider_description["id"],
            )
            return RunTurnResult(
                reply=description,
                iterations=0,
                provider_id=provider_description["id"],
                provider_label=provider_description["label"],
                model_name=provider_description["model"],
            )

        if text == "/whoami":
            description = self._format_actor_identity(inbound)
            provider_description = self.providers.describe(session_state.provider_id)
            self._record_control_command(
                inbound,
                command_text=text,
                reply_text=description,
                provider_id=provider_description["id"],
            )
            return RunTurnResult(
                reply=description,
                iterations=0,
                provider_id=provider_description["id"],
                provider_label=provider_description["label"],
                model_name=provider_description["model"],
            )

        if text.startswith("/use "):
            provider_id = text.removeprefix("/use ").strip()
            if not provider_id:
                return None
            try:
                profile = self.providers.get_profile(provider_id)
            except ValueError as exc:
                provider_description = self.providers.describe(session_state.provider_id)
                return RunTurnResult(
                    reply=str(exc),
                    iterations=0,
                    provider_id=provider_description["id"],
                    provider_label=provider_description["label"],
                    model_name=provider_description["model"],
                )
            state = self.session.update_session_state(
                inbound.session_id,
                provider_id=profile.id,
            )
            reply = t(
                "agent.use.switched",
                id=profile.id,
                label=profile.label or profile.preset,
                model=profile.model or "(default)",
            )
            self._record_control_command(
                inbound,
                command_text=text,
                reply_text=reply,
                provider_id=state.provider_id or profile.id,
            )
            provider_description = self.providers.describe(state.provider_id)
            return RunTurnResult(
                reply=reply,
                iterations=0,
                provider_id=provider_description["id"],
                provider_label=provider_description["label"],
                model_name=provider_description["model"],
            )

        return None

    def _record_control_command(
        self,
        inbound: InboundMessageContext,
        *,
        command_text: str,
        reply_text: str,
        provider_id: str,
    ) -> None:
        self.session.append(
            {
                "role": "command",
                "content": command_text,
                "run_id": inbound.run_id,
                "message_id": inbound.message_id,
                "provider_id": provider_id,
            },
            session_id=inbound.session_id,
        )
        self.session.append(
            {
                "role": "system",
                "content": reply_text,
                "run_id": inbound.run_id,
                "message_id": inbound.message_id,
                "provider_id": provider_id,
            },
            session_id=inbound.session_id,
        )

    def _format_models(self, active_provider_id: str | None) -> str:
        current_id = active_provider_id or self.config.default_provider
        lines = [t("agent.models.title")]
        for profile in self.providers.list_profiles():
            description = self.providers.describe(profile.id)
            marker = "*" if description["id"] == current_id else " "
            lines.append(
                f"{marker} {description['id']}. "
                f"{description['label']} "
                f"[preset={description['preset']}, model={description['model']}]"
            )
        lines.append(t("agent.models.switch_hint"))
        return "\n".join(lines)

    @staticmethod
    def _format_actor_identity(inbound: InboundMessageContext) -> str:
        lines = [t("agent.identity.title")]
        lines.append(
            t("agent.identity.status", value=inbound.actor_identity_status or "unknown")
        )
        lines.append(t("agent.identity.role", value=inbound.actor_role or "unknown"))
        lines.append(
            t(
                "agent.identity.user_id",
                value=inbound.actor_user_id or "(unresolved)",
            )
        )
        lines.append(
            t(
                "agent.identity.external_id",
                value=inbound.actor_external_id or "(unknown)",
            )
        )
        lines.append(
            t(
                "agent.identity.is_owner",
                value="true" if inbound.actor_is_owner else "false",
            )
        )
        lines.append(t("agent.identity.channel", value=inbound.channel or "unknown"))
        lines.append(t("agent.identity.session", value=inbound.session_id))
        return "\n".join(lines)

    def _format_runtime(self, inbound: InboundMessageContext) -> str:
        execution = detect_runtime_execution(
            entrypoint_path=inbound.entrypoint_path,
            python_executable=inbound.python_executable,
        )
        lines = [t("agent.runtime.title")]
        lines.append(f"- runtime_mode: {execution.runtime_mode}")
        lines.append(f"- install_mode: {execution.install_mode}")
        lines.append(f"- source_mode: {inbound.source_mode or 'unknown'}")
        lines.append(f"- channel: {inbound.channel or 'unknown'}")
        lines.append(f"- launch_cwd: {inbound.launch_cwd or '(unknown)'}")
        lines.append(f"- entrypoint_path: {inbound.entrypoint_path or '(unknown)'}")
        lines.append(
            f"- python_executable: {inbound.python_executable or '(unknown)'}"
        )
        if execution.project_root:
            lines.append(f"- project_root: {execution.project_root}")
        lines.append(f"- package_root: {execution.package_root}")
        if execution.dist_info_path:
            lines.append(f"- dist_info_path: {execution.dist_info_path}")
        return "\n".join(lines)
