diff --git a/libs/python/computer-server/computer_server/diorama/draw.py b/libs/python/computer-server/computer_server/diorama/draw.py index e915b7908..853d4b68a 100644 --- a/libs/python/computer-server/computer_server/diorama/draw.py +++ b/libs/python/computer-server/computer_server/diorama/draw.py @@ -37,6 +37,14 @@ # Timing decorator for profiling def timing_decorator(func): + """Decorator that logs the execution time of a function. + + Args: + func: The function to be timed + + Returns: + The wrapped function with timing functionality + """ @functools.wraps(func) def wrapper(*args, **kwargs): start_time = time.time() @@ -116,13 +124,23 @@ def wrapper(*args, **kwargs): def CFAttributeToPyObject(attrValue): + """Convert Core Foundation attribute values to Python objects. + + Args: + attrValue: Core Foundation attribute value to convert + + Returns: + Python object representation of the attribute value, or None if conversion fails + """ def list_helper(list_value): + """Convert Core Foundation array to Python list.""" list_builder = [] for item in list_value: list_builder.append(CFAttributeToPyObject(item)) return list_builder def number_helper(number_value): + """Convert Core Foundation number to Python int or float.""" success, int_value = Foundation.CFNumberGetValue( # type: ignore number_value, Foundation.kCFNumberIntType, None # type: ignore ) @@ -137,6 +155,7 @@ def number_helper(number_value): return None def axuielement_helper(element_value): + """Return accessibility element as-is.""" return element_value cf_attr_type = Foundation.CFGetTypeID(attrValue) # type: ignore @@ -169,6 +188,15 @@ def axuielement_helper(element_value): return None def element_attribute(element, attribute): + """Get an attribute value from an accessibility element. + + Args: + element: The accessibility element + attribute: The attribute name to retrieve + + Returns: + The attribute value or None if retrieval fails + """ if attribute == kAXChildrenAttribute: err, value = AXUIElementCopyAttributeValues(element, attribute, 0, 999, None) if err == kAXErrorSuccess: @@ -185,6 +213,15 @@ def element_attribute(element, attribute): return None def element_value(element, type): + """Extract a typed value from an accessibility element. + + Args: + element: The accessibility element containing the value + type: The expected value type + + Returns: + The extracted value or None if extraction fails + """ err, value = AXValueGetValue(element, type, None) if err == True: return value @@ -844,13 +881,23 @@ def get_dock_items() -> List[Dict[str, Any]]: return dock_items class AppActivationContext: + """Context manager for temporarily activating an application and restoring focus afterwards.""" + def __init__(self, active_app_pid=None, active_app_to_use="", logger=None): + """Initialize the context manager. + + Args: + active_app_pid: Process ID of the app to activate + active_app_to_use: Name of the app to activate (for logging) + logger: Logger instance for debug messages + """ self.active_app_pid = active_app_pid self.active_app_to_use = active_app_to_use self.logger = logger self.frontmost_app = None def __enter__(self): + """Activate the specified app and store the current frontmost app.""" from AppKit import NSWorkspace if self.active_app_pid: if self.logger and self.active_app_to_use: @@ -866,6 +913,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + """Restore the previously frontmost app.""" if self.frontmost_app: # sleep for 0.5 seconds time.sleep(0.5) @@ -873,6 +921,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): def get_frontmost_and_active_app(all_windows, running_apps, app_whitelist): + """Determine which app should be activated for screenshot composition. + + Args: + all_windows: List of all windows with z-order information + running_apps: List of all running applications + app_whitelist: Optional list of app names to filter by + + Returns: + Tuple of (frontmost_app, active_app_to_use, active_app_pid) + """ from AppKit import NSWorkspace frontmost_app = NSWorkspace.sharedWorkspace().frontmostApplication() @@ -919,6 +977,8 @@ def capture_all_apps(save_to_disk: bool = False, app_whitelist: List[str] = None save_to_disk: Whether to save screenshots to disk app_whitelist: Optional list of app names to include in the recomposited screenshot (will always include 'Window Server' and 'Dock') + output_dir: Directory to save screenshots to + take_focus: Whether to temporarily activate apps for better screenshot composition Returns: Dictionary with application information and screenshots @@ -1110,6 +1170,16 @@ async def run_capture(): return # Mosaic-pack: grid (rows of sqrt(N)) def make_mosaic(images, pad=64, bg=(30,30,30)): + """Create a mosaic layout of images using rectangle packing. + + Args: + images: List of (group, image) tuples + pad: Padding between images + bg: Background color tuple + + Returns: + PIL Image containing the mosaic + """ import rpack sizes = [(img.width + pad, img.height + pad) for _, img in images] positions = rpack.pack(sizes) diff --git a/libs/python/computer-server/computer_server/main.py b/libs/python/computer-server/computer_server/main.py index ad0b0edeb..fa5c416ad 100644 --- a/libs/python/computer-server/computer_server/main.py +++ b/libs/python/computer-server/computer_server/main.py @@ -1,3 +1,11 @@ +""" +FastAPI server for computer automation and control. + +This module provides a web API for controlling computer operations including +mouse actions, keyboard input, file system operations, and screen capture. +Supports both WebSocket and HTTP endpoints with optional authentication. +""" + from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException, Header from fastapi.responses import StreamingResponse, JSONResponse from typing import List, Dict, Any, Optional, Union, Literal, cast @@ -112,7 +120,15 @@ class AuthenticationManager: + """ + Manages authentication for cloud-based container access. + + Handles session caching and validates API keys against the TryCUA service. + Provides local development mode when no container name is configured. + """ + def __init__(self): + """Initialize authentication manager with empty session cache.""" self.sessions: Dict[str, Dict[str, Any]] = {} self.container_name = os.environ.get("CONTAINER_NAME") @@ -130,7 +146,16 @@ def _is_session_valid(self, session_data: Dict[str, Any]) -> bool: return time.time() < expires_at async def auth(self, container_name: str, api_key: str) -> bool: - """Authenticate container name and API key, using cached sessions when possible""" + """ + Authenticate container name and API key, using cached sessions when possible. + + Args: + container_name: The container identifier to validate + api_key: The API key for authentication + + Returns: + True if authentication succeeds, False otherwise + """ # If no CONTAINER_NAME is set, always allow access (local development) if not self.container_name: logger.info("No CONTAINER_NAME set in environment. Allowing access (local development mode)") @@ -201,14 +226,33 @@ async def auth(self, container_name: str, api_key: str) -> bool: class ConnectionManager: + """ + Manages WebSocket connections for the server. + + Tracks active connections and handles connection lifecycle events. + """ + def __init__(self): + """Initialize connection manager with empty connection list.""" self.active_connections: List[WebSocket] = [] async def connect(self, websocket: WebSocket): + """ + Accept and register a new WebSocket connection. + + Args: + websocket: The WebSocket connection to accept + """ await websocket.accept() self.active_connections.append(websocket) def disconnect(self, websocket: WebSocket): + """ + Remove a WebSocket connection from the active list. + + Args: + websocket: The WebSocket connection to remove + """ self.active_connections.remove(websocket) @@ -217,6 +261,12 @@ def disconnect(self, websocket: WebSocket): @app.get("/status") async def status(): + """ + Get server status including operating system type and available features. + + Returns: + Dict containing status, OS type, and feature list + """ sys = platform.system().lower() # get os type if "darwin" in sys or sys == "macos" or sys == "mac": @@ -233,6 +283,16 @@ async def status(): @app.websocket("/ws", name="websocket_endpoint") async def websocket_endpoint(websocket: WebSocket): + """ + WebSocket endpoint for real-time command execution. + + Handles authentication for cloud deployments and processes commands + through registered handlers. Maintains persistent connection for + multiple command executions. + + Args: + websocket: The WebSocket connection instance + """ global handlers # WebSocket message size is configured at the app or endpoint level, not on the instance @@ -505,14 +565,21 @@ async def agent_response_endpoint( # Simple env override context class _EnvOverride: + """Context manager for temporarily overriding environment variables.""" + def __init__(self, overrides: Dict[str, str]): + """Initialize with environment variable overrides.""" self.overrides = overrides self._original: Dict[str, Optional[str]] = {} + def __enter__(self): + """Apply environment variable overrides.""" for k, v in (self.overrides or {}).items(): self._original[k] = os.environ.get(k) os.environ[k] = str(v) + def __exit__(self, exc_type, exc, tb): + """Restore original environment variables.""" for k, old in self._original.items(): if old is None: os.environ.pop(k, None) @@ -521,6 +588,7 @@ def __exit__(self, exc_type, exc, tb): # Convert input to messages def _to_messages(data: Union[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + """Convert string or message list to standardized message format.""" if isinstance(data, str): return [{"role": "user", "content": data}] if isinstance(data, list): @@ -533,13 +601,22 @@ def _to_messages(data: Union[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]] from agent.computers import AsyncComputerHandler # runtime-checkable Protocol class DirectComputer(AsyncComputerHandler): + """ + Direct computer interface that delegates to existing handlers. + + Implements the AsyncComputerHandler protocol to provide computer + automation capabilities to the agent system. + """ + def __init__(self): + """Initialize with module-scope handler singletons.""" # use module-scope handler singletons created by HandlerFactory self._auto = automation_handler self._file = file_handler self._access = accessibility_handler async def get_environment(self) -> Literal["windows", "mac", "linux", "browser"]: + """Get the current operating system environment.""" sys = platform.system().lower() if "darwin" in sys or sys in ("macos", "mac"): return "mac" @@ -548,14 +625,24 @@ async def get_environment(self) -> Literal["windows", "mac", "linux", "browser"] return "linux" async def get_dimensions(self) -> tuple[int, int]: + """Get screen dimensions as width and height tuple.""" size = await self._auto.get_screen_size() return size["width"], size["height"] async def screenshot(self) -> str: + """Take a screenshot and return as base64 encoded string.""" img_b64 = await self._auto.screenshot() return img_b64["image_data"] async def click(self, x: int, y: int, button: str = "left") -> None: + """ + Click at the specified coordinates with the given button. + + Args: + x: X coordinate for click + y: Y coordinate for click + button: Mouse button to use ("left" or "right") + """ if button == "left": await self._auto.left_click(x, y) elif button == "right": @@ -564,22 +651,63 @@ async def click(self, x: int, y: int, button: str = "left") -> None: await self._auto.left_click(x, y) async def double_click(self, x: int, y: int) -> None: + """ + Double-click at the specified coordinates. + + Args: + x: X coordinate for double-click + y: Y coordinate for double-click + """ await self._auto.double_click(x, y) async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + """ + Scroll at the specified position with given scroll amounts. + + Args: + x: X coordinate for scroll position + y: Y coordinate for scroll position + scroll_x: Horizontal scroll amount + scroll_y: Vertical scroll amount + """ await self._auto.move_cursor(x, y) await self._auto.scroll(scroll_x, scroll_y) async def type(self, text: str) -> None: + """ + Type the specified text. + + Args: + text: Text to type + """ await self._auto.type_text(text) async def wait(self, ms: int = 1000) -> None: + """ + Wait for the specified number of milliseconds. + + Args: + ms: Milliseconds to wait + """ await asyncio.sleep(ms / 1000.0) async def move(self, x: int, y: int) -> None: + """ + Move cursor to the specified coordinates. + + Args: + x: X coordinate to move to + y: Y coordinate to move to + """ await self._auto.move_cursor(x, y) async def keypress(self, keys: Union[List[str], str]) -> None: + """ + Press key or key combination. + + Args: + keys: Single key or list of keys for combination + """ if isinstance(keys, str): parts = keys.replace("-", "+").split("+") if len(keys) > 1 else [keys] else: @@ -590,6 +718,12 @@ async def keypress(self, keys: Union[List[str], str]) -> None: await self._auto.hotkey(parts) async def drag(self, path: List[Dict[str, int]]) -> None: + """ + Perform drag operation along the specified path. + + Args: + path: List of coordinate dictionaries with 'x' and 'y' keys + """ if not path: return start = path[0] @@ -600,13 +734,28 @@ async def drag(self, path: List[Dict[str, int]]) -> None: await self._auto.mouse_up(end["x"], end["y"]) async def get_current_url(self) -> str: + """Get current URL (not available in this server context).""" # Not available in this server context return "" async def left_mouse_down(self, x: Optional[int] = None, y: Optional[int] = None) -> None: + """ + Press left mouse button down at coordinates. + + Args: + x: X coordinate (optional) + y: Y coordinate (optional) + """ await self._auto.mouse_down(x, y, button="left") async def left_mouse_up(self, x: Optional[int] = None, y: Optional[int] = None) -> None: + """ + Release left mouse button at coordinates. + + Args: + x: X coordinate (optional) + y: Y coordinate (optional) + """ await self._auto.mouse_up(x, y, button="left") # # Inline image URLs to base64 @@ -703,4 +852,5 @@ async def left_mouse_up(self, x: Optional[int] = None, y: Optional[int] = None) if __name__ == "__main__": + """Run the FastAPI server when executed directly.""" uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/libs/python/computer/computer/helpers.py b/libs/python/computer/computer/helpers.py index 8317b8d92..e48c45ed7 100644 --- a/libs/python/computer/computer/helpers.py +++ b/libs/python/computer/computer/helpers.py @@ -8,6 +8,7 @@ # Global reference to the default computer instance _default_computer = None +"""Global variable storing the default computer instance for use with decorators.""" logger = logging.getLogger(__name__) @@ -24,12 +25,23 @@ def set_default_computer(computer): def sandboxed(venv_name: str = "default", computer: str = "default", max_retries: int = 3): """ - Decorator that wraps a function to be executed remotely via computer.venv_exec + Decorator that wraps a function to be executed remotely via computer.venv_exec. + + The decorated function will be executed in a virtual environment on the specified + computer instance. If execution fails, it will retry up to max_retries times with + a 1-second delay between attempts. Args: venv_name: Name of the virtual environment to execute in computer: The computer instance to use, or "default" to use the globally set default max_retries: Maximum number of retries for the remote execution + + Returns: + Callable: An async wrapper function that executes the original function remotely + + Raises: + RuntimeError: If no computer instance is available + Exception: Re-raises the last exception if all retry attempts fail """ def decorator(func): @wraps(func) diff --git a/libs/python/computer/computer/interface/generic.py b/libs/python/computer/computer/interface/generic.py index a802a686e..385a84dfb 100644 --- a/libs/python/computer/computer/interface/generic.py +++ b/libs/python/computer/computer/interface/generic.py @@ -17,6 +17,16 @@ class GenericComputerInterface(BaseComputerInterface): """Generic interface with common functionality for all supported platforms (Windows, Linux, macOS).""" def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None, logger_name: str = "computer.interface.generic"): + """Initialize the generic computer interface. + + Args: + ip_address: IP address of the computer to connect to + username: Username for authentication (default: "lume") + password: Password for authentication (default: "lume") + api_key: Optional API key for secure connections + vm_name: Optional VM name for container-based connections + logger_name: Name for the logger instance + """ super().__init__(ip_address, username, password, api_key, vm_name) self._ws = None self._reconnect_task = None @@ -72,36 +82,97 @@ def rest_uri(self) -> str: # Mouse actions async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left", delay: Optional[float] = None) -> None: + """Press and hold a mouse button at the specified coordinates. + + Args: + x: X coordinate for the mouse action (None for current position) + y: Y coordinate for the mouse action (None for current position) + button: Mouse button to press ("left", "right", or "middle") + delay: Optional delay after the action + """ await self._send_command("mouse_down", {"x": x, "y": y, "button": button}) await self._handle_delay(delay) async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left", delay: Optional[float] = None) -> None: + """Release a mouse button at the specified coordinates. + + Args: + x: X coordinate for the mouse action (None for current position) + y: Y coordinate for the mouse action (None for current position) + button: Mouse button to release ("left", "right", or "middle") + delay: Optional delay after the action + """ await self._send_command("mouse_up", {"x": x, "y": y, "button": button}) await self._handle_delay(delay) async def left_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None: + """Perform a left mouse click at the specified coordinates. + + Args: + x: X coordinate for the click (None for current position) + y: Y coordinate for the click (None for current position) + delay: Optional delay after the action + """ await self._send_command("left_click", {"x": x, "y": y}) await self._handle_delay(delay) async def right_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None: + """Perform a right mouse click at the specified coordinates. + + Args: + x: X coordinate for the click (None for current position) + y: Y coordinate for the click (None for current position) + delay: Optional delay after the action + """ await self._send_command("right_click", {"x": x, "y": y}) await self._handle_delay(delay) async def double_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None: + """Perform a double left mouse click at the specified coordinates. + + Args: + x: X coordinate for the double click (None for current position) + y: Y coordinate for the double click (None for current position) + delay: Optional delay after the action + """ await self._send_command("double_click", {"x": x, "y": y}) await self._handle_delay(delay) async def move_cursor(self, x: int, y: int, delay: Optional[float] = None) -> None: + """Move the mouse cursor to the specified coordinates. + + Args: + x: X coordinate to move to + y: Y coordinate to move to + delay: Optional delay after the action + """ await self._send_command("move_cursor", {"x": x, "y": y}) await self._handle_delay(delay) async def drag_to(self, x: int, y: int, button: "MouseButton" = "left", duration: float = 0.5, delay: Optional[float] = None) -> None: + """Drag from the current cursor position to the specified coordinates. + + Args: + x: X coordinate to drag to + y: Y coordinate to drag to + button: Mouse button to use for dragging + duration: Duration of the drag operation in seconds + delay: Optional delay after the action + """ await self._send_command( "drag_to", {"x": x, "y": y, "button": button, "duration": duration} ) await self._handle_delay(delay) async def drag(self, path: List[Tuple[int, int]], button: "MouseButton" = "left", duration: float = 0.5, delay: Optional[float] = None) -> None: + """Drag along a path of coordinates. + + Args: + path: List of (x, y) coordinate tuples defining the drag path + button: Mouse button to use for dragging + duration: Total duration of the drag operation in seconds + delay: Optional delay after the action + """ await self._send_command( "drag", {"path": path, "button": button, "duration": duration} ) @@ -109,14 +180,35 @@ async def drag(self, path: List[Tuple[int, int]], button: "MouseButton" = "left" # Keyboard Actions async def key_down(self, key: "KeyType", delay: Optional[float] = None) -> None: + """Press and hold a key. + + Args: + key: Key to press down + delay: Optional delay after the action + """ await self._send_command("key_down", {"key": key}) await self._handle_delay(delay) async def key_up(self, key: "KeyType", delay: Optional[float] = None) -> None: + """Release a key. + + Args: + key: Key to release + delay: Optional delay after the action + """ await self._send_command("key_up", {"key": key}) await self._handle_delay(delay) async def type_text(self, text: str, delay: Optional[float] = None) -> None: + """Type text using the keyboard. + + Args: + text: Text to type + delay: Optional delay after the action + + Note: + For Unicode text, this method uses clipboard and paste for better compatibility. + """ # Temporary fix for https://github.com/trycua/cua/issues/165 # Check if text contains Unicode characters if any(ord(char) > 127 for char in text): @@ -212,14 +304,33 @@ async def hotkey(self, *keys: "KeyType", delay: Optional[float] = None) -> None: # Scrolling Actions async def scroll(self, x: int, y: int, delay: Optional[float] = None) -> None: + """Scroll by the specified amount in both directions. + + Args: + x: Horizontal scroll amount (positive for right, negative for left) + y: Vertical scroll amount (positive for down, negative for up) + delay: Optional delay after the action + """ await self._send_command("scroll", {"x": x, "y": y}) await self._handle_delay(delay) async def scroll_down(self, clicks: int = 1, delay: Optional[float] = None) -> None: + """Scroll down by the specified number of clicks. + + Args: + clicks: Number of scroll clicks to perform + delay: Optional delay after the action + """ await self._send_command("scroll_down", {"clicks": clicks}) await self._handle_delay(delay) async def scroll_up(self, clicks: int = 1, delay: Optional[float] = None) -> None: + """Scroll up by the specified number of clicks. + + Args: + clicks: Number of scroll clicks to perform + delay: Optional delay after the action + """ await self._send_command("scroll_up", {"clicks": clicks}) await self._handle_delay(delay) @@ -280,12 +391,28 @@ async def screenshot( return screenshot async def get_screen_size(self) -> Dict[str, int]: + """Get the current screen size. + + Returns: + Dictionary containing 'width' and 'height' keys with screen dimensions + + Raises: + RuntimeError: If unable to get screen size + """ result = await self._send_command("get_screen_size") if result["success"] and result["size"]: return result["size"] raise RuntimeError("Failed to get screen size") async def get_cursor_position(self) -> Dict[str, int]: + """Get the current cursor position. + + Returns: + Dictionary containing 'x' and 'y' keys with cursor coordinates + + Raises: + RuntimeError: If unable to get cursor position + """ result = await self._send_command("get_cursor_position") if result["success"] and result["position"]: return result["position"] @@ -293,17 +420,40 @@ async def get_cursor_position(self) -> Dict[str, int]: # Clipboard Actions async def copy_to_clipboard(self) -> str: + """Copy current selection to clipboard and return its content. + + Returns: + The text content of the clipboard + + Raises: + RuntimeError: If unable to get clipboard content + """ result = await self._send_command("copy_to_clipboard") if result["success"] and result["content"]: return result["content"] raise RuntimeError("Failed to get clipboard content") async def set_clipboard(self, text: str) -> None: + """Set the clipboard content to the specified text. + + Args: + text: Text to set in the clipboard + """ await self._send_command("set_clipboard", {"text": text}) # File Operations async def _write_bytes_chunked(self, path: str, content: bytes, append: bool = False, chunk_size: int = 1024 * 1024) -> None: - """Write large files in chunks to avoid memory issues.""" + """Write large files in chunks to avoid memory issues. + + Args: + path: File path to write to + content: Bytes content to write + append: Whether to append to existing file + chunk_size: Size of each chunk in bytes + + Raises: + RuntimeError: If any chunk write operation fails + """ total_size = len(content) current_offset = 0 @@ -326,6 +476,16 @@ async def _write_bytes_chunked(self, path: str, content: bytes, append: bool = F current_offset = chunk_end async def write_bytes(self, path: str, content: bytes, append: bool = False) -> None: + """Write bytes to a file. + + Args: + path: File path to write to + content: Bytes content to write + append: Whether to append to existing file instead of overwriting + + Raises: + RuntimeError: If the write operation fails + """ # For large files, use chunked writing if len(content) > 5 * 1024 * 1024: # 5MB threshold await self._write_bytes_chunked(path, content, append) @@ -336,7 +496,20 @@ async def write_bytes(self, path: str, content: bytes, append: bool = False) -> raise RuntimeError(result.get("error", "Failed to write file")) async def _read_bytes_chunked(self, path: str, offset: int, total_length: int, chunk_size: int = 1024 * 1024) -> bytes: - """Read large files in chunks to avoid memory issues.""" + """Read large files in chunks to avoid memory issues. + + Args: + path: File path to read from + offset: Starting offset in the file + total_length: Total number of bytes to read + chunk_size: Size of each chunk in bytes + + Returns: + The complete file content as bytes + + Raises: + RuntimeError: If any chunk read operation fails + """ chunks = [] current_offset = offset remaining = total_length @@ -362,6 +535,19 @@ async def _read_bytes_chunked(self, path: str, offset: int, total_length: int, c return b''.join(chunks) async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes: + """Read bytes from a file. + + Args: + path: File path to read from + offset: Starting offset in the file + length: Number of bytes to read (None for entire file) + + Returns: + The file content as bytes + + Raises: + RuntimeError: If the read operation fails + """ # For large files, use chunked reading if length is None: # Get file size first to determine if we need chunking @@ -406,35 +592,97 @@ async def write_text(self, path: str, content: str, encoding: str = 'utf-8', app await self.write_bytes(path, content_bytes, append) async def get_file_size(self, path: str) -> int: + """Get the size of a file in bytes. + + Args: + path: Path to the file + + Returns: + File size in bytes + + Raises: + RuntimeError: If unable to get file size + """ result = await self._send_command("get_file_size", {"path": path}) if not result.get("success", False): raise RuntimeError(result.get("error", "Failed to get file size")) return result.get("size", 0) async def file_exists(self, path: str) -> bool: + """Check if a file exists. + + Args: + path: Path to check + + Returns: + True if the file exists, False otherwise + """ result = await self._send_command("file_exists", {"path": path}) return result.get("exists", False) async def directory_exists(self, path: str) -> bool: + """Check if a directory exists. + + Args: + path: Path to check + + Returns: + True if the directory exists, False otherwise + """ result = await self._send_command("directory_exists", {"path": path}) return result.get("exists", False) async def create_dir(self, path: str) -> None: + """Create a directory. + + Args: + path: Path of the directory to create + + Raises: + RuntimeError: If directory creation fails + """ result = await self._send_command("create_dir", {"path": path}) if not result.get("success", False): raise RuntimeError(result.get("error", "Failed to create directory")) async def delete_file(self, path: str) -> None: + """Delete a file. + + Args: + path: Path of the file to delete + + Raises: + RuntimeError: If file deletion fails + """ result = await self._send_command("delete_file", {"path": path}) if not result.get("success", False): raise RuntimeError(result.get("error", "Failed to delete file")) async def delete_dir(self, path: str) -> None: + """Delete a directory. + + Args: + path: Path of the directory to delete + + Raises: + RuntimeError: If directory deletion fails + """ result = await self._send_command("delete_dir", {"path": path}) if not result.get("success", False): raise RuntimeError(result.get("error", "Failed to delete directory")) async def list_dir(self, path: str) -> list[str]: + """List contents of a directory. + + Args: + path: Path of the directory to list + + Returns: + List of file and directory names in the specified directory + + Raises: + RuntimeError: If directory listing fails + """ result = await self._send_command("list_dir", {"path": path}) if not result.get("success", False): raise RuntimeError(result.get("error", "Failed to list directory")) @@ -442,6 +690,17 @@ async def list_dir(self, path: str) -> list[str]: # Command execution async def run_command(self, command: str) -> CommandResult: + """Execute a system command. + + Args: + command: Command string to execute + + Returns: + CommandResult containing stdout, stderr, and return code + + Raises: + RuntimeError: If command execution fails + """ result = await self._send_command("run_command", {"command": command}) if not result.get("success", False): raise RuntimeError(result.get("error", "Failed to run command")) @@ -691,7 +950,19 @@ async def _ensure_connection(self): raise ConnectionError("Failed to establish WebSocket connection after multiple retries") async def _send_command_ws(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: - """Send command through WebSocket.""" + """Send command through WebSocket. + + Args: + command: Command name to send + params: Optional parameters for the command + + Returns: + Response dictionary from the server + + Raises: + ConnectionError: If WebSocket connection fails + RuntimeError: If command sending fails after retries + """ max_retries = 3 retry_count = 0 last_error = None @@ -731,7 +1002,15 @@ async def _send_command_ws(self, command: str, params: Optional[Dict] = None) -> raise last_error if last_error else RuntimeError("Failed to send command") async def _send_command_rest(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: - """Send command through REST API without retries or connection management.""" + """Send command through REST API without retries or connection management. + + Args: + command: Command name to send + params: Optional parameters for the command + + Returns: + Response dictionary from the server + """ try: # Prepare the request payload payload = {"command": command, "params": params or {}} @@ -784,7 +1063,15 @@ async def _send_command_rest(self, command: str, params: Optional[Dict] = None) } async def _send_command(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: - """Send command using REST API with WebSocket fallback.""" + """Send command using REST API with WebSocket fallback. + + Args: + command: Command name to send + params: Optional parameters for the command + + Returns: + Response dictionary from the server + """ # Try REST API first result = await self._send_command_rest(command, params) @@ -801,7 +1088,16 @@ async def _send_command(self, command: str, params: Optional[Dict] = None) -> Di return result async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0): - """Wait for Computer API Server to be ready by testing version command.""" + """Wait for Computer API Server to be ready by testing version command. + + Args: + timeout: Maximum time to wait in seconds + interval: Time between connection attempts in seconds + + Raises: + TimeoutError: If server doesn't become ready within timeout + RuntimeError: If there's an error while waiting + """ # Check if REST API is available try: @@ -875,7 +1171,15 @@ async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0): raise RuntimeError(error_msg) async def _wait_for_ready_ws(self, timeout: int = 60, interval: float = 1.0): - """Wait for WebSocket connection to become available.""" + """Wait for WebSocket connection to become available. + + Args: + timeout: Maximum time to wait in seconds + interval: Time between connection attempts in seconds + + Raises: + TimeoutError: If connection doesn't become ready within timeout + """ start_time = time.time() last_error = None attempt_count = 0 @@ -970,4 +1274,3 @@ def force_close(self): if self._ws: asyncio.create_task(self._ws.close()) self._ws = None - diff --git a/libs/typescript/agent/src/client.ts b/libs/typescript/agent/src/client.ts index d25e698b9..35c5eadeb 100644 --- a/libs/typescript/agent/src/client.ts +++ b/libs/typescript/agent/src/client.ts @@ -6,6 +6,10 @@ import type { AgentClientOptions, } from "./types"; +/** + * Client for communicating with agents through various connection types. + * Supports HTTP/HTTPS and peer-to-peer connections. + */ export class AgentClient { private url: string; private connectionType: ConnectionType; @@ -13,6 +17,12 @@ export class AgentClient { private peer?: Peer; private connection?: any; + /** + * Creates a new AgentClient instance. + * @param url - The URL to connect to (http://, https://, or peer://) + * @param options - Configuration options for the client + * @throws Error when URL format is invalid + */ constructor(url: string, options: AgentClientOptions = {}) { this.url = url; this.options = { @@ -33,13 +43,26 @@ export class AgentClient { } } - // Main responses API matching the desired usage pattern + /** + * API for creating agent responses. + */ public responses = { + /** + * Creates a new agent response by sending a request. + * @param request - The agent request to send + * @returns Promise resolving to the agent response + */ create: async (request: AgentRequest): Promise => { return this.sendRequest(request); }, }; + /** + * Routes the request to the appropriate sender based on connection type. + * @param request - The agent request to send + * @returns Promise resolving to the agent response + * @throws Error when connection type is unsupported + */ private async sendRequest(request: AgentRequest): Promise { switch (this.connectionType) { case "http": @@ -52,6 +75,12 @@ export class AgentClient { } } + /** + * Sends a request via HTTP/HTTPS. + * @param request - The agent request to send + * @returns Promise resolving to the agent response + * @throws Error when HTTP request fails or times out + */ private async sendHttpRequest(request: AgentRequest): Promise { const controller = new AbortController(); const timeoutId = setTimeout( @@ -91,6 +120,12 @@ export class AgentClient { } } + /** + * Sends a request via peer-to-peer connection. + * @param request - The agent request to send + * @returns Promise resolving to the agent response + * @throws Error when peer connection fails or times out + */ private async sendPeerRequest(request: AgentRequest): Promise { // Extract peer ID from peer:// URL const peerId = this.url.replace("peer://", ""); @@ -166,7 +201,10 @@ export class AgentClient { } } - // Health check method + /** + * Checks the health status of the connection. + * @returns Promise resolving to health status object + */ async health(): Promise<{ status: string }> { if (this.connectionType === "peer") { return { status: this.peer?.open ? "connected" : "disconnected" }; @@ -183,7 +221,10 @@ export class AgentClient { } } - // Clean up resources + /** + * Closes all connections and cleans up resources. + * @returns Promise that resolves when cleanup is complete + */ async disconnect(): Promise { if (this.connection) { this.connection.close();