"""M3 agent — OSWorld-official agent interface (``predict`` / ``reset`` /
``set_api_log_dir``) with an Anthropic-Messages-API transport.

Mirrors how `lib_run_single.py` and `scripts/python/run_multienv_*.py` drive
all other OSWorld agents:

    agent = M3Agent(model="<model-name>", max_tokens=8192, temperature=0.6)
    agent.reset(runtime_logger)
    if hasattr(agent, "set_api_log_dir"):
        agent.set_api_log_dir(os.path.join(example_result_dir, "api_logs"))
    while True:
        response, actions = agent.predict(instruction, obs)
        ...

Behaviour summary:

  * 1920x1080 ORIGINAL screenshots — no LANCZOS resize.
  * ``[0, 1000]`` normalized coordinates (``coordinate_type="relative"``).
  * System prompt = ``M3_SYSTEM_PROMPT_TEMPLATE.format(date_str=...)`` passed
    as the first ``{"role": "system", ...}`` message (mapped onto Anthropic's
    top-level ``system=`` parameter inside ``_call_llm``).
  * First user turn = image + instruction text; subsequent user turns =
    image only (tool result is implicit).
  * Image truncation: keep most recent ``only_n_most_recent_images`` images,
    drop older ones in chunks of ``image_truncation_threshold``.
  * Assistant turns stored as raw text with ``<tool_call>...</tool_call>``
    wrapper restored (see ``parser.wrap_for_history``).
  * Special tokens ``WAIT`` / ``DONE`` / ``FAIL`` / ``CALL_USER`` returned
    as standalone strings (``desktop_env.env.step`` recognises them).

Transport: the model is reached through the Anthropic Messages API. Endpoint
is configured via standard Anthropic SDK env vars (``ANTHROPIC_BASE_URL`` /
``ANTHROPIC_API_KEY``), or passed directly as constructor kwargs. M3 emits
tool calls as ``<tool_call>{json}</tool_call>`` text inside the assistant's
text block (not as native Anthropic ``tool_use`` blocks); the parser handles
the text shape directly.
"""

from __future__ import annotations

import base64
import copy
import json
import logging
import os
import time
from datetime import datetime
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple

from anthropic import Anthropic
from PIL import Image

from .parser import parse_m3_response, wrap_for_history
from .prompts import M3_SYSTEM_PROMPT_TEMPLATE, MACOS_KEYBOARD_MAPPING

logger = logging.getLogger("desktopenv.agent.m3")


_IMAGE_FORMAT = os.environ.get("M3_IMAGE_FORMAT", "JPEG").upper()
_IMAGE_QUALITY = int(os.environ.get("M3_IMAGE_QUALITY", "90"))
_IMAGE_MIME = "image/jpeg" if _IMAGE_FORMAT == "JPEG" else f"image/{_IMAGE_FORMAT.lower()}"


def _encode_screenshot(image_bytes: bytes) -> Tuple[str, str]:
    """Return ``(base64_str, mime_type)`` for the on-wire image content.

    M3 expects 1920x1080 screenshots. The default JPEG encoding
    (env-overridable via ``M3_IMAGE_FORMAT`` / ``M3_IMAGE_QUALITY``)
    shrinks wire bytes ~6× vs PNG with negligible pixel difference, which
    keeps requests well under any gateway body-size limit even with
    cumulative image history. Set ``M3_IMAGE_FORMAT=PNG`` to skip the
    transcoding.
    """
    image = Image.open(BytesIO(image_bytes))
    if image.mode != "RGB":
        image = image.convert("RGB")
    buf = BytesIO()
    save_kwargs: Dict[str, Any] = {"format": _IMAGE_FORMAT}
    if _IMAGE_FORMAT == "JPEG":
        save_kwargs["quality"] = _IMAGE_QUALITY
        save_kwargs["optimize"] = True
    image.save(buf, **save_kwargs)
    return base64.b64encode(buf.getvalue()).decode("utf-8"), _IMAGE_MIME


