"""Consolidated executor — directly executes GUI, coding, and file tools.

Replaces the previous dispatcher pattern where the executor routed tasks to
separate sub-agents (CodingAgent, GUIAgent, ChromeAgent). Now a single LLM
has all tools and decides what to do directly, eliminating the routing overhead.
"""

from __future__ import annotations

from typing import Any

from .config import config
from .llm_client import LLMClient
from .prompts import CONSOLIDATED_EXECUTOR_SYSTEM_PROMPT
from .task_logger import get_agent_logger, log_agent_api_call, save_screenshot_bytes
from .tools import (
    ADD_MILESTONE,
    CHROME_CDP,
    CLICK,
    CLOSE_APPLICATION,
    DELETE_MILESTONE,
    DRAG_AND_DROP,
    HOLD_AND_PRESS,
    HOTKEY,
    INSTALL_PACKAGE,
    LIST_WINDOWS,
    MARK_MILESTONE_DONE,
    MARK_SUB_MILESTONE_DONE,
    OPEN,
    READ_FILE,
    RUN_BACKGROUND,
    RUN_BASH,
    RUN_PYTHON,
    SCREENSHOT,
    SCROLL,
    SET_SUB_MILESTONES,
    SWITCH_APPLICATIONS,
    TASK_DONE,
    TASK_FAIL,
    TYPE,
    UPDATE_MILESTONE,
    WAIT,
    WRITE_FILE,
    log_to_bash_history,
    read_file_on_vm,
    run_bash_on_vm,
    run_cdp_on_vm,
    run_in_background_on_vm,
    run_python_on_vm,
    take_screenshot_on_vm,
    write_file_on_vm,
)
from .types import AgentState, ExecutorAction, ExecutorDecision
from .utils import ToolResult

_task_log = get_agent_logger("executor")
EXECUTOR_TOOLS = [
    RUN_PYTHON,
    RUN_BASH,
    READ_FILE,
    WRITE_FILE,
    INSTALL_PACKAGE,
    RUN_BACKGROUND,
    CHROME_CDP,
    CLICK,
    TYPE,
    SCROLL,
    DRAG_AND_DROP,
    HOTKEY,
    HOLD_AND_PRESS,
    WAIT,
    OPEN,
    SWITCH_APPLICATIONS,
    CLOSE_APPLICATION,
    LIST_WINDOWS,
    SCREENSHOT,
    MARK_MILESTONE_DONE,
    UPDATE_MILESTONE,
    ADD_MILESTONE,
    DELETE_MILESTONE,
    SET_SUB_MILESTONES,
    MARK_SUB_MILESTONE_DONE,
    TASK_DONE,
    TASK_FAIL,
]

_KEY_MAP = {
    "page_down": "pagedown",
    "page_up": "pageup",
    "super_l": "win",
    "super": "command",
    "escape": "esc",
}

_GUI_TOOL_RESULT_TEXT = "GUI action submitted. The result will be visible in the next screenshot."

_SWITCH_APPLICATIONS_TEMPLATE = """import subprocess
import difflib
import pyautogui
import time
pyautogui.press('escape')
time.sleep(0.5)
output = subprocess.check_output(['wmctrl', '-lx']).decode('utf-8').splitlines()
window_titles = [line.split(None, 4)[2] for line in output]
closest_matches = difflib.get_close_matches({app_code!r}, window_titles, n=1, cutoff=0.1)
if closest_matches:
    closest_match = closest_matches[0]
    window_id = None
    for line in output:
        if closest_match in line:
            window_id = line.split()[0]
            break
    if window_id:
        subprocess.run(['wmctrl', '-ia', window_id])
        subprocess.run(['wmctrl', '-ir', window_id, '-b', 'add,maximized_vert,maximized_horz'])
        time.sleep(5)
"""

