CoolFace
Apppublic

Blablablab/audio-classification

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
0likes
agent_runner.py1009 linesDownload Raw Back to potato
1"""2Live Agent Runner3 4Manages an AI agent that browses the web via Playwright, controlled by an LLM.5Annotators can observe, pause, instruct, or take over the agent in real time.6 7The agent loop runs in a background thread with its own asyncio event loop.8Communication with Flask routes happens through thread-safe state and queues.9"""10 11import asyncio12import base6413import json14import logging15import os16import threading17import time18import uuid19from dataclasses import dataclass, field20from enum import Enum21from queue import Queue, Empty22from typing import Any, Callable, Dict, List, Optional23 24logger = logging.getLogger(__name__)25 26 27class AgentState(Enum):28    """States of the agent lifecycle."""29    IDLE = "idle"30    RUNNING = "running"31    PAUSED = "paused"32    TAKEOVER = "takeover"33    COMPLETED = "completed"34    ERROR = "error"35 36 37@dataclass38class AgentStep:39    """A single step in the agent's execution."""40    step_index: int41    screenshot_path: str42    action: Dict[str, Any]43    thought: str44    observation: str45    timestamp: float46    url: str = ""47    viewport: Optional[Dict[str, int]] = None48    coordinates: Optional[Dict[str, int]] = None49    element: Optional[Dict[str, Any]] = None50    annotator_instruction: Optional[str] = None51 52    def to_dict(self) -> Dict[str, Any]:53        d = {54            "step_index": self.step_index,55            "screenshot_url": self.screenshot_path,56            "action_type": self.action.get("type", "unknown"),57            "action": self.action,58            "thought": self.thought,59            "observation": self.observation,60            "timestamp": self.timestamp,61            "url": self.url,62        }63        if self.viewport:64            d["viewport"] = self.viewport65        if self.coordinates:66            d["coordinates"] = self.coordinates67        if self.element:68            d["element"] = self.element69        if self.annotator_instruction:70            d["annotator_instruction"] = self.annotator_instruction71        return d72 73 74@dataclass75class AgentConfig:76    """Configuration for the agent runner."""77    max_steps: int = 3078    step_delay: float = 1.079    viewport_width: int = 128080    viewport_height: int = 72081    system_prompt: str = ""82    model: str = "claude-sonnet-4-20250514"83    api_key: str = ""84    max_tokens: int = 409685    temperature: float = 0.386    endpoint_type: str = "anthropic_vision"87    history_window: int = 5  # Number of recent steps to include in LLM context88    timeout: int = 60  # Per-request timeout in seconds89 90    base_url: str = ""  # For Ollama: server URL91 92    @classmethod93    def from_config(cls, config: Dict[str, Any]) -> "AgentConfig":94        """Create AgentConfig from a live_agent YAML config dict."""95        ai_config = config.get("ai_config", {})96        viewport = config.get("viewport", {})97        endpoint_type = config.get("endpoint_type", "anthropic_vision")98 99        # API key: Ollama doesn't need one; OpenAI-compatible servers100        # (e.g. vLLM) ignore it but the SDK requires a non-empty string.101        if endpoint_type == "ollama_vision":102            api_key = ai_config.get("api_key", "")103            default_model = "gemma3:4b"104        elif endpoint_type == "openai_vision":105            api_key = ai_config.get("api_key", os.environ.get("OPENAI_API_KEY", "EMPTY"))106            default_model = ""  # must be set explicitly (e.g. served model id)107        else:108            api_key = ai_config.get("api_key", os.environ.get("ANTHROPIC_API_KEY", ""))109            default_model = "claude-sonnet-4-20250514"110 111        return cls(112            max_steps=config.get("max_steps", 30),113            step_delay=config.get("step_delay", 1.0),114            viewport_width=viewport.get("width", 1280),115            viewport_height=viewport.get("height", 720),116            system_prompt=config.get("system_prompt", DEFAULT_SYSTEM_PROMPT),117            model=ai_config.get("model", default_model),118            api_key=api_key,119            max_tokens=ai_config.get("max_tokens", 4096),120            temperature=ai_config.get("temperature", 0.3),121            endpoint_type=endpoint_type,122            history_window=config.get("history_window", 5),123            timeout=ai_config.get("timeout", 60),124            base_url=ai_config.get("base_url", "http://localhost:11434"),125        )126 127 128DEFAULT_SYSTEM_PROMPT = """You are a web browsing agent. You can see screenshots of web pages and take actions to complete tasks.129 130For each step, analyze the current screenshot and respond with a JSON object:131{132  "thought": "Your reasoning about what you see and what to do next",133  "action": {134    "type": "click|type|scroll|navigate|wait|done",135    // For click: "x": 100, "y": 200136    // For type: "text": "hello world"137    // For scroll: "direction": "up|down", "amount": 300138    // For navigate: "url": "https://..."139    // For wait: (no extra fields)140    // For done: "summary": "Task completed because..."141  }142}143 144Always respond with valid JSON only. No markdown, no extra text."""145 146 147class AgentRunner:148    """149    Runs an AI agent that browses the web via Playwright.150 151    The agent loop:152    1. Takes a screenshot153    2. Sends it to the LLM with context/history154    3. Parses the LLM response for an action155    4. Executes the action via Playwright156    5. Emits events to all listeners (for SSE)157    6. Repeats until done, error, or max_steps158 159    Thread-safe control methods allow pause/resume/instruct/takeover.160    """161 162    def __init__(self, session_id: str, config: AgentConfig, screenshot_dir: str):163        self.session_id = session_id164        self.config = config165        self.screenshot_dir = screenshot_dir166 167        # State168        self._state = AgentState.IDLE169        self._state_lock = threading.Lock()170        self._steps: List[AgentStep] = []171        self._error: Optional[str] = None172 173        # Control174        self._pause_event = threading.Event()175        self._pause_event.set()  # Not paused initially176        self._stop_flag = threading.Event()177        self._instruction_queue: Queue = Queue()178        self._takeover_actions: Queue = Queue()179 180        # Listeners for SSE181        self._listeners: List[Callable] = []182        self._listeners_lock = threading.Lock()183 184        # Annotator interactions log185        self._interactions: List[Dict[str, Any]] = []186 187        # Playwright session (set during run)188        self._playwright_session = None189        self._llm_client = None190 191        # Background thread192        self._thread: Optional[threading.Thread] = None193 194    @property195    def state(self) -> AgentState:196        with self._state_lock:197            return self._state198 199    @state.setter200    def state(self, new_state: AgentState):201        with self._state_lock:202            old_state = self._state203            self._state = new_state204        self._emit_event("state_change", {205            "old_state": old_state.value,206            "new_state": new_state.value,207            "timestamp": time.time(),208        })209 210    @property211    def steps(self) -> List[AgentStep]:212        return list(self._steps)213 214    @property215    def step_count(self) -> int:216        return len(self._steps)217 218    @property219    def error(self) -> Optional[str]:220        return self._error221 222    # --- Control methods (thread-safe) ---223 224    def pause(self):225        """Pause the agent loop after the current step completes."""226        if self.state == AgentState.RUNNING:227            self._pause_event.clear()228            self.state = AgentState.PAUSED229            logger.info(f"[{self.session_id}] Agent paused")230 231    def resume(self):232        """Resume a paused agent."""233        if self.state == AgentState.PAUSED:234            self.state = AgentState.RUNNING235            self._pause_event.set()236            logger.info(f"[{self.session_id}] Agent resumed")237 238    def inject_instruction(self, instruction: str):239        """Send an instruction to the agent (processed at next step)."""240        self._instruction_queue.put(instruction)241        self._interactions.append({242            "type": "instruction",243            "text": instruction,244            "timestamp": time.time(),245            "step_index": self.step_count,246        })247        self._emit_event("instruction_received", {"instruction": instruction})248        logger.info(f"[{self.session_id}] Instruction injected: {instruction[:100]}")249 250    def enter_takeover(self):251        """Switch to manual takeover mode."""252        if self.state in (AgentState.RUNNING, AgentState.PAUSED):253            self._pause_event.clear()  # Pause the agent loop254            self.state = AgentState.TAKEOVER255            self._interactions.append({256                "type": "takeover_start",257                "timestamp": time.time(),258                "step_index": self.step_count,259            })260            logger.info(f"[{self.session_id}] Takeover mode entered")261 262    def exit_takeover(self):263        """Exit manual takeover and resume the agent."""264        if self.state == AgentState.TAKEOVER:265            self._interactions.append({266                "type": "takeover_end",267                "timestamp": time.time(),268                "step_index": self.step_count,269            })270            self.state = AgentState.RUNNING271            self._pause_event.set()272            logger.info(f"[{self.session_id}] Takeover mode exited")273 274    def submit_manual_action(self, action: Dict[str, Any]):275        """Submit a manual action during takeover mode."""276        if self.state == AgentState.TAKEOVER:277            self._takeover_actions.put(action)278 279    def stop(self):280        """Stop the agent loop."""281        self._stop_flag.set()282        self._pause_event.set()  # Unblock if paused283        logger.info(f"[{self.session_id}] Stop requested")284 285    # --- Listener management ---286 287    def add_listener(self, callback: Callable):288        """Add an SSE listener callback."""289        with self._listeners_lock:290            self._listeners.append(callback)291 292    def remove_listener(self, callback: Callable):293        """Remove an SSE listener callback."""294        with self._listeners_lock:295            self._listeners = [l for l in self._listeners if l is not callback]296 297    def _emit_event(self, event_type: str, data: Dict[str, Any]):298        """Emit an event to all listeners."""299        event = {"type": event_type, "data": data, "session_id": self.session_id}300        with self._listeners_lock:301            for listener in self._listeners:302                try:303                    listener(event)304                except Exception as e:305                    logger.warning(f"Listener error: {e}")306 307    # --- Main agent loop ---308 309    def start(self, task_description: str, start_url: str):310        """Start the agent in a background thread."""311        if self.state != AgentState.IDLE:312            raise RuntimeError(f"Cannot start agent in state {self.state}")313 314        self._thread = threading.Thread(315            target=self._run_thread,316            args=(task_description, start_url),317            daemon=True,318            name=f"agent-{self.session_id}",319        )320        self._thread.start()321 322    def _run_thread(self, task_description: str, start_url: str):323        """Thread target: runs the async agent loop."""324        loop = asyncio.new_event_loop()325        asyncio.set_event_loop(loop)326        try:327            loop.run_until_complete(self._run_async(task_description, start_url))328        except Exception as e:329            logger.error(f"[{self.session_id}] Agent thread error: {e}")330            self._error = str(e)331            self.state = AgentState.ERROR332            self._emit_event("error", {"message": str(e)})333        finally:334            loop.close()335 336    async def _run_async(self, task_description: str, start_url: str):337        """Async agent loop."""338        from potato.web_playwright import PlaywrightSession339 340        self.state = AgentState.RUNNING341 342        # Initialize Playwright343        self._playwright_session = PlaywrightSession(344            width=self.config.viewport_width,345            height=self.config.viewport_height,346        )347        started = await self._playwright_session.start(start_url)348        if not started:349            raise RuntimeError("Failed to start Playwright browser session")350 351        # Initialize LLM client352        self._init_llm_client()353 354        self._emit_event("started", {355            "task": task_description,356            "start_url": start_url,357            "max_steps": self.config.max_steps,358        })359 360        try:361            for step_index in range(self.config.max_steps):362                # Check stop flag363                if self._stop_flag.is_set():364                    logger.info(f"[{self.session_id}] Stopped by user")365                    break366 367                # Wait if paused (blocks until resume/stop)368                while not self._pause_event.is_set():369                    if self._stop_flag.is_set():370                        break371                    # Handle takeover actions while paused in takeover mode372                    if self.state == AgentState.TAKEOVER:373                        await self._process_takeover_actions()374                    await asyncio.sleep(0.1)375 376                if self._stop_flag.is_set():377                    break378 379                # Check for injected instructions380                instruction = None381                try:382                    instruction = self._instruction_queue.get_nowait()383                except Empty:384                    pass385 386                # Execute one agent step387                step = await self._agent_step(388                    step_index, task_description, instruction389                )390                self._steps.append(step)391 392                # Check if agent decided it's done393                if step.action.get("type") == "done":394                    logger.info(f"[{self.session_id}] Agent completed task")395                    break396 397                # Step delay398                if self.config.step_delay > 0:399                    await asyncio.sleep(self.config.step_delay)400 401            self.state = AgentState.COMPLETED402            self._emit_event("complete", {403                "total_steps": len(self._steps),404                "final_url": (await self._playwright_session.get_state()).get("url", ""),405            })406 407        finally:408            await self._playwright_session.stop()409            self._playwright_session = None410 411    async def _agent_step(412        self,413        step_index: int,414        task_description: str,415        instruction: Optional[str] = None,416    ) -> AgentStep:417        """Execute a single agent step: screenshot โ†’ LLM โ†’ action โ†’ emit."""418 419        # 1. Take screenshot420        screenshot_bytes = await self._playwright_session.screenshot()421        if not screenshot_bytes:422            raise RuntimeError("Failed to capture screenshot")423 424        screenshot_path = os.path.join(425            self.screenshot_dir, f"step_{step_index:03d}.png"426        )427        os.makedirs(os.path.dirname(screenshot_path), exist_ok=True)428        with open(screenshot_path, "wb") as f:429            f.write(screenshot_bytes)430 431        # 2. Get page state432        page_state = await self._playwright_session.get_state()433 434        # 3. Emit thinking event435        self._emit_event("thinking", {436            "step_index": step_index,437            "screenshot_url": screenshot_path,438            "url": page_state.get("url", ""),439        })440 441        # 4. Build messages and query LLM442        screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")443        messages = self._build_llm_messages(444            screenshot_b64, task_description, instruction445        )446        llm_response = self._query_llm(messages)447 448        # 5. Parse action from response449        thought, action = self._parse_action(llm_response)450 451        # 6. Execute action452        observation = await self._execute_action(action)453 454        # 7. Build step455        step = AgentStep(456            step_index=step_index,457            screenshot_path=screenshot_path,458            action=action,459            thought=thought,460            observation=observation,461            timestamp=time.time(),462            url=page_state.get("url", ""),463            viewport=page_state.get("viewport"),464            coordinates=_extract_coordinates(action),465            annotator_instruction=instruction,466        )467 468        # 8. Emit step event469        self._emit_event("step", step.to_dict())470 471        return step472 473    def _build_llm_messages(474        self,475        screenshot_b64: str,476        task_description: str,477        instruction: Optional[str] = None,478    ) -> List[Dict[str, Any]]:479        """Build message list for the LLM vision API."""480        messages = []481 482        # System message483        system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT484        messages.append({"role": "system", "content": system_prompt})485 486        # Task description487        task_msg = f"Task: {task_description}"488        if instruction:489            task_msg += f"\n\nAnnotator instruction: {instruction}"490 491        # Include recent step history492        history_steps = self._steps[-self.config.history_window:]493        if history_steps:494            history_parts = []495            for s in history_steps:496                entry = f"Step {s.step_index}: thought='{s.thought}', action={json.dumps(s.action)}, observation='{s.observation}'"497                history_parts.append(entry)498            task_msg += "\n\nRecent history:\n" + "\n".join(history_parts)499 500        messages.append({"role": "user", "content": task_msg})501 502        # Current screenshot (as a separate user message with image)503        messages.append({504            "role": "user",505            "content": [506                {507                    "type": "image",508                    "source": {509                        "type": "base64",510                        "media_type": "image/png",511                        "data": screenshot_b64,512                    },513                },514                {515                    "type": "text",516                    "text": f"Current page screenshot (step {len(self._steps)}). What action should I take next?",517                },518            ],519        })520 521        return messages522 523    def _init_llm_client(self):524        """Initialize the LLM client based on endpoint_type."""525        if self.config.endpoint_type == "anthropic_vision":526            try:527                import anthropic528            except ImportError:529                raise RuntimeError(530                    "anthropic package required. Install with: pip install anthropic"531                )532            api_key = self.config.api_key or os.environ.get("ANTHROPIC_API_KEY")533            if not api_key:534                raise RuntimeError(535                    "Anthropic API key required. Set in config or ANTHROPIC_API_KEY env var."536                )537            self._llm_client = anthropic.Anthropic(538                api_key=api_key, timeout=self.config.timeout539            )540        elif self.config.endpoint_type == "ollama_vision":541            try:542                import ollama543            except ImportError:544                raise RuntimeError(545                    "ollama package required. Install with: pip install ollama"546                )547            host = self.config.base_url or "http://localhost:11434"548            self._llm_client = ollama.Client(549                host=host, timeout=self.config.timeout550            )551            # Verify connectivity552            try:553                self._llm_client.list()554                logger.info(f"Connected to Ollama at {host}, model: {self.config.model}")555            except Exception as e:556                raise RuntimeError(f"Failed to connect to Ollama at {host}: {e}")557        elif self.config.endpoint_type == "openai_vision":558            try:559                from openai import OpenAI560            except ImportError:561                raise RuntimeError(562                    "openai package required. Install with: pip install openai"563                )564            base_url = self.config.base_url or "https://api.openai.com/v1"565            self._llm_client = OpenAI(566                base_url=base_url,567                api_key=self.config.api_key or "EMPTY",568                timeout=self.config.timeout,569            )570            try:571                self._llm_client.models.list()572                logger.info(573                    f"Connected to OpenAI-compatible endpoint at {base_url}, "574                    f"model: {self.config.model}"575                )576            except Exception as e:577                # Non-fatal: some servers gate /models; the chat call will578                # surface a real error if the endpoint is truly unreachable.579                logger.warning(580                    f"Could not list models at {base_url} ({e}); continuing."581                )582        else:583            raise RuntimeError(584                f"Unsupported endpoint_type: {self.config.endpoint_type}. "585                f"Supported: 'anthropic_vision', 'ollama_vision', 'openai_vision'."586            )587 588    def _query_llm(self, messages: List[Dict[str, Any]]) -> str:589        """Send messages to the LLM and return the text response."""590        if self.config.endpoint_type == "anthropic_vision":591            return self._query_anthropic(messages)592        elif self.config.endpoint_type == "ollama_vision":593            return self._query_ollama(messages)594        elif self.config.endpoint_type == "openai_vision":595            return self._query_openai(messages)596        raise RuntimeError(f"Unsupported endpoint type: {self.config.endpoint_type}")597 598    def _query_openai(self, messages: List[Dict[str, Any]]) -> str:599        """Query an OpenAI-compatible vision endpoint (OpenAI, vLLM, etc.).600 601        Converts the internal Anthropic-style message blocks into OpenAI602        chat-completions format (image blocks become ``image_url`` data603        URIs). Requests a JSON object response when the server supports it,604        falling back gracefully if it does not.605        """606        oai_messages = []607        for msg in messages:608            role = msg["role"]609            content = msg.get("content", "")610            if isinstance(content, str):611                oai_messages.append({"role": role, "content": content})612                continue613            parts = []614            for block in content:615                if not isinstance(block, dict):616                    continue617                if block.get("type") == "text":618                    parts.append({"type": "text", "text": block.get("text", "")})619                elif block.get("type") == "image":620                    src = block.get("source", {})621                    if src.get("type") == "base64":622                        media = src.get("media_type", "image/png")623                        parts.append({624                            "type": "image_url",625                            "image_url": {626                                "url": f"data:{media};base64,{src['data']}"627                            },628                        })629            oai_messages.append({"role": role, "content": parts})630 631        kwargs = {632            "model": self.config.model,633            "messages": oai_messages,634            "max_tokens": self.config.max_tokens,635            "temperature": self.config.temperature,636        }637 638        def _is_rate_limit(exc) -> bool:639            if getattr(exc, "status_code", None) == 429:640                return True641            s = str(exc).lower()642            return ("429" in s or "rate limit" in s or "quota" in s643                    or "resource_exhausted" in s)644 645        def _create(use_rf: bool):646            if use_rf:647                return self._llm_client.chat.completions.create(648                    response_format={"type": "json_object"}, **kwargs)649            return self._llm_client.chat.completions.create(**kwargs)650 651        # Transient 429s (per-minute rate/token bursts) are common mid-run652        # even on paid tiers; back off and retry instead of failing the653        # whole agent session.654        backoffs = [5, 15, 30, 30, 30]655        use_rf = True656        attempt = 0657        while True:658            try:659                resp = _create(use_rf)660                break661            except Exception as e:662                if _is_rate_limit(e):663                    if attempt >= len(backoffs):664                        raise665                    wait = backoffs[attempt]666                    attempt += 1667                    logger.warning(668                        f"[{self.session_id}] LLM 429/rate-limited; "669                        f"retry {attempt}/{len(backoffs)} in {wait}s"670                    )671                    self._emit_event("thinking", {672                        "text": f"Rate-limited by the model API; "673                                f"waiting {wait}s before retryingโ€ฆ"674                    })675                    time.sleep(wait)676                    continue677                if use_rf:678                    # Server may not support response_format; drop it once.679                    use_rf = False680                    continue681                raise682        return resp.choices[0].message.content or ""683 684    def _query_anthropic(self, messages: List[Dict[str, Any]]) -> str:685        """Query Anthropic Claude with vision support."""686        # Separate system message687        system = ""688        api_messages = []689        for msg in messages:690            if msg["role"] == "system":691                system = msg["content"]692            else:693                api_messages.append(msg)694 695        kwargs = {696            "model": self.config.model,697            "max_tokens": self.config.max_tokens,698            "temperature": self.config.temperature,699            "messages": api_messages,700        }701        if system:702            kwargs["system"] = system703 704        response = self._llm_client.messages.create(**kwargs)705        return response.content[0].text706 707    def _query_ollama(self, messages: List[Dict[str, Any]]) -> str:708        """Query Ollama vision model.709 710        Converts Anthropic-format messages to Ollama format:711        - System messages are prepended to the prompt text712        - Multiple user messages are merged into a single message713        - Content blocks with images use Ollama's 'images' key714        """715        # Extract text and images from Anthropic-format messages716        all_text_parts = []717        all_images = []718        for msg in messages:719            content = msg.get("content", "")720            if msg["role"] == "system":721                if isinstance(content, str) and content:722                    all_text_parts.insert(0, content)723                continue724            if isinstance(content, list):725                for block in content:726                    if isinstance(block, dict):727                        if block.get("type") == "text":728                            all_text_parts.append(block["text"])729                        elif block.get("type") == "image":730                            source = block.get("source", {})731                            if source.get("type") == "base64":732                                all_images.append(source["data"])733            elif isinstance(content, str) and content:734                all_text_parts.append(content)735 736        ollama_msg = {737            "role": "user",738            "content": "\n\n".join(all_text_parts),739        }740        if all_images:741            ollama_msg["images"] = all_images742 743        options = {744            "temperature": self.config.temperature,745            "num_predict": self.config.max_tokens,746        }747 748        # Use Ollama's format schema to force structured JSON output749        agent_schema = {750            "type": "object",751            "properties": {752                "thought": {"type": "string"},753                "action": {754                    "type": "object",755                    "properties": {756                        "type": {"type": "string"},757                        "x": {"type": "integer"},758                        "y": {"type": "integer"},759                        "text": {"type": "string"},760                        "url": {"type": "string"},761                        "direction": {"type": "string"},762                        "amount": {"type": "integer"},763                        "summary": {"type": "string"},764                    },765                    "required": ["type"],766                },767            },768            "required": ["thought", "action"],769        }770 771        response = self._llm_client.chat(772            model=self.config.model,773            messages=[ollama_msg],774            options=options,775            format=agent_schema,776        )777 778        # Extract content from response (handle both dict and Pydantic model)779        message = (780            response.get("message")781            if hasattr(response, "get")782            else getattr(response, "message", None)783        )784        if message is None:785            raise RuntimeError("No message in Ollama response")786 787        content = (788            message.get("content")789            if hasattr(message, "get")790            else getattr(message, "content", None)791        )792 793        # Some models (e.g. qwen3-vl) put responses in 'thinking' field794        # and leave content empty. Extract the agent JSON from thinking.795        if not content:796            thinking = (797                message.get("thinking")798                if hasattr(message, "get")799                else getattr(message, "thinking", None)800            )801            if thinking:802                content = _extract_agent_json(thinking)803 804        return content or ""805 806    def _parse_action(self, llm_response: str) -> tuple:807        """Parse thought and action from LLM JSON response.808 809        Returns:810            (thought, action_dict)811        """812        # Try to extract JSON from response813        text = llm_response.strip()814 815        # Handle markdown code blocks816        if "```json" in text:817            import re818            match = re.search(r"```json\s*([\s\S]*?)\s*```", text)819            if match:820                text = match.group(1).strip()821        elif "```" in text:822            import re823            match = re.search(r"```\s*([\s\S]*?)\s*```", text)824            if match:825                text = match.group(1).strip()826 827        try:828            parsed = json.loads(text)829        except json.JSONDecodeError:830            logger.warning(f"Failed to parse LLM response as JSON: {text[:200]}")831            return text, {"type": "wait"}832 833        thought = parsed.get("thought", "")834        action = parsed.get("action", {"type": "wait"})835 836        # Validate action has a type837        if "type" not in action:838            action["type"] = "wait"839 840        return thought, action841 842    async def _execute_action(self, action: Dict[str, Any]) -> str:843        """Execute an action via Playwright and return observation."""844        action_type = action.get("type", "wait")845        pw = self._playwright_session846 847        try:848            if action_type == "click":849                x = int(action.get("x", 0))850                y = int(action.get("y", 0))851                success = await pw.click(x, y)852                return f"Clicked at ({x}, {y})" if success else f"Click failed at ({x}, {y})"853 854            elif action_type == "type":855                text = action.get("text", "")856                # Handle control characters via keyboard.press857                if text == "\b":858                    success = await pw.page.keyboard.press("Backspace") or True859                    return "Pressed Backspace"860                elif text == "\n":861                    success = await pw.page.keyboard.press("Enter") or True862                    return "Pressed Enter"863                elif text == "\t":864                    success = await pw.page.keyboard.press("Tab") or True865                    return "Pressed Tab"866                else:867                    success = await pw.type_text(text)868                    return f"Typed '{text}'" if success else f"Type failed: '{text}'"869 870            elif action_type == "scroll":871                direction = action.get("direction", "down")872                amount = int(action.get("amount", 300))873                dy = amount if direction == "down" else -amount874                success = await pw.scroll(0, dy)875                return f"Scrolled {direction} by {amount}px" if success else "Scroll failed"876 877            elif action_type == "navigate":878                url = action.get("url", "")879                success = await pw.navigate(url)880                return f"Navigated to {url}" if success else f"Navigation failed: {url}"881 882            elif action_type == "wait":883                await asyncio.sleep(1)884                return "Waited 1 second"885 886            elif action_type == "done":887                summary = action.get("summary", "Task completed")888                return summary889 890            else:891                logger.warning(f"Unknown action type: {action_type}")892                return f"Unknown action: {action_type}"893 894        except Exception as e:895            logger.error(f"Action execution error: {e}")896            return f"Error executing {action_type}: {e}"897 898    async def _process_takeover_actions(self):899        """Process manual actions submitted during takeover mode."""900        try:901            action = self._takeover_actions.get_nowait()902        except Empty:903            return904 905        pw = self._playwright_session906        if not pw:907            return908 909        observation = await self._execute_action(action)910 911        # Take screenshot after manual action912        screenshot_bytes = await pw.screenshot()913        step_index = len(self._steps)914        screenshot_path = os.path.join(915            self.screenshot_dir, f"step_{step_index:03d}_manual.png"916        )917        if screenshot_bytes:918            with open(screenshot_path, "wb") as f:919                f.write(screenshot_bytes)920 921        page_state = await pw.get_state()922 923        step = AgentStep(924            step_index=step_index,925            screenshot_path=screenshot_path,926            action={**action, "_manual": True},927            thought="[Manual takeover action]",928            observation=observation,929            timestamp=time.time(),930            url=page_state.get("url", ""),931            viewport=page_state.get("viewport"),932            coordinates=_extract_coordinates(action),933        )934        self._steps.append(step)935        self._emit_event("step", step.to_dict())936 937    # --- Trace export ---938 939    def get_trace(self) -> Dict[str, Any]:940        """Export the session as a web_agent_trace-compatible dict."""941        return {942            "steps": [s.to_dict() for s in self._steps],943            "task_description": "",  # Set by caller944            "session_id": self.session_id,945            "agent_config": {946                "model": self.config.model,947                "endpoint_type": self.config.endpoint_type,948                "max_steps": self.config.max_steps,949            },950            "annotator_interactions": self._interactions,951            "state": self.state.value,952            "total_steps": len(self._steps),953        }954 955    def get_state_summary(self) -> Dict[str, Any]:956        """Get a summary of current state for API responses."""957        return {958            "session_id": self.session_id,959            "state": self.state.value,960            "step_count": len(self._steps),961            "error": self._error,962            "has_instructions_pending": not self._instruction_queue.empty(),963        }964 965 966def _extract_agent_json(text: str) -> str:967    """Extract the last valid JSON object containing 'thought' or 'action' from text.968 969    Some models (qwen3-vl) put their chain-of-thought in the thinking field970    with the actual JSON answer embedded in the text. This function finds971    that JSON, skipping any example/template JSON from the prompt.972    """973    import re974 975    # Find all JSON-like blocks (balanced braces)976    candidates = []977    depth = 0978    start = None979    for i, ch in enumerate(text):980        if ch == "{":981            if depth == 0:982                start = i983            depth += 1984        elif ch == "}":985            depth -= 1986            if depth == 0 and start is not None:987                candidates.append(text[start : i + 1])988                start = None989 990    # Try each candidate (last first โ€” most likely to be the final answer)991    for candidate in reversed(candidates):992        try:993            parsed = json.loads(candidate)994            if isinstance(parsed, dict) and ("thought" in parsed or "action" in parsed):995                return candidate996        except (json.JSONDecodeError, ValueError):997            continue998 999    # Fallback: try greedy regex for any JSON1000    match = re.search(r"\{[^{}]*\}", text)1001    return match.group(0) if match else ""1002 1003 1004def _extract_coordinates(action: Dict[str, Any]) -> Optional[Dict[str, int]]:1005    """Extract x, y coordinates from an action if present."""1006    if "x" in action and "y" in action:1007        return {"x": int(action["x"]), "y": int(action["y"])}1008    return None1009