def _split_data_url(url: str) -> Tuple[str, str]:
    """``data:image/png;base64,XXXX`` → ``("image/png", "XXXX")``."""
    if not url.startswith("data:"):
        raise ValueError(f"expected data: URL, got {url[:32]!r}...")
    head, _, payload = url.partition(",")
    media_type = head[len("data:") :].split(";")[0] or "image/png"
    return media_type, payload


def _content_to_anthropic(content: Any) -> Any:
    """Translate one OpenAI-style message ``content`` field to Anthropic shape.

    ``str`` passes through. A list of OpenAI content blocks is converted:
    ``image_url`` blocks become Anthropic ``image`` blocks with a base64
    ``source``; ``text`` blocks are preserved verbatim; unknown block types
    are passed through with a warning so a server-side change to the block
    vocabulary doesn't silently drop turns.
    """
    if isinstance(content, str):
        return content
    if not isinstance(content, list):
        return str(content)
    out: List[Dict[str, Any]] = []
    for block in content:
        btype = block.get("type")
        if btype == "text":
            out.append({"type": "text", "text": block.get("text", "")})
        elif btype == "image_url":
            url = (block.get("image_url") or {}).get("url", "")
            media_type, data = _split_data_url(url)
            out.append({
                "type": "image",
                "source": {"type": "base64", "media_type": media_type, "data": data},
            })
        elif btype == "image":
            out.append(block)
        else:
            logger.warning("Unknown content block type %r — passing through", btype)
            out.append(block)
    return out