_CLOSE_APPLICATION_TEMPLATE = """import subprocess
import difflib
import time
output = subprocess.check_output(['wmctrl', '-lx']).decode('utf-8').splitlines()
window_titles = [line.split(None, 4)[2] for line in output]
closest_matches = difflib.get_close_matches({app_code!r}, window_titles, n=1, cutoff=0.1)
if closest_matches:
    closest_match = closest_matches[0]
    window_id = None
    for line in output:
        if closest_match in line:
            window_id = line.split()[0]
            break
    if window_id:
        subprocess.run(['wmctrl', '-ic', window_id])
        time.sleep(5)
"""

_PYAUTOGUI_SLEEP_SENTINEL = "pyautogui.sleep(2); "


def _handle_run_python(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Execute Python code on the VM and feed the result back to the LLM."""
    code = inp.get("code", "")
    result = run_python_on_vm(executor.env, code)
    log_to_bash_history(executor.env, f"python3 -c {code!r}")
    return None, result


def _handle_run_bash(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Execute a bash script on the VM and feed the result back to the LLM."""
    result = run_bash_on_vm(executor.env, inp.get("script", ""), timeout=inp.get("timeout", 120))
    log_to_bash_history(executor.env, inp.get("script", ""))
    return None, result


def _handle_read_file(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Read a file from the VM and feed the contents back to the LLM."""
    result = read_file_on_vm(executor.env, inp.get("path", ""))
    return None, result


def _handle_write_file(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Write content to a file on the VM and feed the result back to the LLM."""
    result = write_file_on_vm(executor.env, inp.get("path"), inp.get("content"))
    return None, result


def _handle_install_package(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Install a package in the background, optionally verify, and feed result to LLM."""
    command = inp.get("command", "")
    verification = inp.get("verification")

    result = run_in_background_on_vm(executor.env, command, poll_interval=60, max_polls=10)
    log_to_bash_history(executor.env, command)

    if verification and not result.error:
        verify_result = run_bash_on_vm(executor.env, verification, timeout=15)
        verification_text = (
            f"\n\nVerification ({verification}):\n{verify_result.output or verify_result.error or '(no output)'}"
        )
        result = ToolResult(output=(result.output or "") + verification_text)
    return None, result


def _handle_run_background(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Run a long-running command in the background and feed result to LLM."""
    command = inp.get("command", "")
    timeout = min(inp.get("timeout", 600), 600)
    max_polls = max(1, timeout // 60)
    result = run_in_background_on_vm(executor.env, command, poll_interval=60, max_polls=max_polls)
    log_to_bash_history(executor.env, command)
    return None, result


def _scale_xy(coordinate: list[int] | tuple[int, int], resize_factor: tuple[float, float]) -> tuple[int, int]:
    """Scale a 1280x720-space coordinate to the actual screen size."""
    return int(coordinate[0] * resize_factor[0]), int(coordinate[1] * resize_factor[1])


def _handle_click(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `click` tool — emit pyautogui click code and break to harness."""
    coordinate = inp.get("coordinate") or [0, 0]
    num_clicks = inp.get("num_clicks", 1)
    button_type = inp.get("button_type", "left")
    hold_keys = inp.get("hold_keys", []) or []
    if isinstance(hold_keys, str):
        hold_keys = [hold_keys]

    x, y = _scale_xy(coordinate, executor.resize_factor)

    pycode = "import pyautogui; "
    for key in hold_keys:
        k = _KEY_MAP.get(str(key).lower(), str(key).lower())
        pycode += f"pyautogui.keyDown({k!r}); "
    pycode += f"pyautogui.click(x={x}, y={y}, clicks={num_clicks}, button={button_type!r}); "
    for key in hold_keys:
        k = _KEY_MAP.get(str(key).lower(), str(key).lower())
        pycode += f"pyautogui.keyUp({k!r}); "

    pycode += _PYAUTOGUI_SLEEP_SENTINEL
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_type(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `type` tool — optional click-to-focus, then type via write or paste.

    Uses the clipboard (pyperclip) for any text containing non-ASCII characters;
    this is more reliable than pyautogui.write() for unicode on Linux.
    """
    text = inp.get("text", "") or ""
    coordinate = inp.get("coordinate")
    overwrite = bool(inp.get("overwrite", False))
    enter = bool(inp.get("enter", False))

    use_clipboard = any(ord(c) > 127 for c in text)

    pycode = "import pyautogui; import time; "
    if use_clipboard:
        pycode += (
            "try:\n"
            "    import pyperclip\n"
            "except ImportError:\n"
            "    import subprocess; subprocess.check_call([subprocess.sys.executable, '-m', 'pip', 'install', 'pyperclip']); import pyperclip\n"
        )

    if coordinate:
        x, y = _scale_xy(coordinate, executor.resize_factor)
        pycode += f"pyautogui.click(x={x}, y={y}); time.sleep(0.5); "

    if overwrite:
        pycode += "pyautogui.hotkey('ctrl', 'a'); pyautogui.press('backspace'); "

    if use_clipboard:
        pycode += f"pyperclip.copy({text!r}); time.sleep(0.3); "
        pycode += "pyautogui.hotkey('ctrl', 'v'); "
    else:
        pycode += f"pyautogui.write({text!r}, interval=0.05); "

    if enter:
        pycode += "pyautogui.press('enter'); "
    pycode += _PYAUTOGUI_SLEEP_SENTINEL
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_scroll(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `scroll` tool — move to coord then vscroll/hscroll."""
    coordinate = inp.get("coordinate") or [0, 0]
    clicks = int(inp.get("clicks", 0))
    shift = bool(inp.get("shift", False))

    x, y = _scale_xy(coordinate, executor.resize_factor)

    pycode = "import pyautogui; import time; "
    pycode += f"pyautogui.moveTo(x={x}, y={y}); time.sleep(0.3); "
    if shift:
        pycode += f"pyautogui.hscroll({clicks}); "
    else:
        pycode += f"pyautogui.vscroll({clicks}); "
    pycode += _PYAUTOGUI_SLEEP_SENTINEL
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_drag_and_drop(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `drag_and_drop` tool — moveTo(start) then dragTo(end), optional modifiers."""
    start = inp.get("start_coordinate") or [0, 0]
    end = inp.get("end_coordinate") or [0, 0]
    hold_keys = inp.get("hold_keys", []) or []
    if isinstance(hold_keys, str):
        hold_keys = [hold_keys]

    sx, sy = _scale_xy(start, executor.resize_factor)
    ex, ey = _scale_xy(end, executor.resize_factor)

    pycode = "import pyautogui; "
    for key in hold_keys:
        k = _KEY_MAP.get(str(key).lower(), str(key).lower())
        pycode += f"pyautogui.keyDown({k!r}); "
    pycode += f"pyautogui.moveTo(x={sx}, y={sy}); "
    pycode += f"pyautogui.dragTo(x={ex}, y={ey}, duration=1.0, button='left'); "
    for key in hold_keys:
        k = _KEY_MAP.get(str(key).lower(), str(key).lower())
        pycode += f"pyautogui.keyUp({k!r}); "
    pycode += _PYAUTOGUI_SLEEP_SENTINEL
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_hotkey(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `hotkey` tool — pyautogui.hotkey(*keys)."""
    keys = inp.get("keys", []) or []
    if isinstance(keys, str):
        keys = [keys]
    normalized = [_KEY_MAP.get(str(k).lower(), str(k).lower()) for k in keys]

    pycode = "import pyautogui; "
    keys_repr = ", ".join(repr(k) for k in normalized)
    pycode += f"pyautogui.hotkey({keys_repr}); "
    pycode += _PYAUTOGUI_SLEEP_SENTINEL
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_hold_and_press(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `hold_and_press` tool — keyDown(holds), press(each), keyUp(holds)."""
    hold_keys = inp.get("hold_keys", []) or []
    press_keys = inp.get("press_keys", []) or []
    if isinstance(hold_keys, str):
        hold_keys = [hold_keys]
    if isinstance(press_keys, str):
        press_keys = [press_keys]

    holds = [_KEY_MAP.get(str(k).lower(), str(k).lower()) for k in hold_keys]
    presses = [_KEY_MAP.get(str(k).lower(), str(k).lower()) for k in press_keys]

    pycode = "import pyautogui; "
    for k in holds:
        pycode += f"pyautogui.keyDown({k!r}); "
    for k in presses:
        pycode += f"pyautogui.press({k!r}); "
    for k in holds:
        pycode += f"pyautogui.keyUp({k!r}); "
    pycode += _PYAUTOGUI_SLEEP_SENTINEL
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_wait(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `wait` tool — sleep to let the UI settle."""
    seconds = float(inp.get("seconds", 1.0))
    pycode = f"import time; time.sleep({seconds}); "

    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_open(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `open` tool — use the Activities overview to launch an app or file."""
    name = str(inp.get("app_or_filename", ""))

    pycode = "import pyautogui; import time; "
    pycode += "pyautogui.hotkey('win'); time.sleep(1); "
    pycode += f"pyautogui.write({name!r}); time.sleep(1); "
    pycode += "pyautogui.press('enter'); time.sleep(5); "

    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_switch_applications(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `switch_applications` tool — wmctrl-driven window switch + maximize."""
    app_code = str(inp.get("app_code", ""))
    pycode = _SWITCH_APPLICATIONS_TEMPLATE.format(app_code=app_code)
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_close_application(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Handle the `close_application` tool — wmctrl -ic on the fuzzy-matched window."""
    app_code = str(inp.get("app_code", ""))
    pycode = _CLOSE_APPLICATION_TEMPLATE.format(app_code=app_code)
    return pycode, ToolResult(output=_GUI_TOOL_RESULT_TEXT)


def _handle_list_windows(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """List all open windows on the VM via wmctrl -lx."""
    result = run_bash_on_vm(executor.env, "wmctrl -lx", timeout=10)
    return None, result


def _handle_chrome_cdp(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Run a pychrome snippet on the VM against Chrome's CDP endpoint."""
    code = inp.get("code", "")
    result = run_cdp_on_vm(executor.env, code)
    return None, result


def _handle_screenshot(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Capture a screenshot on the VM, persist it locally, and return as an image."""
    result, png_bytes = take_screenshot_on_vm(executor.env)
    saved_path = save_screenshot_bytes(png_bytes, label="tool_screenshot") if png_bytes else ""
    if result.error:
        _task_log.info("Tool ERROR [screenshot]: %s", result.error)
    else:
        _task_log.info("Tool result [screenshot]: captured %s", saved_path)
    return None, result


def _handle_mark_milestone_done(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Mark the current milestone as completed and advance to the next."""
    plan = inp.get("plan")
    if not plan:
        return None, ToolResult(error="No plan exists.")

    completed = plan.mark_current_completed()
    updated_plan = plan.format()
    output = f"Milestone completed: {completed.description}\n\n{updated_plan}"
    return None, ToolResult(output=output)


def _handle_update_milestone(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Update the description of a pending milestone."""
    plan = inp.get("plan")
    if not plan:
        return None, ToolResult(error="No plan exists.")
    milestone_idx = inp.get("milestone_idx")
    new_description = inp.get("new_description", "")
    if milestone_idx is None:
        return None, ToolResult(error="milestone_idx is required.")
    if not new_description.strip():
        return None, ToolResult(error="new_description cannot be empty.")
    error = plan.update_milestone(milestone_idx, new_description)
    if error:
        return None, ToolResult(error=error)
    return None, ToolResult(output=f"Milestone {milestone_idx} updated.\n\n{plan.format()}")


def _handle_add_milestone(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Insert a new milestone into the plan."""
    plan = inp.get("plan")
    if not plan:
        return None, ToolResult(error="No plan exists.")
    description = inp.get("description", "")
    idx = inp.get("idx")
    if not description.strip():
        return None, ToolResult(error="description cannot be empty.")
    error = plan.add_milestone(description, idx)
    if error:
        return None, ToolResult(error=error)
    position = idx if idx is not None else len(plan.milestones)
    return None, ToolResult(output=f"Milestone added at position {position}.\n\n{plan.format()}")


def _handle_delete_milestone(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Remove a pending milestone from the plan."""
    plan = inp.get("plan")
    if not plan:
        return None, ToolResult(error="No plan exists.")
    milestone_idx = inp.get("milestone_idx")
    if milestone_idx is None:
        return None, ToolResult(error="milestone_idx is required.")
    error = plan.delete_milestone(milestone_idx)
    if error:
        return None, ToolResult(error=error)
    return None, ToolResult(output=f"Milestone {milestone_idx} deleted.\n\n{plan.format()}")


def _handle_set_sub_milestones(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Set sub-milestones for the current milestone."""
    plan = inp.get("plan")
    if not plan:
        return None, ToolResult(error="No plan exists.")
    sub_milestones = inp.get("sub_milestones", [])
    if not sub_milestones:
        return None, ToolResult(error="sub_milestones list cannot be empty.")
    error = plan.set_sub_milestones(sub_milestones)
    if error:
        return None, ToolResult(error=error)
    return None, ToolResult(output=f"Sub-milestones set for current milestone.\n\n{plan.format()}")


def _handle_mark_sub_milestone_done(executor: Executor, inp: dict) -> tuple[str | None, ToolResult]:
    """Mark a sub-milestone as completed."""
    plan = inp.get("plan")
    if not plan:
        return None, ToolResult(error="No plan exists.")
    sub_idx = inp.get("sub_milestone_idx")
    if sub_idx is None:
        return None, ToolResult(error="sub_milestone_idx is required.")
    error = plan.mark_sub_milestone_done(sub_idx)
    if error:
        return None, ToolResult(error=error)
    return None, ToolResult(output=f"Sub-milestone {sub_idx} marked done.\n\n{plan.format()}")


def _handle_done(executor: Executor, inp: dict) -> tuple[ExecutorAction | None, ToolResult]:
    summary = inp.get("summary", "")
    _task_log.info("Tool result [done]: %s", summary)
    action = ExecutorAction(decision=ExecutorDecision.DONE, reason=summary)
    return action, ToolResult(output="Task complete.")


def _handle_fail(executor: Executor, inp: dict) -> tuple[ExecutorAction | None, ToolResult]:
    reason = inp.get("reason", "")
    _task_log.info("Tool result [fail]: %s", reason)
    action = ExecutorAction(decision=ExecutorDecision.FAIL, reason=reason)
    return action, ToolResult(output="Task failed.")


# A tool is "breaking" iff its handler returns a non-None action — the dispatch
# loop in _execute_step halts the chain and hands that action back to the harness.
_TOOL_DISPATCH: dict[str, Any] = {
    "click": _handle_click,
    "type": _handle_type,
    "scroll": _handle_scroll,
    "drag_and_drop": _handle_drag_and_drop,
    "hotkey": _handle_hotkey,
    "hold_and_press": _handle_hold_and_press,
    "wait": _handle_wait,
    "open": _handle_open,
    "switch_applications": _handle_switch_applications,
    "close_application": _handle_close_application,
    "list_windows": _handle_list_windows,
    "run_python": _handle_run_python,
    "run_bash": _handle_run_bash,
    "read_file": _handle_read_file,
    "write_file": _handle_write_file,
    "install_package": _handle_install_package,
    "run_background": _handle_run_background,
    "chrome_cdp": _handle_chrome_cdp,
    "screenshot": _handle_screenshot,
    "mark_milestone_done": _handle_mark_milestone_done,
    "update_milestone": _handle_update_milestone,
    "add_milestone": _handle_add_milestone,
    "delete_milestone": _handle_delete_milestone,
    "set_sub_milestones": _handle_set_sub_milestones,
    "mark_sub_milestone_done": _handle_mark_sub_milestone_done,
    "done": _handle_done,
    "fail": _handle_fail,
}


# MARK: ExecutorAgent
class Executor:
    """Executes plan milestones using GUI, coding, and file tools directly.

    Unlike the previous dispatcher-based executor that routed tasks to separate
    sub-agents, this consolidated executor has all tools in a single LLM context.
    This eliminates the extra LLM call for routing and gives the model full
    visibility into tool results across GUI and coding actions.

    Maintains conversation history across ``execute_step()`` calls so it has
    continuity over the whole task.
    """

    def __init__(
        self,
        env: Any,
        screen_size: tuple[int, int] = (1920, 1080),
        *args: Any,
        **kwargs: Any,
    ) -> None:
        self.env = env
        self.resize_factor = (screen_size[0] / 1280, screen_size[1] / 720)

        self.llm = LLMClient(
            name="executor",
            system_prompt=CONSOLIDATED_EXECUTOR_SYSTEM_PROMPT,
            tools=[t.schema for t in EXECUTOR_TOOLS],
            model=config.executor_model,
            provider=config.provider,
            only_n_most_recent_images=config.executor_n_recent_images,
            effort=config.executor_effort,
        )

        global _task_log
        _task_log = get_agent_logger("executor")

    def reset(self) -> None:
        """Clear conversation history for a new task."""
        self.llm.reset()

    def log_usage(self) -> None:
        _task_log.info(self.llm.print_usage())

    def run(
        self, instruction: str, state: AgentState, screenshot_b64: str
    ) -> tuple[ExecutorAction | str | None, AgentState]:
        """Run the execution loop until a GUI action, terminal, or budget exhaustion.

        Returns ``(action, state)`` where *action* is:
        - ``ExecutorAction`` -> terminal (DONE/FAIL)
        - a pyautogui code string -> harness should execute via ``env.step``
        - ``None`` -> budget exhausted without action
        """
        prompt = self._build_prompt(instruction, state)
        images = None
        if screenshot_b64:
            images = [screenshot_b64]
            prompt += "\nThe current screenshot is attached."
        self.llm.add_user_message(text=prompt, images=images)

        # Tool call loop to find the next action or do coding actions
        budget = config.executor_max_steps
        for _ in range(budget):
            action, state = self._execute_step(state)

            if action is None:
                # Not one of GUI/Done/Wait/Fail
                # Most of coding tools do not progress the
                # main loop
                continue
            else:
                return action, state

        return None, state

    def _execute_step(self, state: AgentState) -> tuple[ExecutorAction | str | None, AgentState]:
        """Execute tool calls toward the current milestone.

        Returns ``(action, state)`` where *action* is:
        - ``ExecutorAction`` -> terminal (DONE/FAIL)
        - a pyautogui code string -> GUI action for harness
        - ``None`` -> no breaking action; caller should invoke again
        """
        tool_calls, text_blocks, thinking_blocks, stop_reason = self.llm.generate_and_process()
        tool_call_results: list[tuple[str, ToolResult]] = []
        final_action: ExecutorAction | str | None = None

        if not tool_calls:
            # executor - No tool call — text only response
            return None, state

        for i, tc in enumerate(tool_calls):
            tool_name = tc["name"]
            tool_input = tc["input"]
            tool_exec_hldr = _TOOL_DISPATCH.get(tool_name)
            if tool_exec_hldr:
                action, result = tool_exec_hldr(self, {**tool_input, "plan": state.plan})
            else:
                action = None
                result = ToolResult(error=f"Unknown tool: {tool_name}")

            tool_call_results.append((tc["id"], result))
            if action is not None:
                final_action = action
                for remaining in tool_calls[i + 1 :]:
                    tool_call_results.append(
                        (
                            remaining["id"],
                            ToolResult(error="Not executed: a prior tool returned control to the harness."),
                        )
                    )
                break

        self.llm.add_tool_results(tool_call_results)
        log_agent_api_call(
            agent_name="executor",
            agent_action=final_action,
            text_blocks=text_blocks,
            thinking_blocks=thinking_blocks,
            tool_calls=tool_calls,
            tool_call_results=tool_call_results,
        )
        return final_action, state

    def _build_prompt(self, instruction: str, state: AgentState) -> str:
        """Build the user prompt with task context, plan state, and recent history."""
        parts = [
            f"Task: {instruction}",
            f"\nPlan:\n{state.plan.format()}",
        ]

        if state.replan_guidance:
            parts.append(
                "\nNOTE — Previous attempt failed; this is a fresh plan. "
                "Verifier's guidance for this attempt:\n"
                f"{state.replan_guidance}"
            )
            state.replan_guidance = ""

        parts.append("Decide what action to take next using your available tools.")
        return "\n".join(parts)
