Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@ cli = [
"rich>=13,<16",
"typer>=0.12,<1",
]
desktop = [
"hai-drivers[desktop]>=0.1.0",
]
browser = [
"hai-drivers[web]>=0.1.0",
]

Copy link
Copy Markdown

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 browser and desktop extras declare hai-drivers, but uv.lock still resolves direct selenium/pyautogui pins and never installs hai-drivers, while SidecarClient._build_driver imports hai_drivers.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fa3ff61. Configure here.

all = [
"python-dotenv>=1.2.2,<2",
"rich>=13,<16",
"typer>=0.12,<1",
"hai-agents[browser,cli,desktop]",
]

[project.scripts]
Expand Down
112 changes: 112 additions & 0 deletions src/hai_agents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named agent fetch skips resolve

Medium Severity

When create_session is given a catalog agent name and auto sidecars are enabled, the SDK loads the definition with get_agent but does not pass resolve=true. Unresolved string subagent or environment leaves never expose user_device configs, so collect_sidecar_configs can skip required local sidecars while the session still starts.

Additional Locations (1)
Fix in Cursor Fix in Web

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)
Comment thread
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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
18 changes: 18 additions & 0 deletions src/hai_agents/local/__init__.py
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",
]
76 changes: 76 additions & 0 deletions src/hai_agents/local/chrome.py
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
Comment thread
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
45 changes: 45 additions & 0 deletions src/hai_agents/local/config.py
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
Loading
Loading