-
Notifications
You must be signed in to change notification settings - Fork 0
Local control: in-SDK sidecar for user_device environments #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,11 +7,17 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import functools | ||
| import typing | ||
|
|
||
| import typing_extensions | ||
|
|
||
| from .agents.client import AgentsClient, AsyncAgentsClient | ||
| from .base_client import AsyncBaseClient, BaseClient | ||
| from .local.config import auto_sidecars_enabled | ||
| from .local.runtime import ensure_sidecars | ||
| from .local.wiring import collect_sidecar_configs, localize_agent, localize_environments, localize_subagents | ||
| from .polling import ( | ||
| AnswerT, | ||
| AsyncSessionHandle, | ||
|
|
@@ -24,9 +30,91 @@ | |
| ) | ||
| from .polling import async_run_session as _async_run_session | ||
| from .polling import run_session as _run_session | ||
| from .sessions.client import AsyncSessionsClient, SessionsClient | ||
| from .tools import ToolInput, as_tools | ||
|
|
||
|
|
||
| def _wire_agent_fields(kwargs: typing.Dict[str, typing.Any], get_api_key: typing.Callable[[], str]) -> None: | ||
| if kwargs.get("environments"): | ||
| kwargs["environments"] = localize_environments(kwargs["environments"], get_api_key) | ||
| if kwargs.get("subagents"): | ||
| kwargs["subagents"] = localize_subagents(kwargs["subagents"], get_api_key) | ||
|
|
||
|
|
||
| def _ensure_local_sidecars(agent: typing.Any, client_wrapper: typing.Any) -> None: | ||
| ensure_sidecars(collect_sidecar_configs(agent, client_wrapper._get_api_key(), client_wrapper.get_base_url())) | ||
|
|
||
|
|
||
| class _LocalAgentsClient(AgentsClient): | ||
| @functools.wraps(AgentsClient.create_agent) | ||
| def create_agent(self, **kwargs: typing.Any) -> typing.Any: | ||
| _wire_agent_fields(kwargs, self._raw_client._client_wrapper._get_api_key) | ||
| return super().create_agent(**kwargs) | ||
|
|
||
| @functools.wraps(AgentsClient.update_agent) | ||
| def update_agent(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: | ||
| _wire_agent_fields(kwargs, self._raw_client._client_wrapper._get_api_key) | ||
| return super().update_agent(*args, **kwargs) | ||
|
|
||
| @functools.wraps(AgentsClient.patch_agent) | ||
| def patch_agent(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: | ||
| _wire_agent_fields(kwargs, self._raw_client._client_wrapper._get_api_key) | ||
| return super().patch_agent(*args, **kwargs) | ||
|
|
||
|
|
||
| class _LocalSessionsClient(SessionsClient): | ||
| @functools.wraps(SessionsClient.create_session) | ||
| def create_session(self, **kwargs: typing.Any) -> typing.Any: | ||
| if "agent" in kwargs: | ||
| wrapper = self._raw_client._client_wrapper | ||
| kwargs["agent"] = localize_agent(kwargs["agent"], wrapper._get_api_key) | ||
| if auto_sidecars_enabled(): | ||
| agent = kwargs["agent"] | ||
| if isinstance(agent, str): | ||
| try: | ||
| agent = AgentsClient(client_wrapper=wrapper).get_agent(agent) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Named agent fetch skips resolveMedium Severity When Additional Locations (1)Reviewed by Cursor Bugbot for commit b2762ab. Configure here. |
||
| except Exception: | ||
| agent = None | ||
| if agent is not None: | ||
| _ensure_local_sidecars(agent, wrapper) | ||
| return super().create_session(**kwargs) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class _LocalAsyncAgentsClient(AsyncAgentsClient): | ||
| @functools.wraps(AsyncAgentsClient.create_agent) | ||
| async def create_agent(self, **kwargs: typing.Any) -> typing.Any: | ||
| _wire_agent_fields(kwargs, self._raw_client._client_wrapper._get_api_key) | ||
| return await super().create_agent(**kwargs) | ||
|
|
||
| @functools.wraps(AsyncAgentsClient.update_agent) | ||
| async def update_agent(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: | ||
| _wire_agent_fields(kwargs, self._raw_client._client_wrapper._get_api_key) | ||
| return await super().update_agent(*args, **kwargs) | ||
|
|
||
| @functools.wraps(AsyncAgentsClient.patch_agent) | ||
| async def patch_agent(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: | ||
| _wire_agent_fields(kwargs, self._raw_client._client_wrapper._get_api_key) | ||
| return await super().patch_agent(*args, **kwargs) | ||
|
|
||
|
|
||
| class _LocalAsyncSessionsClient(AsyncSessionsClient): | ||
| @functools.wraps(AsyncSessionsClient.create_session) | ||
| async def create_session(self, **kwargs: typing.Any) -> typing.Any: | ||
| if "agent" in kwargs: | ||
| wrapper = self._raw_client._client_wrapper | ||
| kwargs["agent"] = localize_agent(kwargs["agent"], wrapper._get_api_key) | ||
| if auto_sidecars_enabled(): | ||
| agent = kwargs["agent"] | ||
| if isinstance(agent, str): | ||
| try: | ||
| agent = await AsyncAgentsClient(client_wrapper=wrapper).get_agent(agent) | ||
| except Exception: | ||
| agent = None | ||
| if agent is not None: | ||
| await asyncio.to_thread(_ensure_local_sidecars, agent, wrapper) | ||
| return await super().create_session(**kwargs) | ||
|
|
||
|
|
||
| class Client(BaseClient): | ||
| def run_session( | ||
| self, | ||
|
|
@@ -75,6 +163,18 @@ def session(self, id: str) -> SessionHandle: | |
| """Wrap an existing session id in a handle.""" | ||
| return SessionHandle(self, id) | ||
|
|
||
| @property | ||
| def agents(self) -> _LocalAgentsClient: | ||
| if self._agents is None: | ||
| self._agents = _LocalAgentsClient(client_wrapper=self._client_wrapper) | ||
| return self._agents | ||
|
|
||
| @property | ||
| def sessions(self) -> _LocalSessionsClient: | ||
| if self._sessions is None: | ||
| self._sessions = _LocalSessionsClient(client_wrapper=self._client_wrapper) | ||
| return self._sessions | ||
|
|
||
|
|
||
| class AsyncClient(AsyncBaseClient): | ||
| async def run_session( | ||
|
|
@@ -123,3 +223,15 @@ async def start_session( | |
| def session(self, id: str) -> AsyncSessionHandle: | ||
| """Wrap an existing session id in a handle.""" | ||
| return AsyncSessionHandle(self, id) | ||
|
|
||
| @property | ||
| def agents(self) -> _LocalAsyncAgentsClient: | ||
| if self._agents is None: | ||
| self._agents = _LocalAsyncAgentsClient(client_wrapper=self._client_wrapper) | ||
| return self._agents | ||
|
|
||
| @property | ||
| def sessions(self) -> _LocalAsyncSessionsClient: | ||
| if self._sessions is None: | ||
| self._sessions = _LocalAsyncSessionsClient(client_wrapper=self._client_wrapper) | ||
| return self._sessions | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| """Local control: serve agent actions on this machine's browser or desktop via hai-drivers.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from .chrome import ensure_local_chrome | ||
| from .config import SidecarConfig, session_id_from_environment_id | ||
| from .runtime import ensure_sidecars, stop_sidecars | ||
| from .sidecar import SidecarBusyError, SidecarClient | ||
|
|
||
| __all__ = [ | ||
| "SidecarBusyError", | ||
| "SidecarClient", | ||
| "SidecarConfig", | ||
| "ensure_local_chrome", | ||
| "ensure_sidecars", | ||
| "session_id_from_environment_id", | ||
| "stop_sidecars", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import platform | ||
| import shutil | ||
| import subprocess | ||
| import threading | ||
| import time | ||
| from pathlib import Path | ||
|
|
||
| import httpx | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DEFAULT_DEBUG_PORT = 9222 | ||
| CHROME_STARTUP_TIMEOUT_S = 20.0 | ||
| CHROME_PROFILE_DIR = Path.home() / ".hai" / "chrome-profile" | ||
|
|
||
| _launch_lock = threading.Lock() | ||
|
|
||
| _CHROME_CANDIDATES = { | ||
| "Darwin": ( | ||
| "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", | ||
| "/Applications/Chromium.app/Contents/MacOS/Chromium", | ||
| ), | ||
| "Windows": ( | ||
| r"C:\Program Files\Google\Chrome\Application\chrome.exe", | ||
| r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", | ||
| ), | ||
| } | ||
| _CHROME_COMMANDS = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") | ||
|
|
||
|
|
||
| def ensure_local_chrome(port: int = DEFAULT_DEBUG_PORT) -> None: | ||
| with _launch_lock: | ||
| if _debugger_listening(port): | ||
| return | ||
| binary = next((p for p in _CHROME_CANDIDATES.get(platform.system(), ()) if Path(p).exists()), None) or next( | ||
| (found for command in _CHROME_COMMANDS if (found := shutil.which(command))), None | ||
| ) | ||
| if binary is None: | ||
| raise RuntimeError( | ||
| "Google Chrome was not found. Install Chrome, or start a browser yourself with " | ||
| f"--remote-debugging-port={port}." | ||
| ) | ||
| CHROME_PROFILE_DIR.mkdir(parents=True, exist_ok=True) | ||
| logger.info("launching Chrome with remote debugging on port %d (profile: %s)", port, CHROME_PROFILE_DIR) | ||
| process = subprocess.Popen( | ||
| [ | ||
| binary, | ||
| f"--remote-debugging-port={port}", | ||
| f"--user-data-dir={CHROME_PROFILE_DIR}", | ||
| "--no-first-run", | ||
| "--no-default-browser-check", | ||
| ], | ||
| stdin=subprocess.DEVNULL, | ||
| stdout=subprocess.DEVNULL, | ||
| stderr=subprocess.DEVNULL, | ||
| start_new_session=True, | ||
| ) | ||
| deadline = time.monotonic() + CHROME_STARTUP_TIMEOUT_S | ||
| while time.monotonic() < deadline: | ||
| if _debugger_listening(port): | ||
| return | ||
|
abonneth marked this conversation as resolved.
|
||
| if process.poll() is not None: | ||
| raise RuntimeError(f"Chrome exited with code {process.returncode} before opening debugging port {port}") | ||
| time.sleep(0.25) | ||
| process.kill() | ||
| raise RuntimeError(f"Chrome did not open debugging port {port} within {CHROME_STARTUP_TIMEOUT_S:.0f}s") | ||
|
|
||
|
|
||
| def _debugger_listening(port: int) -> bool: | ||
| try: | ||
| return httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0).status_code == 200 | ||
| except httpx.HTTPError: | ||
| return False | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import uuid | ||
| from typing import Any | ||
|
|
||
| from pydantic import BaseModel, Field, model_validator | ||
| from typing_extensions import Self | ||
|
|
||
| from ..environment import HaiAgentsEnvironment | ||
|
|
||
| API_KEY_ENV_VAR = "HAI_API_KEY" | ||
| BASE_URL_ENV_VAR = "HAI_API_BASE_URL" | ||
| AUTO_SIDECAR_ENV_VAR = "HAI_AUTO_SIDECAR" | ||
|
|
||
| DEFAULT_BASE_URL = HaiAgentsEnvironment.EU.value | ||
| KIND_TO_CAPABILITY = {"web": "browser", "desktop": "desktop"} | ||
| CAPABILITIES = frozenset(KIND_TO_CAPABILITY.values()) | ||
|
|
||
|
|
||
| def auto_sidecars_enabled() -> bool: | ||
| return os.getenv(AUTO_SIDECAR_ENV_VAR, "1").strip().lower() not in {"0", "false", "no"} | ||
|
|
||
|
|
||
| def session_id_from_environment_id(environment_id: str, api_key: str, capability: str) -> str: | ||
| return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{api_key}.{environment_id}.{capability}")) | ||
|
|
||
|
|
||
| class SidecarConfig(BaseModel): | ||
| capability: str | ||
| environment_id: str | ||
| api_key: str = Field(default_factory=lambda: os.getenv(API_KEY_ENV_VAR, "")) | ||
| base_url: str = Field(default_factory=lambda: os.getenv(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL) | ||
| session_id: str = "" | ||
| driver_options: dict[str, Any] = Field(default_factory=dict) | ||
|
|
||
| @model_validator(mode="after") | ||
| def _resolve_defaults(self) -> Self: | ||
| if self.capability not in CAPABILITIES: | ||
| raise ValueError(f"unknown capability {self.capability!r}; expected one of {sorted(CAPABILITIES)}") | ||
| if not self.api_key: | ||
| raise ValueError(f"api_key is required (or set {API_KEY_ENV_VAR})") | ||
| if not self.session_id: | ||
| self.session_id = session_id_from_environment_id(self.environment_id, self.api_key, self.capability) | ||
| return self |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lock omits hai-drivers package
High Severity
Optional
browseranddesktopextras declarehai-drivers, butuv.lockstill resolves directselenium/pyautoguipins and never installshai-drivers, whileSidecarClient._build_driverimportshai_drivers.Additional Locations (1)
uv.lock#L154-L205Reviewed by Cursor Bugbot for commit fa3ff61. Configure here.