class M3Agent:
    """M3 agent — OSWorld-official interface, Anthropic-Messages transport.

    Inputs / outputs match ``lib_run_single.py`` expectations exactly:
      * ``predict(instruction, obs) -> (response_text, pyautogui_code)``
      * ``reset(_logger=None, **kwargs)`` — clears trajectory state
      * ``set_api_log_dir(path)`` — opt-in; writes one JSON per LLM call
        with the full request body, response body and retry metadata
        (useful for offline analysis).

    Endpoint configuration follows the standard Anthropic SDK convention.
    Either pass ``base_url`` / ``api_key`` to the constructor, or set the
    ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_API_KEY`` env vars. Tokens whose
    value starts with ``sk-`` are sent as ``x-api-key`` (the SDK's
    ``api_key=`` kwarg); other strings are sent as ``Authorization: Bearer``
    (the SDK's ``auth_token=`` kwarg) — picks the right header for both
    Anthropic-style API keys and JWT-bearer proxies without an extra knob.
    """

    def __init__(
        self,
        platform: str = "ubuntu",
        model: Optional[str] = None,
        max_tokens: int = 8192,
        top_p: Optional[float] = None,
        temperature: Optional[float] = 0.6,
        action_space: str = "pyautogui",
        observation_type: str = "screenshot",
        coordinate_type: str = "relative",   # [0, 1000] normalised
        only_n_most_recent_images: int = 10,
        image_truncation_threshold: int = 20,
        system_date: Optional[str] = None,
        stop_sequences: Optional[List[str]] = None,
        client_password: str = "osworld-public-evaluation",
        base_url: Optional[str] = None,
        api_key: Optional[str] = None,
        extra_headers: Optional[Dict[str, str]] = None,
        # ───────── runner-compat kwargs (accepted but mapped/discarded) ─────────
        # ``run.py`` and ``scripts/python/run_multienv*.py`` always pass these
        # because every OSWorld baseline agent accepts them. We keep the names
        # so the canonical entrypoints work without modification.
        max_trajectory_length: Optional[int] = None,  # → mapped to only_n_most_recent_images
        a11y_tree_max_tokens: Optional[int] = None,   # → discarded (M3 is screenshot-only)
        **_unused,
    ):
        if action_space != "pyautogui":
            raise ValueError(
                f"M3Agent only supports action_space='pyautogui', got {action_space!r}"
            )
        if observation_type != "screenshot":
            raise ValueError(
                "M3Agent only supports observation_type='screenshot' (a11y_tree / som "
                f"are not supported by M3). Got {observation_type!r}"
            )
        if coordinate_type not in ("relative", "absolute"):
            raise ValueError(
                f"coordinate_type must be 'relative' or 'absolute', got {coordinate_type!r}"
            )
        if platform not in ("ubuntu", "macos", "windows"):
            logger.warning(
                "Unknown platform=%r; treating as 'ubuntu'. macOS keyboard mapping "
                "is only appended when platform='macos'.", platform,
            )

        # Resolve transport config: explicit args > env vars. ``ANTHROPIC_AUTH_TOKEN``
        # is accepted as a legacy alias for ``ANTHROPIC_API_KEY`` so old configs
        # that pre-date the rename keep working.
        resolved_base_url = base_url or os.environ.get("ANTHROPIC_BASE_URL")
        resolved_api_key = (
            api_key
            or os.environ.get("ANTHROPIC_API_KEY")
            or os.environ.get("ANTHROPIC_AUTH_TOKEN")
        )
        resolved_model = model or os.environ.get("ANTHROPIC_MODEL") or "m3"
        if not resolved_base_url or not resolved_api_key:
            raise RuntimeError(
                "M3Agent requires base_url + api_key — pass them as kwargs or set "
                "ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN) "
                "in the environment."
            )

        # Extra HTTP headers to send on every request (e.g. enterprise-gateway
        # routing hints, ``anthropic-beta`` flags, custom auth). Resolution:
        # kwarg > ``ANTHROPIC_EXTRA_HEADERS`` env var (JSON-encoded dict).
        resolved_extra_headers: Dict[str, str] = {}
        if extra_headers:
            resolved_extra_headers = dict(extra_headers)
        else:
            env_val = os.environ.get("ANTHROPIC_EXTRA_HEADERS", "").strip()
            if env_val:
                try:
                    parsed = json.loads(env_val)
                except json.JSONDecodeError as e:
                    logger.warning(
                        "ANTHROPIC_EXTRA_HEADERS is not valid JSON (%s); ignored.", e
                    )
                else:
                    if isinstance(parsed, dict):
                        resolved_extra_headers = {str(k): str(v) for k, v in parsed.items()}
                    else:
                        logger.warning(
                            "ANTHROPIC_EXTRA_HEADERS must decode to a dict, got %s; ignored.",
                            type(parsed).__name__,
                        )

        self.platform = platform
        self.model = resolved_model
        self.max_tokens = max_tokens
        self.top_p = top_p
        self.temperature = temperature
        self.action_space = action_space
        self.observation_type = observation_type
        self.coordinate_type = coordinate_type
        # Response-retry policy. Each predict() call wraps ``_call_llm`` in
        # a loop: if ``_call_llm`` raises (network / 5xx) or the response
        # parses to zero actions despite a non-empty body, resample. Costs
        # one extra LLM call per retry, capped at ``max_llm_retries``
        # (default 2, so up to 3 total attempts). Override via the
        # ``M3_MAX_LLM_RETRIES`` env var.
        self.max_llm_retries = int(os.environ.get("M3_MAX_LLM_RETRIES", "2"))
        # If the runner passed max_trajectory_length, take it as the image budget
        # (closest semantic equivalent — both bound how many past observations
        # the model still sees).
        if max_trajectory_length is not None:
            only_n_most_recent_images = max_trajectory_length
        self.only_n_most_recent_images = only_n_most_recent_images
        self.image_truncation_threshold = image_truncation_threshold
        self.system_date = system_date or datetime.today().strftime("%A, %B %d, %Y")
        self.client_password = client_password
        self.a11y_tree_max_tokens = a11y_tree_max_tokens  # kept for introspection; unused

        # Stops protect against M3 degenerate loops:
        #   - "</tool_call>" — natural turn-end token
        #   - "Perform the next action. Perform" — repeat detected, stop at 2nd
        # Without these a single bad emit can grow context by 8K and OOM by
        # step 50.
        self.stop_sequences = stop_sequences if stop_sequences is not None else [
            "</tool_call>",
            "Perform the next action. Perform",
        ]

        # Auth-style auto-detection: tokens starting with ``sk-`` (common API-key
        # convention) go via the ``x-api-key`` header (``api_key=`` kwarg).
        # Everything else (JWT bearer tokens) goes via ``Authorization: Bearer``
        # (``auth_token=`` kwarg). Each Anthropic-compatible deployment accepts
        # exactly one of these, and the prefix tells us which.
        client_kwargs: Dict[str, Any] = dict(
            base_url=resolved_base_url,
            max_retries=4,
            timeout=180.0,
        )
        if resolved_extra_headers:
            client_kwargs["default_headers"] = resolved_extra_headers
        if resolved_api_key.startswith("sk-"):
            client_kwargs["api_key"] = resolved_api_key
            auth_mode = "api_key (x-api-key)"
        else:
            client_kwargs["auth_token"] = resolved_api_key
            auth_mode = "auth_token (Authorization: Bearer)"
        self._client = Anthropic(**client_kwargs)
        logger.info(
            "M3Agent ready — base_url=%s model=%s max_tokens=%d temperature=%s auth=%s",
            resolved_base_url, self.model, self.max_tokens, self.temperature, auth_mode,
        )

        # Trajectory state — wiped by reset()
        self.thoughts: List[str] = []
        self.actions: List[str] = []
        self.observations: List[Dict] = []
        self.responses: List[str] = []     # raw assistant text per step (history-wrapped)
        self.screenshots: List[str] = []   # base64 image strings (format from M3_IMAGE_FORMAT)

        # API log capture (multi_turn.json prerequisite). lib_run_single
        # calls set_api_log_dir() if it exists.
        self._api_log_dir: Optional[str] = None
        self._api_call_count: int = 0

    # ─────────────────────────────────────────────────────────────────────
    # OSWorld lifecycle hooks (called by lib_run_single / run_multienv_*)
    # ─────────────────────────────────────────────────────────────────────

    def reset(self, _logger=None, **kwargs):
        """Clear trajectory state between tasks.

        ``lib_run_single.run_single_example`` calls
        ``agent.reset(runtime_logger, vm_ip=env.vm_ip)`` so the agent can pin
        snapshot-aware behaviour to the freshly-booted VM. M3 doesn't need
        ``vm_ip`` (the LLM transport handles routing on its own), so we
        silently absorb the kwarg — anything else passed in is also ignored
        for forward-compat with future runners.
        """
        global logger
        if _logger is not None:
            logger = _logger
        self.thoughts.clear()
        self.actions.clear()
        self.observations.clear()
        self.responses.clear()
        self.screenshots.clear()
        self._api_call_count = 0

    def set_api_log_dir(self, path: str):
        self._api_log_dir = path
        os.makedirs(path, exist_ok=True)

    # ─────────────────────────────────────────────────────────────────────
    # Prompt assembly
    # ─────────────────────────────────────────────────────────────────────

    def _build_system_prompt(self) -> str:
        prompt = M3_SYSTEM_PROMPT_TEMPLATE.format(
            date_str=self.system_date,
            client_password=self.client_password,
        )
        if self.platform == "macos":
            prompt += MACOS_KEYBOARD_MAPPING
        return prompt

    def _user_message_with_image(self, b64_img: str, text: Optional[str] = None) -> Dict:
        """Build an OpenAI-style user message with image + optional text.

        The image is delivered as a ``data:<mime>;base64,...`` URL inside
        an ``image_url`` content block — the de-facto standard accepted by
        OpenAI-compatible and most VLM endpoints. Mime type matches
        whatever ``_encode_screenshot`` produced (JPEG by default; PNG
        when configured via ``M3_IMAGE_FORMAT``). The shape is translated
        to Anthropic's ``image.source.base64`` block inside
        ``_translate_messages`` before being sent over the wire.
        """
        content: List[Dict[str, Any]] = [
            {"type": "image_url", "image_url": {"url": f"data:{_IMAGE_MIME};base64,{b64_img}"}}
        ]
        if text:
            content.append({"type": "text", "text": text})
        return {"role": "user", "content": content}

    @staticmethod
    def _user_message_tool_result_placeholder() -> Dict:
        """Placeholder user turn for steps whose screenshot was dropped by the
        image-truncation policy. Text matches what the chat template
        rendered when older retained images were still visible, so the
        truncated turn slots into the conversation seamlessly."""
        return {"role": "user", "content": [{"type": "text", "text": "Tool result: Success"}]}

    def _build_messages(self, instruction: str, current_b64: str) -> List[Dict]:
        """Assemble the full messages list for one ``predict`` call.

        Layout:
          1. system: M3_SYSTEM_PROMPT_TEMPLATE.format(date_str=...)
          2. user: image_0 (initial obs) + instruction text
          3. for i in 0..k-1:
               assistant: self.responses[i]                       (history-wrapped)
               user:      screenshot[i+1]   (or placeholder if dropped)
          4. user: current screenshot

        Image truncation: keep most recent ``K = only_n_most_recent_images``
        screenshots, drop older ones in chunks of
        ``T = image_truncation_threshold``.
        """
        K = self.only_n_most_recent_images
        T = self.image_truncation_threshold
        k = len(self.responses)
        remove = max(0, k - K)
        remove -= remove % T
        if remove > 0:
            logger.info(
                f"[ImageTruncation] step={k+1} k={k} K={K} T={T} → "
                f"drop oldest {remove} of {k} tool_result images, "
                f"keep {k - remove} + initial = {k - remove + 1}"
            )

        messages: List[Dict] = [
            {"role": "system", "content": self._build_system_prompt()},
            self._user_message_with_image(self.screenshots[0], instruction),
        ]
        for i in range(k):
            messages.append({"role": "assistant", "content": self.responses[i]})
            tool_result_idx = i + 1
            if tool_result_idx <= remove:
                messages.append(self._user_message_tool_result_placeholder())
            else:
                messages.append(self._user_message_with_image(self.screenshots[tool_result_idx]))
        # Current observation appended by predict() before this method runs,
        # so self.screenshots[-1] == current_b64 — no extra append needed.
        return messages

    # ─────────────────────────────────────────────────────────────────────
    # LLM transport — Anthropic Messages API
    # ─────────────────────────────────────────────────────────────────────

    def _translate_messages(self, messages: List[Dict]) -> Tuple[str, List[Dict]]:
        """OpenAI-style messages → ``(system_text, anthropic_messages)``.

        ``_build_messages`` produces messages in OpenAI shape (system role +
        image_url content blocks). Anthropic Messages API takes ``system`` as
        a top-level parameter and expects ``image.source.base64`` blocks, so
        we split system out and convert image blocks via
        ``_content_to_anthropic``.
        """
        system_parts: List[str] = []
        anth_messages: List[Dict] = []
        for msg in messages:
            role = msg.get("role")
            if role == "system":
                content = msg.get("content", "")
                if isinstance(content, list):
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "text":
                            system_parts.append(block.get("text", ""))
                else:
                    system_parts.append(str(content))
                continue
            anth_messages.append({
                "role": role,
                "content": _content_to_anthropic(msg.get("content", "")),
            })
        return "\n".join(s for s in system_parts if s), anth_messages

    def _build_request_body(self, messages: List[Dict]) -> Dict[str, Any]:
        """Build the actual Anthropic Messages API payload from OpenAI-shape
        messages. Used by both ``_call_llm`` (to issue the request) and
        ``predict`` (to log the payload that was sent on the wire), so the
        saved ``api_logs/NNN_*.json`` is a faithful replay of the request.
        """
        system_text, anth_messages = self._translate_messages(messages)
        body: Dict[str, Any] = {
            "model": self.model,
            "messages": anth_messages,
            "max_tokens": self.max_tokens,
        }
        if system_text:
            body["system"] = system_text
        if self.temperature is not None:
            body["temperature"] = self.temperature
        if self.top_p is not None:
            body["top_p"] = self.top_p
        if self.stop_sequences:
            body["stop_sequences"] = list(self.stop_sequences)
        # --- thinking 注入(M3_THINKING_BUDGET env 控制;设了才开)---
        import os as _os
        _tb = _os.environ.get("M3_THINKING_BUDGET", "").strip()
        _tm = _os.environ.get("M3_THINKING_MODE", "").strip().lower()
        if _tm == "adaptive":
            body["thinking"] = {"type": "adaptive"}
            if _tb.isdigit() and int(_tb) > 0:
                body["thinking"]["budget_tokens"] = int(_tb)
            body["temperature"] = 1
            body.pop("top_p", None)
        elif _tb.isdigit() and int(_tb) > 0:
            body["thinking"] = {"type": "enabled", "budget_tokens": int(_tb)}
            body["temperature"] = 1  # 开 thinking 时 temperature 必须为 1
            body.pop("top_p", None)  # thinking 模式不允许 top_p
        return body

    def _call_llm(self, messages: List[Dict]) -> Tuple[str, Any]:
        """Send ``messages`` via the Anthropic SDK and return
        ``(response_text, raw_response)``.

        Exceptions are NOT caught here — they propagate to ``predict()``'s
        retry loop, which treats them as a retryable failure (so transient
        500s from the upstream proxy don't silently no-op a whole step).

        The response body is the concatenation of all ``TextBlock.text`` in
        ``response.content``. If the model emits a ``thinking`` block (e.g.
        when extended thinking is enabled on the upstream), it is wrapped as
        ``<mm:think>...</mm:think>`` and prepended so the chat-template
        renders the thinking block consistently across turns.
        """
        body = self._build_request_body(messages)
        response = self._client.messages.create(**body)

        text_parts: List[str] = []
        for block in getattr(response, "content", []) or []:
            btype = getattr(block, "type", None)
            if btype == "text":
                text_parts.append(getattr(block, "text", ""))
            elif btype == "thinking":
                thinking = getattr(block, "thinking", "")
                if thinking and "<mm:think>" not in "".join(text_parts):
                    text_parts.insert(0, f"<mm:think>{thinking}</mm:think>\n")
        return "".join(text_parts), response

    # ─────────────────────────────────────────────────────────────────────
    # predict (canonical OSWorld agent entrypoint)
    # ─────────────────────────────────────────────────────────────────────

    def predict(self, instruction: str, obs: Dict) -> List:
        """Predict the next action.

        Returns ``[response_text, pyautogui_code]`` — same shape as every
        other OSWorld agent. ``pyautogui_code`` is a list of executable
        strings; special tokens ``WAIT`` / ``DONE`` / ``FAIL`` / ``CALL_USER``
        are kept as standalone elements.
        """
        screenshot_bytes = obs["screenshot"]
        image = Image.open(BytesIO(screenshot_bytes))
        original_width, original_height = image.size
        logger.info(f"Original screen resolution: {original_width}x{original_height}")

        # Save the very first observation under ``api_logs/..``'s sibling
        # directory as ``step_0_initial.png`` — used by trajectory viewers
        # that expect both initial obs and step screenshots present.
        if self._api_call_count == 0 and self._api_log_dir:
            try:
                screenshot_dir = os.path.dirname(self._api_log_dir)
                initial_path = os.path.join(screenshot_dir, "step_0_initial.png")
                if not os.path.exists(initial_path):
                    with open(initial_path, "wb") as f:
                        f.write(screenshot_bytes)
                    logger.info(f"Saved initial screenshot: {initial_path}")
            except Exception:
                logger.exception("Failed to save step_0_initial.png")

        processed_b64, _ = _encode_screenshot(screenshot_bytes)
        processed_img = Image.open(BytesIO(base64.b64decode(processed_b64)))
        processed_width, processed_height = processed_img.size
        logger.info(
            f"Processed image resolution: {processed_width}x{processed_height} "
            f"(M3 native, no resize)"
        )

        self.screenshots.append(processed_b64)
        messages = self._build_messages(instruction, processed_b64)

        # Build the faithful wire payload up-front so the saved api_log is a
        # true replay of what was sent (same shape Anthropic SDK receives:
        # ``system`` at top level, ``image.source.base64`` blocks, key
        # ``stop_sequences`` not ``stop``). Wrapped in try/except so a
        # logging-only failure can never crash the step — the actual API
        # request is built independently inside ``_call_llm``.
        try:
            request_body = self._build_request_body(messages)
        except Exception:
            logger.exception(
                "Failed to build request_body for logging; api_log entry "
                "will have a stub body but the live API call is unaffected."
            )
            request_body = {"_log_build_error": True}

        # ── LLM call with retry ─────────────────────────────────────────────
        # Resample up to self.max_llm_retries times if:
        #   1. _call_llm raises (network / API error)
        #   2. response parses to 0 actions despite being non-empty
        # On final failure, fall through with the last response_text so history
        # stays well-formed and the env step receives an empty action list
        # (existing no-op behaviour). Each retry's brief metadata is recorded
        # in the saved api_log so post-hoc analysis can spot retry patterns.
        retry_attempts: List[Dict[str, Any]] = []
        response_text = ""
        raw_response: Any = {"error": "no_call_made"}
        low_level_instruction = ""
        pyautogui_code: List[str] = []

        for attempt in range(self.max_llm_retries + 1):
            try:
                response_text, raw_response = self._call_llm(messages)
                logger.info(
                    f"M3 Output (attempt {attempt + 1}/{self.max_llm_retries + 1}): "
                    f"{(response_text or '')[:600]}"
                )
            except Exception as e:
                logger.exception(f"LLM call failed (attempt {attempt + 1}): {e}")
                retry_attempts.append({
                    "attempt": attempt + 1,
                    "outcome": "exception",
                    "reason": f"{type(e).__name__}: {str(e)[:200]}",
                })
                response_text = ""
                raw_response = {"error": str(e)}
                if attempt < self.max_llm_retries:
                    continue
                break

            # Parse to actions; if 0 actions yet response was non-empty → retry
            low_level_instruction, pyautogui_code = parse_m3_response(
                response_text,
                original_width=original_width,
                original_height=original_height,
                processed_width=processed_width,
                processed_height=processed_height,
                coordinate_type=self.coordinate_type,
            )
            if not pyautogui_code and response_text.strip():
                logger.warning(
                    f"[parse-empty] attempt {attempt + 1} parsed 0 actions from "
                    f"{len(response_text)}-char response; retrying"
                    if attempt < self.max_llm_retries
                    else f"[parse-empty] attempt {attempt + 1} parsed 0 actions from "
                         f"{len(response_text)}-char response; out of retries, no-op"
                )
                retry_attempts.append({
                    "attempt": attempt + 1,
                    "outcome": "parse_empty",
                    "reason": f"response_len={len(response_text)} but 0 actions parsed",
                })
                if attempt < self.max_llm_retries:
                    continue

            # accepted
            break

        self._save_api_log(request_body, raw_response, retry_attempts=retry_attempts or None)

        wrapped = wrap_for_history(response_text)
        self.responses.append(wrapped)

        self.actions.append(low_level_instruction or "(no-op)")
        if low_level_instruction:
            self.thoughts.append(low_level_instruction)

        return [response_text, pyautogui_code]

    # ─────────────────────────────────────────────────────────────────────
    # api_log persistence (multi_turn.json prerequisite)
    # ─────────────────────────────────────────────────────────────────────

    def _save_api_log(self, request_body: dict, response: Any,
                      retry_attempts: Optional[List[Dict[str, Any]]] = None):
        if not self._api_log_dir:
            return
        ts = int(time.time() * 1000)
        if hasattr(response, "model_dump"):
            response_body = response.model_dump()
        elif isinstance(response, str):
            response_body = {"choices": [{"message": {"role": "assistant", "content": response}}]}
        elif isinstance(response, dict):
            response_body = response
        else:
            response_body = {"raw": str(response)}
        entry = {
            "model": self.model,
            "request_body": copy.deepcopy(request_body),
            "response_body": response_body,
            "request_time": ts,
            "response_time": int(time.time() * 1000),
        }
        if retry_attempts:
            # Compact metadata about parse-fail / network retries that
            # happened on this step. Useful for offline analysis to spot
            # tasks where M3 needed multiple resamples to produce clean output.
            entry["_retry_attempts"] = retry_attempts
        self._api_call_count += 1
        path = os.path.join(self._api_log_dir, f"{self._api_call_count:03d}_{ts}.json")
        try:
            with open(path, "w") as f:
                json.dump(entry, f, ensure_ascii=False)
            logger.info(f"Saved API log: {path}")
        except Exception:
            logger.exception(f"Failed to save API log: {path}")
