From 716505442061696dfa72ce7fc229f51dde082be9 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 2 Jun 2025 20:39:53 -0700 Subject: [PATCH 01/11] separate logging from utils --- stagehand/__init__.py | 2 +- stagehand/a11y/utils.py | 3 +- stagehand/client.py | 20 +- stagehand/logging.py | 626 +++++++++++++++++++++++++++++++++++++++ stagehand/schemas.py | 1 - stagehand/utils.py | 629 +--------------------------------------- 6 files changed, 641 insertions(+), 640 deletions(-) create mode 100644 stagehand/logging.py diff --git a/stagehand/__init__.py b/stagehand/__init__.py index 3a40ef9..5684dbe 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -2,6 +2,7 @@ from .client import Stagehand from .config import StagehandConfig, default_config from .handlers.observe_handler import ObserveHandler +from .logging import configure_logging from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import ( @@ -17,7 +18,6 @@ ObserveOptions, ObserveResult, ) -from .utils import configure_logging __version__ = "0.0.1" diff --git a/stagehand/a11y/utils.py b/stagehand/a11y/utils.py index 55ee78d..828ada5 100644 --- a/stagehand/a11y/utils.py +++ b/stagehand/a11y/utils.py @@ -7,13 +7,14 @@ if TYPE_CHECKING: from stagehand.page import StagehandPage +from ..logging import StagehandLogger from ..types.a11y import ( AccessibilityNode, AXNode, CDPSession, TreeResult, ) -from ..utils import StagehandLogger, format_simplified_tree +from ..utils import format_simplified_tree async def _clean_structural_nodes( diff --git a/stagehand/client.py b/stagehand/client.py index 296b60a..6879ffa 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -2,9 +2,9 @@ import json import os import shutil -import tempfile import signal import sys +import tempfile import time from pathlib import Path from typing import Any, Literal, Optional @@ -23,13 +23,12 @@ from .config import StagehandConfig, default_config from .context import StagehandContext from .llm import LLMClient +from .logging import StagehandLogger, default_log_handler from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import AgentConfig from .utils import ( - StagehandLogger, convert_dict_keys_to_camel_case, - default_log_handler, make_serializable, ) @@ -47,7 +46,7 @@ class Stagehand: # Dictionary to store one lock per session_id _session_locks = {} - + # Flag to track if cleanup has been called _cleanup_called = False @@ -193,7 +192,7 @@ def __init__( raise ValueError( "browserbase_project_id is required for BROWSERBASE env with existing session_id (or set BROWSERBASE_PROJECT_ID in env)." ) - + # Register signal handlers for graceful shutdown self._register_signal_handlers() @@ -224,13 +223,16 @@ def __init__( def _register_signal_handlers(self): """Register signal handlers for SIGINT and SIGTERM to ensure proper cleanup.""" + def cleanup_handler(sig, frame): # Prevent multiple cleanup calls if self.__class__._cleanup_called: return self.__class__._cleanup_called = True - print(f"\n[{signal.Signals(sig).name}] received. Ending Browserbase session...") + print( + f"\n[{signal.Signals(sig).name}] received. Ending Browserbase session..." + ) try: # Try to get the current event loop @@ -252,11 +254,11 @@ def cleanup_handler(sig, frame): def schedule_cleanup(): task = asyncio.create_task(self._async_cleanup()) # Shield the task to prevent it from being cancelled - shielded = asyncio.shield(task) + asyncio.shield(task) # We don't need to await here since we're in call_soon_threadsafe - + loop.call_soon_threadsafe(schedule_cleanup) - + except Exception as e: print(f"Error during signal cleanup: {str(e)}") sys.exit(1) diff --git a/stagehand/logging.py b/stagehand/logging.py new file mode 100644 index 0000000..4220bb2 --- /dev/null +++ b/stagehand/logging.py @@ -0,0 +1,626 @@ +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Callable, Optional + +from rich.console import Console +from rich.logging import RichHandler +from rich.panel import Panel +from rich.syntax import Syntax +from rich.table import Table +from rich.theme import Theme + +# Custom theme for Rich +stagehand_theme = Theme( + { + "info": "cyan", + "warning": "yellow", + "error": "bold red", + "debug": "bold white", + "category": "bold blue", + "auxiliary": "white", + "timestamp": "dim white", + "success": "bold white", + "pending": "bold yellow", + "ellipsis": "bold white", + } +) + +# Create console instance with theme +console = Console(theme=stagehand_theme) + +# Setup logging with Rich handler +logger = logging.getLogger(__name__) +# Only add handler if there isn't one already to avoid duplicate logs +if not logger.handlers: + handler = RichHandler( + rich_tracebacks=True, + markup=True, + console=console, + show_time=False, # We'll add our own timestamp + show_level=False, # We'll format our own level + ) + handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(handler) + # Don't propagate to root logger to avoid duplicate logs + logger.propagate = False + + +def configure_logging( + level=logging.INFO, + format_str=None, + datefmt="%Y-%m-%d %H:%M:%S", + quiet_dependencies=True, + utils_level=None, + remove_logger_name=True, + use_rich=True, +): + """ + Configure logging for Stagehand with sensible defaults. + + Args: + level: The logging level for Stagehand loggers (default: INFO) + format_str: The format string for log messages (default: depends on remove_logger_name) + datefmt: The date format string for log timestamps + quiet_dependencies: If True, sets httpx, httpcore, and other noisy dependencies to WARNING level + utils_level: Optional specific level for stagehand.utils logger (default: None, uses the main level) + remove_logger_name: If True, use a more concise log format without showing full logger name + use_rich: If True, use Rich for colorized, pretty-printed output + """ + # Set default format if not provided + if format_str is None: + if remove_logger_name: + format_str = "%(asctime)s - %(levelname)s - %(message)s" + else: + format_str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Configure root logger with custom format + if use_rich: + # Use Rich handler for root logger + logging.basicConfig( + level=level, + format="%(message)s", + datefmt=datefmt, + handlers=[ + RichHandler( + rich_tracebacks=True, + markup=True, + console=console, + show_time=False, + show_level=False, + ) + ], + ) + else: + # Use standard handler + logging.basicConfig( + level=level, + format=format_str, + datefmt=datefmt, + ) + + # Configure Stagehand logger to use the specified level + logging.getLogger("stagehand").setLevel(level) + + # Set specific level for utils logger if specified + if utils_level is not None: + logging.getLogger("stagehand.logging").setLevel(utils_level) + + # Set higher log levels for noisy dependencies + if quiet_dependencies: + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("litellm").setLevel(logging.WARNING) + logging.getLogger("LiteLLM").setLevel(logging.WARNING) + + +class StagehandLogger: + """ + Enhanced Python equivalent of the TypeScript StagehandLogger class. + Provides structured logging with improved formatting using Rich. + """ + + def __init__( + self, + verbose: int = 1, + external_logger: Optional[Callable] = None, + use_rich: bool = True, + ): + """ + Initialize the logger with specified verbosity and optional external logger. + + Args: + verbose: Verbosity level (0=error only, 1=info, 2=detailed, 3=debug) + external_logger: Optional callback function for log events + use_rich: Whether to use Rich for pretty output (default: True) + """ + self.verbose = verbose + self.external_logger = external_logger + self.use_rich = use_rich + self.console = console + + # Map our verbosity levels to Python's logging levels + self.level_map = { + 0: logging.ERROR, # Critical errors only + 1: logging.INFO, # Standard information + 2: logging.WARNING, # More detailed info (using WARNING level) + 3: logging.DEBUG, # Debug information + } + + # Map level to style names + self.level_style = {0: "error", 1: "info", 2: "warning", 3: "debug"} + + # Update logger level based on verbosity + self._set_verbosity(verbose) + + def _set_verbosity(self, level: int): + """Set the logger verbosity level""" + self.verbose = level + logger.setLevel(self.level_map.get(level, logging.INFO)) + + def _format_json(self, data: dict) -> str: + """Format JSON data nicely with syntax highlighting""" + if not self.use_rich: + return json.dumps(data, indent=2) + + # Create a nice-looking JSON string with syntax highlighting + json_str = json.dumps(data, indent=2) + syntax = Syntax(json_str, "json", theme="monokai", word_wrap=True) + return syntax + + def _format_message_with_json(self, message: str) -> str: + """ + Parse and format any JSON-like structures within a message string. + This helps with pretty-printing log messages that contain raw JSON or Python dict representations. + """ + # Handle case where message is already a dictionary + if isinstance(message, dict): + # Format the dict as JSON + if self.use_rich: + json_str = json.dumps(message, indent=2) + return f"\n{json_str}" + else: + return json.dumps(message, indent=2) + + if not isinstance(message, str): + # If not a string and not a dict, convert to string + return str(message) + + import re + + # Function to replace dict-like patterns with formatted JSON + def replace_dict(match): + try: + # Handle Python dictionary format by safely evaluating it + # This converts string representation of Python dict to actual dict + import ast + + dict_str = match.group(0) + dict_obj = ast.literal_eval(dict_str) + + # Format the dict as JSON + if self.use_rich: + json_str = json.dumps(dict_obj, indent=2) + return f"\n{json_str}" + else: + return json.dumps(dict_obj, indent=2) + except (SyntaxError, ValueError): + # If parsing fails, return the original string + return match.group(0) + + # Pattern to match Python dictionary literals + pattern = r"(\{[^{}]*(\{[^{}]*\}[^{}]*)*\})" + + # Replace dictionary patterns with formatted JSON + return re.sub(pattern, replace_dict, message) + + def _format_fastify_log( + self, message: str, auxiliary: dict[str, Any] = None + ) -> tuple: + """ + Special formatting for logs that come from the Fastify server. + These often contain Python representations of JSON objects. + + Returns: + tuple: (formatted_message, formatted_auxiliary) + """ + # Handle case where message is already a dictionary + if isinstance(message, dict): + # Extract the actual message and other fields + extracted_message = message.get("message", "") + category = message.get("category", "") + + # Format any remaining data for display + formatted_json = json.dumps(message, indent=2) + + if self.use_rich: + Syntax(formatted_json, "json", theme="monokai", word_wrap=True) + if category: + extracted_message = f"[{category}] {extracted_message}" + + # Handle ellipses in message separately + if "..." in extracted_message: + extracted_message = extracted_message.replace( + "...", "[ellipsis]...[/ellipsis]" + ) + + return extracted_message, None + else: + if category and not extracted_message.startswith(f"[{category}]"): + extracted_message = f"[{category}] {extracted_message}" + return extracted_message, None + + # Check if this appears to be a string representation of a JSON object + elif isinstance(message, str) and ( + message.startswith("{'") or message.startswith("{") + ): + try: + # Try to parse the message as a Python dict using ast.literal_eval + # This is safer than eval() for parsing Python literal structures + import ast + + data = ast.literal_eval(message) + + # Extract the actual message and other fields + extracted_message = data.get("message", "") + category = data.get("category", "") + + # Format any remaining data for display + formatted_json = json.dumps(data, indent=2) + + if self.use_rich: + Syntax(formatted_json, "json", theme="monokai", word_wrap=True) + if category: + extracted_message = f"[{category}] {extracted_message}" + + # Handle ellipses in message separately + if "..." in extracted_message: + extracted_message = extracted_message.replace( + "...", "[ellipsis]...[/ellipsis]" + ) + + return extracted_message, None + else: + if category and not extracted_message.startswith(f"[{category}]"): + extracted_message = f"[{category}] {extracted_message}" + return extracted_message, None + except (SyntaxError, ValueError): + # If parsing fails, use the original message + pass + + # For regular string messages that contain ellipses + elif isinstance(message, str) and "..." in message: + formatted_message = message.replace("...", "[ellipsis]...[/ellipsis]") + return formatted_message, auxiliary + + # Default: return the original message and auxiliary + return message, auxiliary + + def _format_auxiliary_compact(self, auxiliary: dict[str, Any]) -> str: + """Format auxiliary data in a compact, readable way""" + if not auxiliary: + return {} + + # Clean and format the auxiliary data + formatted = {} + + for key, value in auxiliary.items(): + # Skip internal keys in normal logging + if key in ["requestId", "elementId", "type"]: + continue + + # Handle nested values that come from the API + if isinstance(value, dict) and "value" in value: + extracted = value.get("value") + type_info = value.get("type") + + # Skip empty values + if not extracted: + continue + + # For nested objects with 'value' and 'type', use a cleaner representation + if isinstance(extracted, (dict, list)) and type_info == "object": + # For complex objects, keep the whole structure + formatted[key] = extracted + # Handle different types of values + elif key in ["sessionId", "url", "sessionUrl", "debugUrl"]: + # Keep these values as is + formatted[key] = extracted + elif isinstance(extracted, str) and len(extracted) > 40: + # Truncate long strings + formatted[key] = f"{extracted[:37]}..." + else: + formatted[key] = extracted + else: + # Handle direct values + formatted[key] = value + + return formatted + + def log( + self, + message: str, + level: int = 1, + category: str = None, + auxiliary: dict[str, Any] = None, + ): + """ + Log a message with structured data, with Rich formatting. + + Args: + message: The message to log + level: Verbosity level (0=error, 1=info, 2=detailed, 3=debug) + category: Optional category for the message + auxiliary: Optional dictionary of auxiliary data + """ + # Skip logging if below current verbosity level + if level > self.verbose and level != 0: # Always log errors (level 0) + return + + # Call external logger if provided (handle async function) + if self.external_logger and self.external_logger is not default_log_handler: + # Format log data similar to TS LogLine structure + log_data = { + "message": {"message": message, "level": level}, + "timestamp": datetime.now().isoformat(), + } + if category: + log_data["category"] = category + if auxiliary: + log_data["auxiliary"] = auxiliary + + # Handle async callback properly + if asyncio.iscoroutinefunction(self.external_logger): + # Create a task but don't wait for it - this avoids blocking + # Must be called from an async context + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.create_task(self.external_logger(log_data)) + else: + self.external_logger(log_data) + except RuntimeError: + # No event loop running, log a warning + self.external_logger(log_data) + else: + # Synchronous callback, just call directly + self.external_logger(log_data) + return + + # Get level style + level_style = self.level_style.get(level, "info") + + # Check for Fastify server logs and format them specially + formatted_message, formatted_auxiliary = self._format_fastify_log( + message, auxiliary + ) + + # Process the auxiliary data if it wasn't handled by the Fastify formatter + if formatted_auxiliary is None: + aux_data = None + else: + # For regular messages, apply JSON formatting + formatted_message = self._format_message_with_json(formatted_message) + aux_data = ( + self._format_auxiliary_compact(formatted_auxiliary or auxiliary) + if auxiliary + else {} + ) + + # Format the log message + if self.use_rich: + # Format the timestamp + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Special handling for specific categories + if category in ["action", "navigation"]: + # Success marker for completed actions + if ( + "Navigated to" in formatted_message + or "Clicked on" in formatted_message + ): + self.console.print( + f"[timestamp]{timestamp}[/timestamp] [success]✓[/success] {formatted_message}" + ) + else: + # Pending action marker + self.console.print( + f"[timestamp]{timestamp}[/timestamp] [pending]→[/pending] {formatted_message}" + ) + return + + # For captcha category, show a more compact format + if category == "captcha": + self.console.print( + f"[timestamp]{timestamp}[/timestamp] [info]⏳[/info] {formatted_message}" + ) + return + + # Create the line prefix + line_prefix = f"[timestamp]{timestamp}[/timestamp] [{level_style}]{level_style.upper()}[/{level_style}]" + + # Add category if present + if category: + line_prefix += f" [category]{category}[/category]" + + # Add the message + log_line = f"{line_prefix} - {formatted_message}" + + # Handle ellipses in the log line + if "..." in log_line and "[ellipsis]" not in log_line: + log_line = log_line.replace("...", "[ellipsis]...[/ellipsis]") + + # Add auxiliary data if we have it and it's processed + if aux_data: + if isinstance(aux_data, dict) and len(aux_data) <= 2: + # Show simple data inline + items = [] + for k, v in aux_data.items(): + if isinstance(v, str) and len(v) > 50: + # Truncate long strings for inline display + items.append(f"{k}={v[:47]}...") + else: + items.append(f"{k}={v}") + + # Add as inline content with soft styling + if items: + log_line += f" [auxiliary]({', '.join(items)})[/auxiliary]" + elif aux_data is not None: + # We'll print auxiliary data separately + self.console.print(log_line) + + # Create a table for structured display of auxiliary data + table = Table(show_header=False, box=None, padding=(0, 1, 0, 1)) + table.add_column("Key", style="cyan") + table.add_column("Value") + + for k, v in aux_data.items(): + if isinstance(v, (dict, list)): + # Format complex value as JSON + table.add_row(k, str(self._format_json(v))) + elif isinstance(v, str) and v.startswith( + ("http://", "https://") + ): + # Highlight URLs + table.add_row(k, f"[link]{v}[/link]") + else: + table.add_row(k, str(v)) + + # Print the table with a subtle panel + self.console.print(Panel(table, expand=False, border_style="dim")) + return + + # Print the log line + self.console.print(log_line) + + else: + # Standard logging + prefix = f"[{category}] " if category else "" + log_message = f"{prefix}{formatted_message}" + + # Add auxiliary data in a clean format if present + if auxiliary: + # Format auxiliary data + aux_parts = [] + for key, value in auxiliary.items(): + # Unpack nested values similar to TS implementation + if isinstance(value, dict) and "value" in value: + extracted_value = value["value"] + # Handle different value types appropriately + if ( + isinstance(extracted_value, str) + and len(extracted_value) > 80 + ): + # Keep URLs and IDs intact + if any( + term in key.lower() for term in ["url", "id", "link"] + ): + aux_parts.append(f"{key}={extracted_value}") + else: + aux_parts.append(f"{key}={extracted_value[:77]}...") + else: + aux_parts.append(f"{key}={str(extracted_value)}") + else: + # For direct values + if isinstance(value, str) and len(value) > 80: + aux_parts.append(f"{key}={value[:77]}...") + else: + aux_parts.append(f"{key}={str(value)}") + + # Add formatted auxiliary data + if aux_parts: + log_message += f" ({', '.join(aux_parts)})" + + # Log with appropriate level + if level == 0: + logger.error(log_message) + elif level == 1: + logger.info(log_message) + elif level == 2: + logger.warning(log_message) + elif level == 3: + logger.debug(log_message) + + # Convenience methods + def error( + self, message: str, category: str = None, auxiliary: dict[str, Any] = None + ): + """Log an error message (level 0)""" + self.log(message, level=0, category=category, auxiliary=auxiliary) + + def info( + self, message: str, category: str = None, auxiliary: dict[str, Any] = None + ): + """Log an info message (level 1)""" + self.log(message, level=1, category=category, auxiliary=auxiliary) + + def warning( + self, message: str, category: str = None, auxiliary: dict[str, Any] = None + ): + """Log a warning/detailed message (level 2)""" + self.log(message, level=2, category=category, auxiliary=auxiliary) + + def debug( + self, message: str, category: str = None, auxiliary: dict[str, Any] = None + ): + """Log a debug message (level 3)""" + self.log(message, level=3, category=category, auxiliary=auxiliary) + + +# Create a synchronous wrapper for the async default_log_handler +def sync_log_handler(log_data: dict[str, Any]) -> None: + """ + Synchronous wrapper for log handling, doesn't require awaiting. + This avoids the coroutine never awaited warnings. + """ + # Extract relevant data from the log message + level = log_data.get("level", 1) + if isinstance(level, str): + level = {"error": 0, "info": 1, "warning": 2, "debug": 3}.get(level.lower(), 1) + + message = log_data.get("message", "") + category = log_data.get("category", "") + auxiliary = {} + + # Process auxiliary data if present + if "auxiliary" in log_data: + auxiliary = log_data.get("auxiliary", {}) + + # Convert string representation to actual object if needed + if isinstance(auxiliary, str) and ( + auxiliary.startswith("{") or auxiliary.startswith("{'") + ): + try: + import ast + + auxiliary = ast.literal_eval(auxiliary) + except (SyntaxError, ValueError): + # If parsing fails, keep as string + pass + + # Create a temporary logger to handle + temp_logger = StagehandLogger(verbose=3, use_rich=True, external_logger=None) + + try: + # Use the logger to format and display the message + temp_logger.log(message, level=level, category=category, auxiliary=auxiliary) + except Exception as e: + # Fall back to basic logging if formatting fails + print(f"Error formatting log: {str(e)}") + print(f"Original message: {message}") + if category: + print(f"Category: {category}") + if auxiliary: + print(f"Auxiliary data: {auxiliary}") + + +async def default_log_handler(log_data: dict[str, Any]) -> None: + """ + Enhanced default handler for log messages from the Stagehand server. + Uses Rich for pretty printing and JSON formatting. + + This is an async function but calls the synchronous implementation. + """ + sync_log_handler(log_data) diff --git a/stagehand/schemas.py b/stagehand/schemas.py index d0d14cd..56852df 100644 --- a/stagehand/schemas.py +++ b/stagehand/schemas.py @@ -193,7 +193,6 @@ class ObserveOptions(StagehandBaseModel): instruction: str = Field( ..., description="Instruction detailing what the AI should observe." ) - only_visible: Optional[bool] = False model_name: Optional[str] = None return_action: Optional[bool] = None draw_overlay: Optional[bool] = None diff --git a/stagehand/utils.py b/stagehand/utils.py index 9ef5278..d5e9867 100644 --- a/stagehand/utils.py +++ b/stagehand/utils.py @@ -1,638 +1,11 @@ -import asyncio import inspect -import json -import logging -from datetime import datetime -from typing import Any, Callable, Optional, Union, get_args, get_origin +from typing import Any, Union, get_args, get_origin from pydantic import AnyUrl, BaseModel, Field, HttpUrl, create_model from pydantic.fields import FieldInfo -from rich.console import Console -from rich.logging import RichHandler -from rich.panel import Panel -from rich.syntax import Syntax -from rich.table import Table -from rich.theme import Theme from stagehand.types.a11y import AccessibilityNode -# Custom theme for Rich -stagehand_theme = Theme( - { - "info": "cyan", - "warning": "yellow", - "error": "bold red", - "debug": "bold white", - "category": "bold blue", - "auxiliary": "white", - "timestamp": "dim white", - "success": "bold white", - "pending": "bold yellow", - "ellipsis": "bold white", - } -) - -# Create console instance with theme -console = Console(theme=stagehand_theme) - -# Setup logging with Rich handler -logger = logging.getLogger(__name__) -# Only add handler if there isn't one already to avoid duplicate logs -if not logger.handlers: - handler = RichHandler( - rich_tracebacks=True, - markup=True, - console=console, - show_time=False, # We'll add our own timestamp - show_level=False, # We'll format our own level - ) - handler.setFormatter(logging.Formatter("%(message)s")) - logger.addHandler(handler) - # Don't propagate to root logger to avoid duplicate logs - logger.propagate = False - - -def configure_logging( - level=logging.INFO, - format_str=None, - datefmt="%Y-%m-%d %H:%M:%S", - quiet_dependencies=True, - utils_level=None, - remove_logger_name=True, - use_rich=True, -): - """ - Configure logging for Stagehand with sensible defaults. - - Args: - level: The logging level for Stagehand loggers (default: INFO) - format_str: The format string for log messages (default: depends on remove_logger_name) - datefmt: The date format string for log timestamps - quiet_dependencies: If True, sets httpx, httpcore, and other noisy dependencies to WARNING level - utils_level: Optional specific level for stagehand.utils logger (default: None, uses the main level) - remove_logger_name: If True, use a more concise log format without showing full logger name - use_rich: If True, use Rich for colorized, pretty-printed output - """ - # Set default format if not provided - if format_str is None: - if remove_logger_name: - format_str = "%(asctime)s - %(levelname)s - %(message)s" - else: - format_str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - - # Configure root logger with custom format - if use_rich: - # Use Rich handler for root logger - logging.basicConfig( - level=level, - format="%(message)s", - datefmt=datefmt, - handlers=[ - RichHandler( - rich_tracebacks=True, - markup=True, - console=console, - show_time=False, - show_level=False, - ) - ], - ) - else: - # Use standard handler - logging.basicConfig( - level=level, - format=format_str, - datefmt=datefmt, - ) - - # Configure Stagehand logger to use the specified level - logging.getLogger("stagehand").setLevel(level) - - # Set specific level for utils logger if specified - if utils_level is not None: - logging.getLogger("stagehand.utils").setLevel(utils_level) - - # Set higher log levels for noisy dependencies - if quiet_dependencies: - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - logging.getLogger("asyncio").setLevel(logging.WARNING) - - -################################################################################ -# -# StagehandLogger: move into it's own file -# -################################################################################ -class StagehandLogger: - """ - Enhanced Python equivalent of the TypeScript StagehandLogger class. - Provides structured logging with improved formatting using Rich. - """ - - def __init__( - self, - verbose: int = 1, - external_logger: Optional[Callable] = None, - use_rich: bool = True, - ): - """ - Initialize the logger with specified verbosity and optional external logger. - - Args: - verbose: Verbosity level (0=error only, 1=info, 2=detailed, 3=debug) - external_logger: Optional callback function for log events - use_rich: Whether to use Rich for pretty output (default: True) - """ - self.verbose = verbose - self.external_logger = external_logger - self.use_rich = use_rich - self.console = console - - # Map our verbosity levels to Python's logging levels - self.level_map = { - 0: logging.ERROR, # Critical errors only - 1: logging.INFO, # Standard information - 2: logging.WARNING, # More detailed info (using WARNING level) - 3: logging.DEBUG, # Debug information - } - - # Map level to style names - self.level_style = {0: "error", 1: "info", 2: "warning", 3: "debug"} - - # Update logger level based on verbosity - self._set_verbosity(verbose) - - def _set_verbosity(self, level: int): - """Set the logger verbosity level""" - self.verbose = level - logger.setLevel(self.level_map.get(level, logging.INFO)) - - def _format_json(self, data: dict) -> str: - """Format JSON data nicely with syntax highlighting""" - if not self.use_rich: - return json.dumps(data, indent=2) - - # Create a nice-looking JSON string with syntax highlighting - json_str = json.dumps(data, indent=2) - syntax = Syntax(json_str, "json", theme="monokai", word_wrap=True) - return syntax - - def _format_message_with_json(self, message: str) -> str: - """ - Parse and format any JSON-like structures within a message string. - This helps with pretty-printing log messages that contain raw JSON or Python dict representations. - """ - # Handle case where message is already a dictionary - if isinstance(message, dict): - # Format the dict as JSON - if self.use_rich: - json_str = json.dumps(message, indent=2) - return f"\n{json_str}" - else: - return json.dumps(message, indent=2) - - if not isinstance(message, str): - # If not a string and not a dict, convert to string - return str(message) - - import re - - # Function to replace dict-like patterns with formatted JSON - def replace_dict(match): - try: - # Handle Python dictionary format by safely evaluating it - # This converts string representation of Python dict to actual dict - import ast - - dict_str = match.group(0) - dict_obj = ast.literal_eval(dict_str) - - # Format the dict as JSON - if self.use_rich: - json_str = json.dumps(dict_obj, indent=2) - return f"\n{json_str}" - else: - return json.dumps(dict_obj, indent=2) - except (SyntaxError, ValueError): - # If parsing fails, return the original string - return match.group(0) - - # Pattern to match Python dictionary literals - pattern = r"(\{[^{}]*(\{[^{}]*\}[^{}]*)*\})" - - # Replace dictionary patterns with formatted JSON - return re.sub(pattern, replace_dict, message) - - def _format_fastify_log( - self, message: str, auxiliary: dict[str, Any] = None - ) -> tuple: - """ - Special formatting for logs that come from the Fastify server. - These often contain Python representations of JSON objects. - - Returns: - tuple: (formatted_message, formatted_auxiliary) - """ - # Handle case where message is already a dictionary - if isinstance(message, dict): - # Extract the actual message and other fields - extracted_message = message.get("message", "") - category = message.get("category", "") - - # Format any remaining data for display - formatted_json = json.dumps(message, indent=2) - - if self.use_rich: - Syntax(formatted_json, "json", theme="monokai", word_wrap=True) - if category: - extracted_message = f"[{category}] {extracted_message}" - - # Handle ellipses in message separately - if "..." in extracted_message: - extracted_message = extracted_message.replace( - "...", "[ellipsis]...[/ellipsis]" - ) - - return extracted_message, None - else: - if category and not extracted_message.startswith(f"[{category}]"): - extracted_message = f"[{category}] {extracted_message}" - return extracted_message, None - - # Check if this appears to be a string representation of a JSON object - elif isinstance(message, str) and ( - message.startswith("{'") or message.startswith("{") - ): - try: - # Try to parse the message as a Python dict using ast.literal_eval - # This is safer than eval() for parsing Python literal structures - import ast - - data = ast.literal_eval(message) - - # Extract the actual message and other fields - extracted_message = data.get("message", "") - category = data.get("category", "") - - # Format any remaining data for display - formatted_json = json.dumps(data, indent=2) - - if self.use_rich: - Syntax(formatted_json, "json", theme="monokai", word_wrap=True) - if category: - extracted_message = f"[{category}] {extracted_message}" - - # Handle ellipses in message separately - if "..." in extracted_message: - extracted_message = extracted_message.replace( - "...", "[ellipsis]...[/ellipsis]" - ) - - return extracted_message, None - else: - if category and not extracted_message.startswith(f"[{category}]"): - extracted_message = f"[{category}] {extracted_message}" - return extracted_message, None - except (SyntaxError, ValueError): - # If parsing fails, use the original message - pass - - # For regular string messages that contain ellipses - elif isinstance(message, str) and "..." in message: - formatted_message = message.replace("...", "[ellipsis]...[/ellipsis]") - return formatted_message, auxiliary - - # Default: return the original message and auxiliary - return message, auxiliary - - def _format_auxiliary_compact(self, auxiliary: dict[str, Any]) -> str: - """Format auxiliary data in a compact, readable way""" - if not auxiliary: - return {} - - # Clean and format the auxiliary data - formatted = {} - - for key, value in auxiliary.items(): - # Skip internal keys in normal logging - if key in ["requestId", "elementId", "type"]: - continue - - # Handle nested values that come from the API - if isinstance(value, dict) and "value" in value: - extracted = value.get("value") - type_info = value.get("type") - - # Skip empty values - if not extracted: - continue - - # For nested objects with 'value' and 'type', use a cleaner representation - if isinstance(extracted, (dict, list)) and type_info == "object": - # For complex objects, keep the whole structure - formatted[key] = extracted - # Handle different types of values - elif key in ["sessionId", "url", "sessionUrl", "debugUrl"]: - # Keep these values as is - formatted[key] = extracted - elif isinstance(extracted, str) and len(extracted) > 40: - # Truncate long strings - formatted[key] = f"{extracted[:37]}..." - else: - formatted[key] = extracted - else: - # Handle direct values - formatted[key] = value - - return formatted - - def log( - self, - message: str, - level: int = 1, - category: str = None, - auxiliary: dict[str, Any] = None, - ): - """ - Log a message with structured data, with Rich formatting. - - Args: - message: The message to log - level: Verbosity level (0=error, 1=info, 2=detailed, 3=debug) - category: Optional category for the message - auxiliary: Optional dictionary of auxiliary data - """ - # Skip logging if below current verbosity level - if level > self.verbose and level != 0: # Always log errors (level 0) - return - - # Call external logger if provided (handle async function) - if self.external_logger and self.external_logger is not default_log_handler: - # Format log data similar to TS LogLine structure - log_data = { - "message": {"message": message, "level": level}, - "timestamp": datetime.now().isoformat(), - } - if category: - log_data["category"] = category - if auxiliary: - log_data["auxiliary"] = auxiliary - - # Handle async callback properly - if asyncio.iscoroutinefunction(self.external_logger): - # Create a task but don't wait for it - this avoids blocking - # Must be called from an async context - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - asyncio.create_task(self.external_logger(log_data)) - else: - self.external_logger(log_data) - except RuntimeError: - # No event loop running, log a warning - self.external_logger(log_data) - else: - # Synchronous callback, just call directly - self.external_logger(log_data) - return - - # Get level style - level_style = self.level_style.get(level, "info") - - # Check for Fastify server logs and format them specially - formatted_message, formatted_auxiliary = self._format_fastify_log( - message, auxiliary - ) - - # Process the auxiliary data if it wasn't handled by the Fastify formatter - if formatted_auxiliary is None: - aux_data = None - else: - # For regular messages, apply JSON formatting - formatted_message = self._format_message_with_json(formatted_message) - aux_data = ( - self._format_auxiliary_compact(formatted_auxiliary or auxiliary) - if auxiliary - else {} - ) - - # Format the log message - if self.use_rich: - # Format the timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # Special handling for specific categories - if category in ["action", "navigation"]: - # Success marker for completed actions - if ( - "Navigated to" in formatted_message - or "Clicked on" in formatted_message - ): - self.console.print( - f"[timestamp]{timestamp}[/timestamp] [success]✓[/success] {formatted_message}" - ) - else: - # Pending action marker - self.console.print( - f"[timestamp]{timestamp}[/timestamp] [pending]→[/pending] {formatted_message}" - ) - return - - # For captcha category, show a more compact format - if category == "captcha": - self.console.print( - f"[timestamp]{timestamp}[/timestamp] [info]⏳[/info] {formatted_message}" - ) - return - - # Create the line prefix - line_prefix = f"[timestamp]{timestamp}[/timestamp] [{level_style}]{level_style.upper()}[/{level_style}]" - - # Add category if present - if category: - line_prefix += f" [category]{category}[/category]" - - # Add the message - log_line = f"{line_prefix} - {formatted_message}" - - # Handle ellipses in the log line - if "..." in log_line and "[ellipsis]" not in log_line: - log_line = log_line.replace("...", "[ellipsis]...[/ellipsis]") - - # Add auxiliary data if we have it and it's processed - if aux_data: - if isinstance(aux_data, dict) and len(aux_data) <= 2: - # Show simple data inline - items = [] - for k, v in aux_data.items(): - if isinstance(v, str) and len(v) > 50: - # Truncate long strings for inline display - items.append(f"{k}={v[:47]}...") - else: - items.append(f"{k}={v}") - - # Add as inline content with soft styling - if items: - log_line += f" [auxiliary]({', '.join(items)})[/auxiliary]" - elif aux_data is not None: - # We'll print auxiliary data separately - self.console.print(log_line) - - # Create a table for structured display of auxiliary data - table = Table(show_header=False, box=None, padding=(0, 1, 0, 1)) - table.add_column("Key", style="cyan") - table.add_column("Value") - - for k, v in aux_data.items(): - if isinstance(v, (dict, list)): - # Format complex value as JSON - table.add_row(k, str(self._format_json(v))) - elif isinstance(v, str) and v.startswith( - ("http://", "https://") - ): - # Highlight URLs - table.add_row(k, f"[link]{v}[/link]") - else: - table.add_row(k, str(v)) - - # Print the table with a subtle panel - self.console.print(Panel(table, expand=False, border_style="dim")) - return - - # Print the log line - self.console.print(log_line) - - else: - # Standard logging - prefix = f"[{category}] " if category else "" - log_message = f"{prefix}{formatted_message}" - - # Add auxiliary data in a clean format if present - if auxiliary: - # Format auxiliary data - aux_parts = [] - for key, value in auxiliary.items(): - # Unpack nested values similar to TS implementation - if isinstance(value, dict) and "value" in value: - extracted_value = value["value"] - # Handle different value types appropriately - if ( - isinstance(extracted_value, str) - and len(extracted_value) > 80 - ): - # Keep URLs and IDs intact - if any( - term in key.lower() for term in ["url", "id", "link"] - ): - aux_parts.append(f"{key}={extracted_value}") - else: - aux_parts.append(f"{key}={extracted_value[:77]}...") - else: - aux_parts.append(f"{key}={str(extracted_value)}") - else: - # For direct values - if isinstance(value, str) and len(value) > 80: - aux_parts.append(f"{key}={value[:77]}...") - else: - aux_parts.append(f"{key}={str(value)}") - - # Add formatted auxiliary data - if aux_parts: - log_message += f" ({', '.join(aux_parts)})" - - # Log with appropriate level - if level == 0: - logger.error(log_message) - elif level == 1: - logger.info(log_message) - elif level == 2: - logger.warning(log_message) - elif level == 3: - logger.debug(log_message) - - # Convenience methods - def error( - self, message: str, category: str = None, auxiliary: dict[str, Any] = None - ): - """Log an error message (level 0)""" - self.log(message, level=0, category=category, auxiliary=auxiliary) - - def info( - self, message: str, category: str = None, auxiliary: dict[str, Any] = None - ): - """Log an info message (level 1)""" - self.log(message, level=1, category=category, auxiliary=auxiliary) - - def warning( - self, message: str, category: str = None, auxiliary: dict[str, Any] = None - ): - """Log a warning/detailed message (level 2)""" - self.log(message, level=2, category=category, auxiliary=auxiliary) - - def debug( - self, message: str, category: str = None, auxiliary: dict[str, Any] = None - ): - """Log a debug message (level 3)""" - self.log(message, level=3, category=category, auxiliary=auxiliary) - - -# Create a synchronous wrapper for the async default_log_handler -def sync_log_handler(log_data: dict[str, Any]) -> None: - """ - Synchronous wrapper for log handling, doesn't require awaiting. - This avoids the coroutine never awaited warnings. - """ - # Extract relevant data from the log message - level = log_data.get("level", 1) - if isinstance(level, str): - level = {"error": 0, "info": 1, "warning": 2, "debug": 3}.get(level.lower(), 1) - - message = log_data.get("message", "") - category = log_data.get("category", "") - auxiliary = {} - - # Process auxiliary data if present - if "auxiliary" in log_data: - auxiliary = log_data.get("auxiliary", {}) - - # Convert string representation to actual object if needed - if isinstance(auxiliary, str) and ( - auxiliary.startswith("{") or auxiliary.startswith("{'") - ): - try: - import ast - - auxiliary = ast.literal_eval(auxiliary) - except (SyntaxError, ValueError): - # If parsing fails, keep as string - pass - - # Create a temporary logger to handle - temp_logger = StagehandLogger(verbose=3, use_rich=True, external_logger=None) - - try: - # Use the logger to format and display the message - temp_logger.log(message, level=level, category=category, auxiliary=auxiliary) - except Exception as e: - # Fall back to basic logging if formatting fails - print(f"Error formatting log: {str(e)}") - print(f"Original message: {message}") - if category: - print(f"Category: {category}") - if auxiliary: - print(f"Auxiliary data: {auxiliary}") - - -async def default_log_handler(log_data: dict[str, Any]) -> None: - """ - Enhanced default handler for log messages from the Stagehand server. - Uses Rich for pretty printing and JSON formatting. - - This is an async function but calls the synchronous implementation. - """ - sync_log_handler(log_data) - def snake_to_camel(snake_str: str) -> str: """ From ddae14f3f027f3fbc41b18ab631e668a7ec29a25 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 2 Jun 2025 20:59:31 -0700 Subject: [PATCH 02/11] remove warning level, consolidate with api --- stagehand/a11y/utils.py | 13 ++++++------- stagehand/agent.py | 10 +++++++++- stagehand/agent/anthropic_cua.py | 14 +++++++------- stagehand/agent/google_cua.py | 10 +++++----- stagehand/agent/openai_cua.py | 6 +++--- stagehand/client.py | 17 ++++++++--------- stagehand/config.py | 4 ++-- stagehand/handlers/act_handler.py | 6 +++--- stagehand/handlers/act_handler_utils.py | 2 +- stagehand/handlers/cua_handler.py | 8 ++++---- stagehand/llm/client.py | 3 --- stagehand/logging.py | 25 ++++++++----------------- stagehand/page.py | 24 +++++++++++++----------- 13 files changed, 69 insertions(+), 73 deletions(-) diff --git a/stagehand/a11y/utils.py b/stagehand/a11y/utils.py index 828ada5..17d6359 100644 --- a/stagehand/a11y/utils.py +++ b/stagehand/a11y/utils.py @@ -81,16 +81,16 @@ async def _clean_structural_nodes( node["role"] = result_value node_role = result_value except Exception as tag_name_error: - # Use logger.warning (level 2) - logger.warning( + # Use logger.debug (level 2) + logger.debug( message=f"Could not fetch tagName for node {backend_node_id}", auxiliary={ "error": {"value": str(tag_name_error), "type": "string"} }, ) except Exception as resolve_error: - # Use logger.warning (level 2) - logger.warning( + # Use logger.debug (level 2) + logger.debug( message=f"Could not resolve DOM node ID {backend_node_id}", auxiliary={"error": {"value": str(resolve_error), "type": "string"}}, ) @@ -278,9 +278,8 @@ async def get_accessibility_tree( try: await page.disable_cdp_domain("Accessibility") except Exception: - # Log if disabling fails, but don't raise further - if logger: - logger.warning("Failed to disable Accessibility domain on cleanup.") + # Use logger.debug (level 2) + logger.debug("Failed to disable Accessibility domain on cleanup.") # JavaScript function to get XPath (remains JavaScript) diff --git a/stagehand/agent.py b/stagehand/agent.py index 1d8c93d..32e689d 100644 --- a/stagehand/agent.py +++ b/stagehand/agent.py @@ -31,12 +31,20 @@ def __init__(self, stagehand_client, agent_config: AgentConfig): self._stagehand = stagehand_client self._config = agent_config # Store the required config + if not self._stagehand._initialized: + self._stagehand.logger.error( + "Stagehand must be initialized before creating an agent. Call await stagehand.init() first." + ) + raise RuntimeError( + "Stagehand must be initialized before creating an agent. Call await stagehand.init() first." + ) + # Perform provider inference and validation if self._config.model and not self._config.provider: if self._config.model in MODEL_TO_PROVIDER_MAP: self._config.provider = MODEL_TO_PROVIDER_MAP[self._config.model] else: - self._stagehand.logger.warning( + self._stagehand.logger.error( f"Could not infer provider for model: {self._config.model}" ) diff --git a/stagehand/agent/anthropic_cua.py b/stagehand/agent/anthropic_cua.py index ef8406a..c0ae7e9 100644 --- a/stagehand/agent/anthropic_cua.py +++ b/stagehand/agent/anthropic_cua.py @@ -243,7 +243,7 @@ async def run_task( break if not agent_action and not task_completed: - self.logger.warning( + self.logger.debug( "Model did not request an action and task not marked complete. Ending task to prevent loop.", category=StagehandFunctionName.AGENT, ) @@ -290,7 +290,7 @@ def _process_provider_response( block.model_dump() for block in response.content ] except Exception as e: - self.logger.warning( + self.logger.debug( f"Could not model_dump response.content blocks: {e}", category=StagehandFunctionName.AGENT, ) @@ -337,7 +337,7 @@ def _convert_tool_use_to_agent_action( and tool_name != "goto" and tool_name != "navigate_back" ): - self.logger.warning( + self.logger.debug( f"Unsupported tool name from Anthropic: {tool_name}", category=StagehandFunctionName.AGENT, ) @@ -501,7 +501,7 @@ def _convert_tool_use_to_agent_action( ) action_type_str = "drag" # Normalize else: - self.logger.warning( + self.logger.debug( "Drag action missing valid start or end coordinates.", category=StagehandFunctionName.AGENT, ) @@ -559,7 +559,7 @@ def _convert_tool_use_to_agent_action( ) action_type_str = "function" else: - self.logger.warning( + self.logger.debug( "Goto action from Anthropic missing URL", category=StagehandFunctionName.AGENT, ) @@ -572,7 +572,7 @@ def _convert_tool_use_to_agent_action( ) action_type_str = "function" else: - self.logger.warning( + self.logger.debug( f"Unsupported action type '{action_type_str}' from Anthropic computer tool.", category=StagehandFunctionName.AGENT, ) @@ -613,7 +613,7 @@ def _format_action_feedback( self.format_screenshot(new_screenshot_base64) ) else: - self.logger.warning( + self.logger.debug( "Missing screenshot for computer tool feedback (empty string passed).", category=StagehandFunctionName.AGENT, ) diff --git a/stagehand/agent/google_cua.py b/stagehand/agent/google_cua.py index d908e2d..5f15c3a 100644 --- a/stagehand/agent/google_cua.py +++ b/stagehand/agent/google_cua.py @@ -150,7 +150,7 @@ async def _process_provider_response( and candidate.safety_ratings ): error_message += f" - Safety Ratings: {candidate.safety_ratings}" - self.logger.warning(error_message, category="agent") + self.logger.info(error_message, category="agent") return None, reasoning_text, True, error_message, None agent_action: Optional[AgentAction] = None @@ -218,7 +218,7 @@ async def _process_provider_response( "keys": [self.key_to_playwright("PageDown")], } else: - self.logger.warning( + self.logger.error( f"Unsupported scroll direction: {direction}", category="agent" ) return ( @@ -264,7 +264,7 @@ async def _process_provider_response( "arguments": {"url": "https://www.google.com"}, } else: - self.logger.warning( + self.logger.error( f"Unsupported Google CUA function: {action_name}", category="agent" ) return ( @@ -524,7 +524,7 @@ async def run_task( ) if not agent_action and not task_completed: - self.logger.warning( + self.logger.error( "Model did not request an action and task not marked complete. Ending task.", category="agent", ) @@ -540,7 +540,7 @@ async def run_task( usage=usage_obj, ) - self.logger.warning("Max steps reached for Google CUA task.", category="agent") + self.logger.error("Max steps reached for Google CUA task.", category="agent") usage_obj = { "input_tokens": total_input_tokens, "output_tokens": total_output_tokens, diff --git a/stagehand/agent/openai_cua.py b/stagehand/agent/openai_cua.py index e5fa6d8..8a86569 100644 --- a/stagehand/agent/openai_cua.py +++ b/stagehand/agent/openai_cua.py @@ -214,7 +214,7 @@ def _process_provider_response( ) # Ensure arguments is a dict, even if empty if not isinstance(arguments, dict): - self.logger.warning( + self.logger.debug( f"Function call arguments are not a dict: {arguments}. Using empty dict.", category="agent", ) @@ -464,7 +464,7 @@ async def run_task( ) if not agent_action and not task_completed: - self.logger.warning( + self.logger.info( "Model did not request an action and task not marked complete. Ending task to prevent loop.", category="agent", ) @@ -480,7 +480,7 @@ async def run_task( usage=usage_obj, ) - self.logger.warning("Max steps reached for OpenAI CUA task.", category="agent") + self.logger.info("Max steps reached for OpenAI CUA task.", category="agent") usage_obj = { "input_tokens": total_input_tokens, "output_tokens": total_output_tokens, diff --git a/stagehand/client.py b/stagehand/client.py index 6879ffa..4a3a5db 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -179,7 +179,7 @@ def __init__( ) if not self.model_api_key: # Model API key needed if Stagehand server creates the session - self.logger.warning( + self.logger.info( "model_api_key is recommended when creating a new BROWSERBASE session to configure the Stagehand server's LLM." ) elif self.session_id: @@ -477,7 +477,7 @@ async def init(self): self._context = existing_contexts[0] else: # This case might be less common with Browserbase but handle it - self.logger.warning( + self.logger.debug( "No existing context found in remote browser, creating a new one." ) self._context = ( @@ -705,7 +705,7 @@ async def close(self): f"Error ending server session {self.session_id}: {str(e)}" ) elif self.session_id: - self.logger.warning( + self.logger.debug( "Cannot end server session: HTTP client not available." ) @@ -769,7 +769,7 @@ async def _create_session(self): payload = { "modelName": self.model_name, - "verbose": 2 if self.verbose == 3 else self.verbose, + "verbose": self.verbose, "domSettleTimeoutMs": self.dom_settle_timeout_ms, "browserbaseSessionCreateParams": ( browserbase_session_create_params @@ -913,7 +913,7 @@ async def _execute(self, method: str, payload: dict[str, Any]) -> Any: # Log any other message types self.logger.debug(f"[UNKNOWN] Message type: {msg_type}") except json.JSONDecodeError: - self.logger.warning(f"Could not parse line as JSON: {line}") + self.logger.debug(f"Could not parse line as JSON: {line}") # Return the final result return result @@ -942,9 +942,8 @@ async def _handle_log(self, msg: dict[str, Any]): # Map level strings to internal levels level_map = { - "debug": 3, + "debug": 2, "info": 1, - "warning": 2, "error": 0, } @@ -952,7 +951,7 @@ async def _handle_log(self, msg: dict[str, Any]): if isinstance(level_str, str): internal_level = level_map.get(level_str.lower(), 1) else: - internal_level = min(level_str, 3) # Ensure level is between 0-3 + internal_level = min(level_str, 2) # Ensure level is between 0-2 # Handle the case where message itself might be a JSON-like object if isinstance(message, dict): @@ -986,7 +985,7 @@ def _log( Args: message: The message to log - level: Verbosity level (0=error, 1=info, 2=detailed, 3=debug) + level: Verbosity level (0=error, 1=info, 2=debug) category: Optional category for the message auxiliary: Optional auxiliary data to include """ diff --git a/stagehand/config.py b/stagehand/config.py index 4bbf68e..ab02b25 100644 --- a/stagehand/config.py +++ b/stagehand/config.py @@ -25,7 +25,7 @@ class StagehandConfig(BaseModel): wait_for_captcha_solves (Optional[bool]): Whether to wait for CAPTCHA to be solved. act_timeout_ms (Optional[int]): Timeout for act commands (in milliseconds). system_prompt (Optional[str]): System prompt to use for LLM interactions. - verbose (Optional[int]): Verbosity level for logs (1=minimal, 2=medium, 3=detailed). + verbose (Optional[int]): Verbosity level for logs (0=errors, 1=info, 2=debug). local_browser_launch_options (Optional[dict[str, Any]]): Local browser launch options. """ @@ -38,7 +38,7 @@ class StagehandConfig(BaseModel): ) verbose: Optional[int] = Field( 1, - description="Verbosity level for logs: 1=minimal (INFO), 2=medium (WARNING), 3=detailed (DEBUG)", + description="Verbosity level for logs: 0=errors only, 1=info, 2=debug", ) logger: Optional[Callable[[Any], None]] = Field( None, description="Custom logging function" diff --git a/stagehand/handlers/act_handler.py b/stagehand/handlers/act_handler.py index b6333e9..27ae6bd 100644 --- a/stagehand/handlers/act_handler.py +++ b/stagehand/handlers/act_handler.py @@ -156,7 +156,7 @@ async def _act_from_observe_result( ) if observe_result.method == "not-supported": - self.logger.warning( + self.logger.error( message="Cannot execute ObserveResult with unsupported method", category="act", auxiliary={ @@ -229,7 +229,7 @@ async def _act_from_observe_result( if ( not act_command ): # If both method and description were empty or resulted in an empty command - self.logger.warning( + self.logger.error( "Self-heal attempt aborted: could not construct a valid command from ObserveResult.", category="act", auxiliary={ @@ -307,7 +307,7 @@ async def _perform_playwright_method( elif hasattr(locator, method) and callable(getattr(locator, method)): await fallback_locator_method(context) else: - self.logger.warning( + self.logger.error( message="chosen method is invalid", category="act", auxiliary={"method": {"value": method, "type": "string"}}, diff --git a/stagehand/handlers/act_handler_utils.py b/stagehand/handlers/act_handler_utils.py index 6506ab5..24950eb 100644 --- a/stagehand/handlers/act_handler_utils.py +++ b/stagehand/handlers/act_handler_utils.py @@ -458,7 +458,7 @@ async def handle_possible_page_navigation( try: await stagehand_page._wait_for_settled_dom(dom_settle_timeout_ms) except Exception as e: - logger.warning( + logger.debug( message="wait for settled DOM timeout hit", category="action", auxiliary={ diff --git a/stagehand/handlers/cua_handler.py b/stagehand/handlers/cua_handler.py index cfe9aec..d718b1b 100644 --- a/stagehand/handlers/cua_handler.py +++ b/stagehand/handlers/cua_handler.py @@ -149,7 +149,7 @@ async def perform_action(self, action: AgentAction) -> ActionExecutionResult: await self.page.go_back() return {"success": True} # Add other function calls like back, forward, reload if needed, similar to TS version - self.logger.warning( + self.logger.error( f"Unsupported function call: {name}", category=StagehandFunctionName.AGENT, ) @@ -195,7 +195,7 @@ async def perform_action(self, action: AgentAction) -> ActionExecutionResult: return {"success": True} else: - self.logger.warning( + self.logger.error( f"Unsupported action type: {action_type}", category=StagehandFunctionName.AGENT, ) @@ -236,7 +236,7 @@ async def _update_cursor_position(self, x: int, y: int) -> None: f"window.__stagehandUpdateCursorPosition({x}, {y})" ) except Exception as e: - self.logger.warning( + self.logger.debug( f"Failed to call window.__stagehandUpdateCursorPosition: {e}", category=StagehandFunctionName.AGENT, ) @@ -246,7 +246,7 @@ async def _animate_click(self, x: int, y: int) -> None: try: await self.page.evaluate(f"window.__stagehandAnimateClick({x}, {y})") except Exception as e: - self.logger.warning( + self.logger.debug( f"Failed to call window.__stagehandAnimateClick: {e}", category=StagehandFunctionName.AGENT, ) diff --git a/stagehand/llm/client.py b/stagehand/llm/client.py index d3c31c9..955335f 100644 --- a/stagehand/llm/client.py +++ b/stagehand/llm/client.py @@ -45,9 +45,6 @@ def __init__( # Warning:Prefer environment variables for specific providers. if api_key: litellm.api_key = api_key - logger.warning( - "Set global litellm.api_key. Prefer provider-specific environment variables." - ) # Apply other global settings if provided for key, value in kwargs.items(): diff --git a/stagehand/logging.py b/stagehand/logging.py index 4220bb2..ac64504 100644 --- a/stagehand/logging.py +++ b/stagehand/logging.py @@ -132,7 +132,7 @@ def __init__( Initialize the logger with specified verbosity and optional external logger. Args: - verbose: Verbosity level (0=error only, 1=info, 2=detailed, 3=debug) + verbose: Verbosity level (0=error only, 1=info, 2=debug) external_logger: Optional callback function for log events use_rich: Whether to use Rich for pretty output (default: True) """ @@ -145,12 +145,11 @@ def __init__( self.level_map = { 0: logging.ERROR, # Critical errors only 1: logging.INFO, # Standard information - 2: logging.WARNING, # More detailed info (using WARNING level) - 3: logging.DEBUG, # Debug information + 2: logging.DEBUG, # Debug information } # Map level to style names - self.level_style = {0: "error", 1: "info", 2: "warning", 3: "debug"} + self.level_style = {0: "error", 1: "info", 2: "debug"} # Update logger level based on verbosity self._set_verbosity(verbose) @@ -351,7 +350,7 @@ def log( Args: message: The message to log - level: Verbosity level (0=error, 1=info, 2=detailed, 3=debug) + level: Verbosity level (0=error, 1=info, 2=debug) category: Optional category for the message auxiliary: Optional dictionary of auxiliary data """ @@ -539,8 +538,6 @@ def log( elif level == 1: logger.info(log_message) elif level == 2: - logger.warning(log_message) - elif level == 3: logger.debug(log_message) # Convenience methods @@ -556,17 +553,11 @@ def info( """Log an info message (level 1)""" self.log(message, level=1, category=category, auxiliary=auxiliary) - def warning( - self, message: str, category: str = None, auxiliary: dict[str, Any] = None - ): - """Log a warning/detailed message (level 2)""" - self.log(message, level=2, category=category, auxiliary=auxiliary) - def debug( self, message: str, category: str = None, auxiliary: dict[str, Any] = None ): - """Log a debug message (level 3)""" - self.log(message, level=3, category=category, auxiliary=auxiliary) + """Log a debug message (level 2)""" + self.log(message, level=2, category=category, auxiliary=auxiliary) # Create a synchronous wrapper for the async default_log_handler @@ -578,7 +569,7 @@ def sync_log_handler(log_data: dict[str, Any]) -> None: # Extract relevant data from the log message level = log_data.get("level", 1) if isinstance(level, str): - level = {"error": 0, "info": 1, "warning": 2, "debug": 3}.get(level.lower(), 1) + level = {"error": 0, "info": 1, "warning": 2, "debug": 2}.get(level.lower(), 1) message = log_data.get("message", "") category = log_data.get("category", "") @@ -601,7 +592,7 @@ def sync_log_handler(log_data: dict[str, Any]) -> None: pass # Create a temporary logger to handle - temp_logger = StagehandLogger(verbose=3, use_rich=True, external_logger=None) + temp_logger = StagehandLogger(verbose=2, use_rich=True, external_logger=None) try: # Use the logger to format and display the message diff --git a/stagehand/page.py b/stagehand/page.py index 6430e0b..46b161b 100644 --- a/stagehand/page.py +++ b/stagehand/page.py @@ -124,7 +124,7 @@ async def act( # Check if it's an ObserveResult for direct execution if isinstance(action_or_result, ObserveResult): if kwargs: - self._stagehand.logger.warning( + self._stagehand.logger.debug( "Additional keyword arguments provided to 'act' when using an ObserveResult are ignored." ) payload = action_or_result.model_dump(exclude_none=True, by_alias=True) @@ -208,7 +208,7 @@ async def observe(self, options: Union[str, ObserveOptions]) -> list[ObserveResu # If single dict, wrap in list (should ideally be list from server) return [ObserveResult(**result)] # Handle unexpected return types - self._stagehand.logger.warning( + self._stagehand.logger.info( f"Unexpected result type from observe: {type(result)}" ) return [] @@ -308,7 +308,7 @@ async def extract( # Return raw dict if parsing fails, or raise? Returning dict for now. return result # type: ignore # Handle unexpected return types - self._stagehand.logger.warning( + self._stagehand.logger.info( f"Unexpected result type from extract: {type(result)}" ) # Return raw result if not dict or raise error @@ -331,7 +331,7 @@ async def screenshot(self, options: Optional[dict] = None) -> str: str: Base64-encoded screenshot data. """ if self._stagehand.env == "LOCAL": - self._stagehand.logger.warning( + self._stagehand.logger.info( "Local execution of screenshot is not implemented" ) return None @@ -364,7 +364,11 @@ async def send_cdp(self, method: str, params: Optional[dict] = None) -> dict: # Type assertion might be needed depending on playwright version/typing result = await client.send(method, params or {}) except Exception as e: - self._stagehand.logger.error(f"CDP command '{method}' failed: {e}") + self._stagehand.logger.debug( + f"CDP command '{method}' failed: {e}. Attempting to reconnect..." + ) + # Try to reconnect + await self._ensure_cdp_session() # Handle specific errors if needed (e.g., session closed) if "Target closed" in str(e) or "Session closed" in str(e): # Attempt to reset the client if the session closed unexpectedly @@ -381,9 +385,7 @@ async def enable_cdp_domain(self, domain: str): try: await self.send_cdp(f"{domain}.enable") except Exception as e: - self._stagehand.logger.warning( - f"Failed to enable CDP domain '{domain}': {e}" - ) + self._stagehand.logger.debug(f"Failed to enable CDP domain '{domain}': {e}") # Method to disable a specific CDP domain async def disable_cdp_domain(self, domain: str): @@ -402,7 +404,7 @@ async def detach_cdp_client(self): await self._cdp_client.detach() self._cdp_client = None except Exception as e: - self._stagehand.logger.warning(f"Error detaching CDP client: {e}") + self._stagehand.logger.debug(f"Error detaching CDP client: {e}") self._cdp_client = None async def _wait_for_settled_dom(self, timeout_ms: int = None): @@ -463,13 +465,13 @@ async def _wait_for_settled_dom(self, timeout_ms: int = None): # If the timeout was hit, log a warning if timeout_task in done: - self._stagehand.logger.warning( + self._stagehand.logger.debug( "DOM settle timeout exceeded, continuing anyway", extra={"timeout_ms": timeout}, ) except Exception as e: - self._stagehand.logger.warning(f"Error waiting for DOM to settle: {e}") + self._stagehand.logger.debug(f"Error waiting for DOM to settle: {e}") except Exception as e: self._stagehand.logger.error(f"Error in _wait_for_settled_dom: {e}") From 76bfb90ba16dbc470c5ae6c3b53d116464674166 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 2 Jun 2025 21:17:26 -0700 Subject: [PATCH 03/11] centralize logging --- stagehand/__init__.py | 26 +++---- stagehand/agent/anthropic_cua.py | 14 ++-- stagehand/client.py | 32 ++++----- stagehand/logging.py | 120 ++++++++++++++++++++++++++++--- stagehand/schemas.py | 1 - 5 files changed, 147 insertions(+), 46 deletions(-) diff --git a/stagehand/__init__.py b/stagehand/__init__.py index 5684dbe..95aec2e 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -1,8 +1,11 @@ +"""Stagehand - Browser automation with AI""" + from .agent import Agent from .client import Stagehand -from .config import StagehandConfig, default_config +from .config import StagehandConfig from .handlers.observe_handler import ObserveHandler -from .logging import configure_logging +from .llm import LLMClient +from .logging import LogConfig, configure_logging, get_logger from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import ( @@ -12,35 +15,34 @@ AgentExecuteOptions, AgentExecuteResult, AgentProvider, - AvailableModel, ExtractOptions, ExtractResult, ObserveOptions, ObserveResult, ) -__version__ = "0.0.1" +__version__ = "0.1.0" __all__ = [ "Stagehand", "StagehandConfig", - "default_config", "StagehandPage", "Agent", - "configure_logging", + "AgentConfig", + "AgentExecuteOptions", + "AgentExecuteResult", + "AgentProvider", "ActOptions", "ActResult", - "AvailableModel", "ExtractOptions", "ExtractResult", "ObserveOptions", "ObserveResult", - "AgentConfig", - "AgentExecuteOptions", - "AgentExecuteResult", - "AgentProvider", "ObserveHandler", - "observe", + "LLMClient", + "configure_logging", "StagehandFunctionName", "StagehandMetrics", + "LogConfig", + "get_logger", ] diff --git a/stagehand/agent/anthropic_cua.py b/stagehand/agent/anthropic_cua.py index c0ae7e9..273b579 100644 --- a/stagehand/agent/anthropic_cua.py +++ b/stagehand/agent/anthropic_cua.py @@ -243,7 +243,7 @@ async def run_task( break if not agent_action and not task_completed: - self.logger.debug( + self.logger.info( "Model did not request an action and task not marked complete. Ending task to prevent loop.", category=StagehandFunctionName.AGENT, ) @@ -290,7 +290,7 @@ def _process_provider_response( block.model_dump() for block in response.content ] except Exception as e: - self.logger.debug( + self.logger.error( f"Could not model_dump response.content blocks: {e}", category=StagehandFunctionName.AGENT, ) @@ -337,7 +337,7 @@ def _convert_tool_use_to_agent_action( and tool_name != "goto" and tool_name != "navigate_back" ): - self.logger.debug( + self.logger.error( f"Unsupported tool name from Anthropic: {tool_name}", category=StagehandFunctionName.AGENT, ) @@ -501,7 +501,7 @@ def _convert_tool_use_to_agent_action( ) action_type_str = "drag" # Normalize else: - self.logger.debug( + self.logger.error( "Drag action missing valid start or end coordinates.", category=StagehandFunctionName.AGENT, ) @@ -559,7 +559,7 @@ def _convert_tool_use_to_agent_action( ) action_type_str = "function" else: - self.logger.debug( + self.logger.error( "Goto action from Anthropic missing URL", category=StagehandFunctionName.AGENT, ) @@ -572,7 +572,7 @@ def _convert_tool_use_to_agent_action( ) action_type_str = "function" else: - self.logger.debug( + self.logger.error( f"Unsupported action type '{action_type_str}' from Anthropic computer tool.", category=StagehandFunctionName.AGENT, ) @@ -613,7 +613,7 @@ def _format_action_feedback( self.format_screenshot(new_screenshot_base64) ) else: - self.logger.debug( + self.logger.error( "Missing screenshot for computer tool feedback (empty string passed).", category=StagehandFunctionName.AGENT, ) diff --git a/stagehand/client.py b/stagehand/client.py index 4a3a5db..e2a9632 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -23,7 +23,7 @@ from .config import StagehandConfig, default_config from .context import StagehandContext from .llm import LLMClient -from .logging import StagehandLogger, default_log_handler +from .logging import LogConfig, StagehandLogger, default_log_handler from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import AgentConfig @@ -159,12 +159,19 @@ def __init__( if self.env not in ["BROWSERBASE", "LOCAL"]: raise ValueError("env must be either 'BROWSERBASE' or 'LOCAL'") - # Initialize the centralized logger with the specified verbosity - self.on_log = self.config.logger or default_log_handler - self.logger = StagehandLogger( - verbose=self.verbose, external_logger=self.on_log, use_rich=use_rich_logging + # Create centralized log configuration + self.log_config = LogConfig( + verbose=self.verbose, + use_rich=use_rich_logging, + env=self.env, + external_logger=self.config.logger or default_log_handler, + quiet_dependencies=True, ) + # Initialize the centralized logger with the LogConfig + self.on_log = self.log_config.external_logger + self.logger = StagehandLogger(config=self.log_config) + # If using BROWSERBASE, session_id or creation params are needed if self.env == "BROWSERBASE": if not self.session_id: @@ -393,17 +400,14 @@ def _get_lock_for_session(self) -> asyncio.Lock: """ if self.session_id not in self._session_locks: self._session_locks[self.session_id] = asyncio.Lock() - self.logger.debug(f"Created lock for session {self.session_id}") return self._session_locks[self.session_id] async def __aenter__(self): - self.logger.debug("Entering Stagehand context manager (__aenter__)...") # Just call init() if not already done await self.init() return self async def __aexit__(self, exc_type, exc_val, exc_tb): - self.logger.debug("Exiting Stagehand context manager (__aexit__)...") await self.close() async def init(self): @@ -463,7 +467,6 @@ async def init(self): self._browser = await self._playwright.chromium.connect_over_cdp( connect_url ) - self.logger.debug(f"Connected to remote browser: {self._browser}") except Exception as e: self.logger.error(f"Failed to connect Playwright via CDP: {str(e)}") await self.close() @@ -769,7 +772,7 @@ async def _create_session(self): payload = { "modelName": self.model_name, - "verbose": self.verbose, + "verbose": self.log_config.get_remote_verbose(), "domSettleTimeoutMs": self.dom_settle_timeout_ms, "browserbaseSessionCreateParams": ( browserbase_session_create_params @@ -850,10 +853,6 @@ async def _execute(self, method: str, payload: dict[str, Any]) -> Any: modified_payload = convert_dict_keys_to_camel_case(payload) client = self.httpx_client or httpx.AsyncClient(timeout=self.timeout_settings) - self.logger.debug(f"\n==== EXECUTING {method.upper()} ====") - self.logger.debug(f"URL: {self.api_url}/sessions/{self.session_id}/{method}") - self.logger.debug(f"Payload: {modified_payload}") - self.logger.debug(f"Headers: {headers}") async with client: try: @@ -874,7 +873,6 @@ async def _execute(self, method: str, payload: dict[str, Any]) -> Any: f"Request failed with status {response.status_code}: {error_message}" ) - self.logger.debug("[STREAM] Processing server response") result = None async for line in response.aiter_lines(): @@ -903,9 +901,7 @@ async def _execute(self, method: str, payload: dict[str, Any]) -> Any: ) elif status == "finished": result = message.get("data", {}).get("result") - self.logger.debug( - "[SYSTEM] Operation completed successfully" - ) + elif msg_type == "log": # Process log message using _handle_log await self._handle_log(message) diff --git a/stagehand/logging.py b/stagehand/logging.py index ac64504..85ab510 100644 --- a/stagehand/logging.py +++ b/stagehand/logging.py @@ -11,6 +11,62 @@ from rich.table import Table from rich.theme import Theme + +class LogConfig: + """ + Centralized configuration for logging across Stagehand. + Manages log levels, formatting, and environment-specific settings. + """ + + def __init__( + self, + verbose: int = 1, + use_rich: bool = True, + env: str = "LOCAL", + external_logger: Optional[Callable] = None, + quiet_dependencies: bool = True, + ): + """ + Initialize logging configuration. + + Args: + verbose: Verbosity level (0=error, 1=info, 2=debug) + use_rich: Whether to use Rich for formatted output + env: Environment ("LOCAL" or "BROWSERBASE") + external_logger: Optional external logging callback + quiet_dependencies: Whether to quiet noisy dependencies + """ + self.verbose = verbose + self.use_rich = use_rich + self.env = env + self.external_logger = external_logger + self.quiet_dependencies = quiet_dependencies + + def get_remote_verbose(self) -> int: + """ + Map local verbose levels to remote levels. + Since we now use the same 3-level system, this is a direct mapping. + """ + return self.verbose + + def get_python_log_level(self) -> int: + """Get the Python logging level based on verbose setting.""" + level_map = { + 0: logging.ERROR, + 1: logging.INFO, + 2: logging.DEBUG, + } + return level_map.get(self.verbose, logging.INFO) + + def should_log(self, level: int) -> bool: + """Check if a message at the given level should be logged.""" + # Always log errors (level 0) + if level == 0: + return True + # Otherwise check against verbose setting + return level <= self.verbose + + # Custom theme for Rich stagehand_theme = Theme( { @@ -27,8 +83,25 @@ } ) -# Create console instance with theme -console = Console(theme=stagehand_theme) + +def get_console(use_rich: bool = True) -> Console: + """ + Get a console instance based on whether Rich formatting is enabled. + + Args: + use_rich: If True, returns a console with theme. If False, returns a plain console. + + Returns: + Console instance configured appropriately + """ + if use_rich: + return Console(theme=stagehand_theme) + else: + return Console(theme=None) + + +# Create default console instance with theme (for backward compatibility) +console = get_console(use_rich=True) # Setup logging with Rich handler logger = logging.getLogger(__name__) @@ -77,6 +150,8 @@ def configure_logging( # Configure root logger with custom format if use_rich: + # Get a console with theme for Rich handler + rich_console = get_console(use_rich=True) # Use Rich handler for root logger logging.basicConfig( level=level, @@ -86,7 +161,7 @@ def configure_logging( RichHandler( rich_tracebacks=True, markup=True, - console=console, + console=rich_console, show_time=False, show_level=False, ) @@ -127,6 +202,7 @@ def __init__( verbose: int = 1, external_logger: Optional[Callable] = None, use_rich: bool = True, + config: Optional[LogConfig] = None, ): """ Initialize the logger with specified verbosity and optional external logger. @@ -135,13 +211,27 @@ def __init__( verbose: Verbosity level (0=error only, 1=info, 2=debug) external_logger: Optional callback function for log events use_rich: Whether to use Rich for pretty output (default: True) + config: Optional LogConfig instance. If provided, overrides other parameters. """ - self.verbose = verbose - self.external_logger = external_logger - self.use_rich = use_rich - self.console = console + if config: + self.verbose = config.verbose + self.external_logger = config.external_logger + self.use_rich = config.use_rich + self.config = config + else: + self.verbose = verbose + self.external_logger = external_logger + self.use_rich = use_rich + self.config = LogConfig( + verbose=verbose, + use_rich=use_rich, + external_logger=external_logger, + ) + + self.console = get_console(self.use_rich) # Map our verbosity levels to Python's logging levels + # Now using only 3 levels to match the remote Fastify server self.level_map = { 0: logging.ERROR, # Critical errors only 1: logging.INFO, # Standard information @@ -152,7 +242,7 @@ def __init__( self.level_style = {0: "error", 1: "info", 2: "debug"} # Update logger level based on verbosity - self._set_verbosity(verbose) + self._set_verbosity(self.verbose) def _set_verbosity(self, level: int): """Set the logger verbosity level""" @@ -560,6 +650,20 @@ def debug( self.log(message, level=2, category=category, auxiliary=auxiliary) +def get_logger(name: str, config: LogConfig) -> StagehandLogger: + """ + Factory function to get a configured logger instance for a module. + + Args: + name: The name of the module requesting the logger + config: LogConfig instance with logging configuration + + Returns: + StagehandLogger: Configured logger instance + """ + return StagehandLogger(config=config) + + # Create a synchronous wrapper for the async default_log_handler def sync_log_handler(log_data: dict[str, Any]) -> None: """ diff --git a/stagehand/schemas.py b/stagehand/schemas.py index 56852df..b47479f 100644 --- a/stagehand/schemas.py +++ b/stagehand/schemas.py @@ -184,7 +184,6 @@ class ObserveOptions(StagehandBaseModel): Attributes: instruction (str): Instruction detailing what the AI should observe. model_name (Optional[str]): The model to use for processing. - only_visible (Optional[bool]): Whether to only consider visible elements. return_action (Optional[bool]): Whether to include action information in the result. draw_overlay (Optional[bool]): Whether to draw an overlay on observed elements. dom_settle_timeout_ms (Optional[int]): Additional time for DOM to settle before observation. From a3e8d0bfe986dd54d730d8f93b401708911978d2 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 2 Jun 2025 21:42:58 -0700 Subject: [PATCH 04/11] update llmclient logging --- stagehand/__init__.py | 7 +++--- stagehand/client.py | 1 + stagehand/llm/client.py | 24 ++++++++++++------- stagehand/logging.py | 53 +++++++++++++++-------------------------- 4 files changed, 38 insertions(+), 47 deletions(-) diff --git a/stagehand/__init__.py b/stagehand/__init__.py index 95aec2e..250c381 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -1,11 +1,11 @@ -"""Stagehand - Browser automation with AI""" +"""Stagehand - The AI Browser Automation Framework""" from .agent import Agent from .client import Stagehand from .config import StagehandConfig from .handlers.observe_handler import ObserveHandler from .llm import LLMClient -from .logging import LogConfig, configure_logging, get_logger +from .logging import LogConfig, configure_logging from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import ( @@ -21,7 +21,7 @@ ObserveResult, ) -__version__ = "0.1.0" +__version__ = "0.0.1" __all__ = [ "Stagehand", @@ -44,5 +44,4 @@ "StagehandFunctionName", "StagehandMetrics", "LogConfig", - "get_logger", ] diff --git a/stagehand/client.py b/stagehand/client.py index e2a9632..f470926 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -222,6 +222,7 @@ def __init__( self.llm = None if self.env == "LOCAL": self.llm = LLMClient( + stagehand_logger=self.logger, api_key=self.model_api_key, default_model=self.model_name, metrics_callback=self._handle_llm_metrics, diff --git a/stagehand/llm/client.py b/stagehand/llm/client.py index 955335f..855b0fe 100644 --- a/stagehand/llm/client.py +++ b/stagehand/llm/client.py @@ -1,14 +1,13 @@ """LLM client for model interactions.""" -import logging -from typing import Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional import litellm from stagehand.metrics import get_inference_time_ms, start_inference_timer -# Configure logger for the module -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from ..logging import StagehandLogger class LLMClient: @@ -19,6 +18,7 @@ class LLMClient: def __init__( self, + stagehand_logger: "StagehandLogger", api_key: Optional[str] = None, default_model: Optional[str] = None, metrics_callback: Optional[Callable[[Any, int, Optional[str]], None]] = None, @@ -28,6 +28,7 @@ def __init__( Initializes the LiteLLMClient. Args: + stagehand_logger: StagehandLogger instance for centralized logging api_key: An API key for the default provider, if required. It's often better to set provider-specific environment variables (e.g., OPENAI_API_KEY, ANTHROPIC_API_KEY) which litellm reads automatically. @@ -39,6 +40,7 @@ def __init__( **kwargs: Additional global settings for litellm (e.g., api_base). See litellm documentation for available settings. """ + self.logger = stagehand_logger self.default_model = default_model self.metrics_callback = metrics_callback @@ -50,11 +52,13 @@ def __init__( for key, value in kwargs.items(): if hasattr(litellm, key): setattr(litellm, key, value) - logger.debug(f"Set global litellm.{key}") + self.logger.debug(f"Set global litellm.{key}", category="llm") # Handle common aliases or expected config names if necessary elif key == "api_base": # Example: map api_base if needed litellm.api_base = value - logger.debug(f"Set global litellm.api_base to {value}") + self.logger.debug( + f"Set global litellm.api_base to {value}", category="llm" + ) def create_response( self, @@ -107,9 +111,11 @@ def create_response( k: v for k, v in params.items() if v is not None or k in kwargs } - logger.debug( - f"Calling litellm.completion with model={completion_model} and params: {filtered_params}" + self.logger.debug( + f"Calling litellm.completion with model={completion_model} and params: {filtered_params}", + category="llm", ) + try: # Start tracking inference time start_time = start_inference_timer() @@ -127,6 +133,6 @@ def create_response( return response except Exception as e: - logger.error(f"Error calling litellm.completion: {e}") + self.logger.error(f"Error calling litellm.completion: {e}", category="llm") # Consider more specific exception handling based on litellm errors raise diff --git a/stagehand/logging.py b/stagehand/logging.py index 85ab510..586dc3b 100644 --- a/stagehand/logging.py +++ b/stagehand/logging.py @@ -49,15 +49,6 @@ def get_remote_verbose(self) -> int: """ return self.verbose - def get_python_log_level(self) -> int: - """Get the Python logging level based on verbose setting.""" - level_map = { - 0: logging.ERROR, - 1: logging.INFO, - 2: logging.DEBUG, - } - return level_map.get(self.verbose, logging.INFO) - def should_log(self, level: int) -> bool: """Check if a message at the given level should be logged.""" # Always log errors (level 0) @@ -214,24 +205,17 @@ def __init__( config: Optional LogConfig instance. If provided, overrides other parameters. """ if config: - self.verbose = config.verbose - self.external_logger = config.external_logger - self.use_rich = config.use_rich self.config = config else: - self.verbose = verbose - self.external_logger = external_logger - self.use_rich = use_rich self.config = LogConfig( verbose=verbose, use_rich=use_rich, external_logger=external_logger, ) - self.console = get_console(self.use_rich) + self.console = get_console(self.config.use_rich) # Map our verbosity levels to Python's logging levels - # Now using only 3 levels to match the remote Fastify server self.level_map = { 0: logging.ERROR, # Critical errors only 1: logging.INFO, # Standard information @@ -242,11 +226,26 @@ def __init__( self.level_style = {0: "error", 1: "info", 2: "debug"} # Update logger level based on verbosity - self._set_verbosity(self.verbose) + self._set_verbosity(self.config.verbose) + + @property + def verbose(self): + """Get verbose level from config""" + return self.config.verbose + + @property + def use_rich(self): + """Get use_rich setting from config""" + return self.config.use_rich + + @property + def external_logger(self): + """Get external logger from config""" + return self.config.external_logger def _set_verbosity(self, level: int): """Set the logger verbosity level""" - self.verbose = level + self.config.verbose = level logger.setLevel(self.level_map.get(level, logging.INFO)) def _format_json(self, data: dict) -> str: @@ -445,7 +444,7 @@ def log( auxiliary: Optional dictionary of auxiliary data """ # Skip logging if below current verbosity level - if level > self.verbose and level != 0: # Always log errors (level 0) + if not self.config.should_log(level): return # Call external logger if provided (handle async function) @@ -650,20 +649,6 @@ def debug( self.log(message, level=2, category=category, auxiliary=auxiliary) -def get_logger(name: str, config: LogConfig) -> StagehandLogger: - """ - Factory function to get a configured logger instance for a module. - - Args: - name: The name of the module requesting the logger - config: LogConfig instance with logging configuration - - Returns: - StagehandLogger: Configured logger instance - """ - return StagehandLogger(config=config) - - # Create a synchronous wrapper for the async default_log_handler def sync_log_handler(log_data: dict[str, Any]) -> None: """ From bdbc9063eb65dc28ec38e659d35b7784bbb87351 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 2 Jun 2025 22:33:37 -0700 Subject: [PATCH 05/11] correct verbose levels from env bb --- stagehand/client.py | 6 +-- stagehand/logging.py | 96 ++++++++++++++++++++++++++------------------ stagehand/schemas.py | 1 - 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/stagehand/client.py b/stagehand/client.py index f470926..4c0f7aa 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -436,11 +436,11 @@ async def init(self): # Create session if we don't have one if not self.session_id: await self._create_session() # Uses self._client and api_url - self.logger.debug( - f"Created new Browserbase session via Stagehand server: {self.session_id}" + self.logger.info( + f"Created new Browserbase session. Session ID: {self.session_id}" ) else: - self.logger.debug( + self.logger.info( f"Using existing Browserbase session: {self.session_id}" ) diff --git a/stagehand/logging.py b/stagehand/logging.py index 586dc3b..f1370b8 100644 --- a/stagehand/logging.py +++ b/stagehand/logging.py @@ -649,58 +649,76 @@ def debug( self.log(message, level=2, category=category, auxiliary=auxiliary) -# Create a synchronous wrapper for the async default_log_handler def sync_log_handler(log_data: dict[str, Any]) -> None: """ - Synchronous wrapper for log handling, doesn't require awaiting. - This avoids the coroutine never awaited warnings. + Enhanced log handler for messages from the Stagehand server. + Uses Rich for pretty printing and JSON formatting. + + The log_data structure from the server is: + { + "message": { // This is the actual LogLine object + "message": "...", + "level": 0|1|2, + "category": "...", + "auxiliary": {...} + }, + "status": "running" + } """ - # Extract relevant data from the log message - level = log_data.get("level", 1) - if isinstance(level, str): - level = {"error": 0, "info": 1, "warning": 2, "debug": 2}.get(level.lower(), 1) - - message = log_data.get("message", "") - category = log_data.get("category", "") - auxiliary = {} - - # Process auxiliary data if present - if "auxiliary" in log_data: - auxiliary = log_data.get("auxiliary", {}) - - # Convert string representation to actual object if needed - if isinstance(auxiliary, str) and ( - auxiliary.startswith("{") or auxiliary.startswith("{'") - ): - try: - import ast + try: + # Extract the actual LogLine object from the nested structure + log_line = log_data.get("message", {}) + + # Handle case where log_data might directly be the LogLine (fallback) + if not isinstance(log_line, dict) or not log_line: + # If message field is not a dict or is empty, treat log_data as the LogLine + log_line = log_data + + # Extract data from the LogLine object + level = log_line.get("level", 1) + message = log_line.get("message", "") + category = log_line.get("category", "") + auxiliary = log_line.get("auxiliary", {}) + + # Handle level conversion if it's a string + if isinstance(level, str): + level = {"error": 0, "info": 1, "warning": 1, "warn": 1, "debug": 2}.get( + level.lower(), 1 + ) - auxiliary = ast.literal_eval(auxiliary) - except (SyntaxError, ValueError): - # If parsing fails, keep as string - pass + # Ensure level is within valid range + level = max(0, min(2, int(level))) if level is not None else 1 - # Create a temporary logger to handle - temp_logger = StagehandLogger(verbose=2, use_rich=True, external_logger=None) + # Handle cases where message might be a complex object + if isinstance(message, dict): + # If message is a dict, convert to string for display + if "message" in message: + # Handle nested message structure + actual_message = message.get("message", "") + if not level and "level" in message: + level = message.get("level", 1) + if not category and "category" in message: + category = message.get("category", "") + message = actual_message + else: + # Convert dict to JSON string + message = json.dumps(message, indent=2) + + # Create a temporary logger to handle the message + temp_logger = StagehandLogger(verbose=2, use_rich=True, external_logger=None) - try: # Use the logger to format and display the message - temp_logger.log(message, level=level, category=category, auxiliary=auxiliary) + temp_logger.log(message, level=level, auxiliary=auxiliary) + except Exception as e: # Fall back to basic logging if formatting fails print(f"Error formatting log: {str(e)}") - print(f"Original message: {message}") - if category: - print(f"Category: {category}") - if auxiliary: - print(f"Auxiliary data: {auxiliary}") + print(f"Original log_data: {log_data}") async def default_log_handler(log_data: dict[str, Any]) -> None: """ - Enhanced default handler for log messages from the Stagehand server. - Uses Rich for pretty printing and JSON formatting. - - This is an async function but calls the synchronous implementation. + Default handler for log messages from the Stagehand server. + This is just a wrapper around sync_log_handler for backward compatibility. """ sync_log_handler(log_data) diff --git a/stagehand/schemas.py b/stagehand/schemas.py index b47479f..472ec05 100644 --- a/stagehand/schemas.py +++ b/stagehand/schemas.py @@ -193,7 +193,6 @@ class ObserveOptions(StagehandBaseModel): ..., description="Instruction detailing what the AI should observe." ) model_name: Optional[str] = None - return_action: Optional[bool] = None draw_overlay: Optional[bool] = None dom_settle_timeout_ms: Optional[int] = None model_client_options: Optional[dict[str, Any]] = None From 141744d346446a5b1f0ab222395190e290045e9e Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 4 Jun 2025 11:48:22 -0700 Subject: [PATCH 06/11] fix imports --- stagehand/browser.py | 2 +- stagehand/client.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/stagehand/browser.py b/stagehand/browser.py index 431d1ce..877c730 100644 --- a/stagehand/browser.py +++ b/stagehand/browser.py @@ -14,7 +14,7 @@ from .context import StagehandContext from .page import StagehandPage -from .utils import StagehandLogger +from .logging import StagehandLogger async def connect_browserbase_browser( diff --git a/stagehand/client.py b/stagehand/client.py index 54a2030..482d4d5 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -30,10 +30,8 @@ from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import AgentConfig -from .utils import ( - convert_dict_keys_to_camel_case, - make_serializable, -) +from .utils import make_serializable + load_dotenv() From 43501a0515a5cb9f80246e5d94785b236d3d9744 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 6 Jun 2025 15:10:18 -0700 Subject: [PATCH 07/11] formatting --- stagehand/browser.py | 2 +- stagehand/client.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/stagehand/browser.py b/stagehand/browser.py index 877c730..88c60b7 100644 --- a/stagehand/browser.py +++ b/stagehand/browser.py @@ -13,8 +13,8 @@ ) from .context import StagehandContext -from .page import StagehandPage from .logging import StagehandLogger +from .page import StagehandPage async def connect_browserbase_browser( diff --git a/stagehand/client.py b/stagehand/client.py index 482d4d5..f1d553b 100644 --- a/stagehand/client.py +++ b/stagehand/client.py @@ -2,7 +2,6 @@ import os import signal import sys -import tempfile import time from pathlib import Path from typing import Any, Literal, Optional @@ -32,7 +31,6 @@ from .schemas import AgentConfig from .utils import make_serializable - load_dotenv() From bcf76412091a876603f44e405035d3a89b69be17 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 6 Jun 2025 15:15:58 -0700 Subject: [PATCH 08/11] formatting --- stagehand/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stagehand/__init__.py b/stagehand/__init__.py index b2c40f1..8f3a5f0 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -3,9 +3,9 @@ from .agent import Agent from .config import StagehandConfig, default_config from .handlers.observe_handler import ObserveHandler -from .main import Stagehand from .llm import LLMClient from .logging import LogConfig, configure_logging +from .main import Stagehand from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import ( From 23ba08c1b2d4554b11e1349c5997d72d6fd153c8 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 6 Jun 2025 15:24:20 -0700 Subject: [PATCH 09/11] missing merge conflict errors --- stagehand/main.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index 386e1c7..96a28ef 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -25,14 +25,11 @@ from .config import StagehandConfig, default_config from .context import StagehandContext from .llm import LLMClient +from .logging import StagehandLogger, default_log_handler from .metrics import StagehandFunctionName, StagehandMetrics from .page import StagehandPage from .schemas import AgentConfig -from .utils import ( - StagehandLogger, - default_log_handler, - make_serializable, -) +from .utils import make_serializable load_dotenv() @@ -183,6 +180,7 @@ def __init__( self.llm = None if self.env == "LOCAL": self.llm = LLMClient( + stagehand_logger=self.logger, api_key=self.model_api_key, default_model=self.model_name, metrics_callback=self._handle_llm_metrics, From d0a99bafd41aa0ec838415ce8af192acaaa65090 Mon Sep 17 00:00:00 2001 From: miguel Date: Thu, 12 Jun 2025 12:11:54 -0700 Subject: [PATCH 10/11] delete empty file --- stagehand/agent/google_cua.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 stagehand/agent/google_cua.py diff --git a/stagehand/agent/google_cua.py b/stagehand/agent/google_cua.py deleted file mode 100644 index e69de29..0000000 From 56cba7269159c7d8c1e874ae8c0d99458036addc Mon Sep 17 00:00:00 2001 From: miguel Date: Thu, 12 Jun 2025 12:15:34 -0700 Subject: [PATCH 11/11] fix CI --- .github/workflows/test.yml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b459efc..1953e19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,9 +37,7 @@ jobs: pip install -e ".[dev]" # Install jsonschema for schema validation tests pip install jsonschema - # Install temporary Google GenAI wheel - pip install temp/google_genai-1.14.0-py3-none-any.whl - + - name: Run unit tests run: | pytest tests/unit/ -v --junit-xml=junit-unit-${{ matrix.python-version }}.xml @@ -74,8 +72,6 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" pip install jsonschema - # Install temporary Google GenAI wheel - pip install temp/google_genai-1.14.0-py3-none-any.whl # Install Playwright browsers for integration tests playwright install chromium playwright install-deps chromium @@ -129,8 +125,6 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" pip install jsonschema - # Install temporary Google GenAI wheel - pip install temp/google_genai-1.14.0-py3-none-any.whl - name: Run API integration tests run: | @@ -170,7 +164,6 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" pip install jsonschema - pip install temp/google_genai-1.14.0-py3-none-any.whl - name: Run smoke tests run: | @@ -213,8 +206,6 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" pip install jsonschema - # Install temporary Google GenAI wheel - pip install temp/google_genai-1.14.0-py3-none-any.whl playwright install chromium playwright install-deps chromium @@ -261,8 +252,6 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" pip install jsonschema - # Install temporary Google GenAI wheel - pip install temp/google_genai-1.14.0-py3-none-any.whl playwright install chromium - name: Run complete test suite