Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
fae5de8
wip(sdk): add agent placement and cancellable local execution
cm2435-hcomp Sep 29, 2026
f94841b
docs: explain candidate local agent setup and lifecycle limits
cm2435-hcomp Sep 29, 2026
48b35ee
docs: describe local shared file support and lifetime
cm2435-hcomp Sep 29, 2026
e2a3739
docs: describe candidate cloud workstation retention
cm2435-hcomp Sep 29, 2026
ac92c76
Interrupt desktop commands when the SDK bridge stops
cm2435-hcomp Sep 29, 2026
0b58d7e
Keep async desktop permission prompts on the caller thread
cm2435-hcomp Sep 29, 2026
0f6250d
build: prepare SDK-owned runtime pin updates
cm2435-hcomp Sep 29, 2026
5fcc24b
style(sdk): format shared client exports
cm2435-hcomp Sep 30, 2026
0e7f94b
Merge branch 'charlie/placement-python-sdk' into charlie/placement-sd…
cm2435-hcomp Sep 30, 2026
486bf46
fix(sdk): release download lock scope and close idle-probe clients
cm2435-hcomp Sep 30, 2026
3f968ec
Merge branch 'charlie/placement-python-sdk' into charlie/placement-sd…
cm2435-hcomp Sep 30, 2026
6f33203
fix(sdk): preserve cancellation retries and safe asynchronous lifecycle
cm2435-hcomp Sep 30, 2026
cb86d87
Merge branch 'charlie/placement-python-sdk' into charlie/placement-sd…
cm2435-hcomp Sep 30, 2026
f75842f
Clean up owned runtime when client validation fails
cm2435-hcomp Sep 30, 2026
8620ce1
Merge branch 'charlie/placement-python-sdk' into charlie/placement-sd…
cm2435-hcomp Sep 30, 2026
82bf0e7
Notify displaced sessions even when bridge stop times out
cm2435-hcomp Sep 30, 2026
8c31d02
Merge branch 'charlie/placement-python-sdk' into charlie/placement-sd…
cm2435-hcomp Sep 30, 2026
d21f77f
refactor(sdk): move local runtime into hai_agents_local and add Clien…
abonneth Oct 1, 2026
0f5f9a4
fix(sdk): keep loopback runtime traffic off environment proxies
abonneth Oct 1, 2026
547d2a3
fix(sdk): require the local runtime to prove its identity
abonneth Oct 1, 2026
1446f8e
fix(sdk): publish the runtime token only after the child proves the port
abonneth Oct 1, 2026
8e9b38f
fix(local): stop a bridge cleanly when its session's channel closes
abonneth Oct 1, 2026
f738f10
fix(local): keep cancel_session's signature and close only live sessions
abonneth Oct 1, 2026
d02c2de
fix(sdk): keep a started runtime alive while other clients use it
abonneth Oct 1, 2026
fb3f3d3
chore(sdk): drop internal markers and em dashes from local runtime docs
abonneth Oct 1, 2026
1d6640b
fix(local): give the runtime time to release environments before SIGKILL
abonneth Oct 1, 2026
c2c9a96
ci: block SDK releases whose pinned runtime lacks the shared recipe
abonneth Oct 1, 2026
a42b972
test: match the runtime identity error
abonneth Oct 1, 2026
725d4e6
Merge remote-tracking branch 'origin/charlie/placement-python-sdk' in…
abonneth Oct 1, 2026
c629770
test: skip desktop stop test until hai-drivers ships DesktopCommandRu…
abonneth Oct 1, 2026
bd285d2
fix(local): treat a closed channel on result post as a clean stop
abonneth Oct 1, 2026
b677898
fix(local): serialize idle shutdown with startup and cover a full spa…
abonneth Oct 1, 2026
59edf26
Merge remote-tracking branch 'origin/charlie/placement-python-sdk' in…
abonneth Oct 1, 2026
434d07a
fix(local): keep the create error when stopping its bridges fails
abonneth Oct 1, 2026
292d460
fix(local): count queued sessions as active in idle shutdown
abonneth Oct 1, 2026
ee14299
Merge remote-tracking branch 'origin/charlie/placement-python-sdk' in…
abonneth Oct 1, 2026
688453b
fix(runtime): stop an owned runtime whose idle probe fails
abonneth Oct 2, 2026
5c66e7c
refactor(runtime): drop unused LocalRuntime.force_kill and health
abonneth Oct 2, 2026
871349e
docs: trim local-agent README to usage
abonneth Oct 2, 2026
59a1d3b
refactor(local): set verify_runtime once where local sessions localize
abonneth Oct 2, 2026
3b15d98
refactor(runtime): keep the runtime pin in pin.json; bump script load…
abonneth Oct 2, 2026
e2b0410
refactor(local): share sync/async session bookkeeping and close-failu…
abonneth Oct 2, 2026
9202c2f
style(runtime): one-line docstrings, drop narrating comments
abonneth Oct 2, 2026
f4659fd
Merge remote-tracking branch 'origin/main' into charlie/placement-pyt…
abonneth Oct 2, 2026
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
27 changes: 26 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,33 @@ jobs:
name: dist
path: dist/

publish:
runtime-pin:
needs: build
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Install uv
uses: astral-sh/setup-uv@v10.1.0

- uses: actions/download-artifact@v8
with:
name: dist
path: dist/

- name: Pinned runtime downloads, verifies and serves the shared recipe
env:
HAI_AGENT_RUNTIME_FAKE: "1"
run: |
uv venv -q && uv pip install -q dist/*.whl
.venv/bin/python -c '
import tempfile
from hai_agents_local.runtime import LocalRuntime
LocalRuntime.ensure_started(required_recipe="shared", cache_dir=tempfile.mkdtemp(), port=18799,
spawn_env={"HAI_AGENT_RUNTIME_RECIPE": "shared"}).shutdown()
'

publish:
needs: [build, runtime-pin]
runs-on: ubuntu-latest
timeout-minutes: 10
environment: release
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,36 @@ print(result.answer)

`result` is a `SessionRunResult`: `id`, `status`, `answer`, the accumulated `events`, and `final_changes`.

## Local agents

`Client.local()` and `await AsyncClient.local()` run the agent on this machine through a local agent runtime,
started on demand or passed in with `runtime=...`. Each environment picks `host="user_device"` or `host="cloud"`.
`Client()` keeps using the hosted Agents API. Closing the client stops a runtime it started, unless other clients still
have active sessions there.

```python
from hai_agents import Client

with Client.local(local_options={"binary_path": "/path/to/hai-agent-runtime"}) as client:
session = client.sessions.create_session(
agent={
"name": "local-example",
"description": "Local workstation example",
"instructions": "Answer the user's task using the workstation tools.",
"environments": [
{"id": "workstation", "kind": "workstation", "host": "user_device"}
],
},
messages=[{"type": "user_message", "message": "Print hello using the shell."}],
max_steps=8,
max_time_s=120,
)
# Poll or steer the session here, before leaving the client context.
```

Inference stays hosted (`HAI_API_KEY`) unless you pass `inference=Inference.self_hosted(url, model=...)`
(`from hai_agents_local.runtime import Inference`).

## How a session works

A session is one run of an agent against a task. It moves through a small set of states: `pending`, `running`, and then a settled state such as `completed`, `idle`, `failed`, `timed_out`, or `interrupted`.
Expand Down
67 changes: 67 additions & 0 deletions scripts/bump_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Pin a hai-agent-runtime release: its version plus a fresh sha256 for every platform in the pin file."""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

# Stdlib only: runs in a bare checkout with no dependencies installed.
PIN_FILE = Path(__file__).parents[1] / "src" / "hai_agents_local" / "runtime" / "pin.json"
_VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?")
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
_PLACEHOLDER_SHA256 = "0" * 64


def apply_bump(pin: dict, version: str, shas: dict[str, str]) -> dict:
"""`pin` moved to `version`; raises unless `shas` holds a real digest for exactly the pinned platforms."""
if not _VERSION_RE.fullmatch(version):
raise ValueError("runtime version must be a release version, e.g. 0.1.13")
for platform, sha in shas.items():
if not _SHA256_RE.fullmatch(sha) or sha == _PLACEHOLDER_SHA256:
raise ValueError(f"{platform}: {sha!r} is not a lowercase 64-char sha256")
published = set(pin["sha256"])
extra = shas.keys() - published
if extra:
raise ValueError(f"no manifest entry for platform(s): {sorted(extra)}")
# A platform left on its old digest would fail verification at the new version's URL.
missing = published - shas.keys()
if missing:
raise ValueError(f"missing sha for published platform(s): {sorted(missing)}")
return {**pin, "version": version, "sha256": {platform: shas[platform] for platform in pin["sha256"]}}


def _parse_args(argv: list[str]) -> tuple[str, dict[str, str]]:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument(
"--sha",
action="append",
required=True,
metavar="PLATFORM=SHA256",
help="per-platform digest, e.g. darwin-arm64=<hex> (repeatable)",
)
args = parser.parse_args(argv)
shas: dict[str, str] = {}
for entry in args.sha:
platform, _, sha = entry.partition("=")
if not platform or not sha:
parser.error(f"--sha must be PLATFORM=SHA256, got {entry!r}")
if platform in shas:
parser.error(f"duplicate --sha for {platform}")
shas[platform] = sha.lower()
return args.version, shas


def main(argv: list[str]) -> int:
version, shas = _parse_args(argv)
pin = apply_bump(json.loads(PIN_FILE.read_text(encoding="utf-8")), version, shas)
PIN_FILE.write_text(json.dumps(pin, indent=2) + "\n", encoding="utf-8")
print(f"bumped runtime to {version} ({', '.join(sorted(shas))})")
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
108 changes: 106 additions & 2 deletions src/hai_agents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import asyncio
import typing

import typing_extensions
Expand All @@ -27,8 +28,57 @@
from .sessions.client import AsyncSessionsClient, SessionsClient
from .tools import ToolInput, as_tools

if typing.TYPE_CHECKING:
from hai_agents_local.runtime import Inference, LocalRuntime


class Client(BaseClient):
local_runtime: typing.Optional[LocalRuntime] = None
_owns_runtime = False
_auto_bridges = True

@classmethod
def local(
cls,
*,
runtime: typing.Optional[LocalRuntime] = None,
inference: typing.Optional[Inference] = None,
local_options: typing.Optional[typing.Dict[str, typing.Any]] = None,
auto_bridges: bool = True,
timeout: typing.Optional[float] = None,
) -> Client:
"""A client on a local agent runtime: ``runtime`` if given, else one this client starts and owns."""
from hai_agents_local.runtime import acquire_runtime

runtime, owned = acquire_runtime(runtime, inference=inference, local_options=local_options)
try:
client = cls(base_url=runtime.base_url, api_key=runtime.api_key, httpx_client=runtime.http_client(timeout))
except BaseException:
if owned:
runtime.shutdown()
raise
Comment thread
cursor[bot] marked this conversation as resolved.
client.local_runtime, client._owns_runtime, client._auto_bridges = runtime, owned, auto_bridges
return client

def close(self) -> None:
"""Stop sessions this client bridged; a local client also stops its runtime once no other client uses it."""
try:
if self._sessions is not None:
self._sessions.close()
finally:
if self.local_runtime is not None:
try:
if self._owns_runtime:
self.local_runtime.shutdown_if_idle(getattr(self._sessions, "own_session_ids", ()))
finally:
self._client_wrapper.httpx_client.httpx_client.close()

def __enter__(self) -> Client:
return self

def __exit__(self, *exc: typing.Any) -> None:
self.close()

def run_session(
self,
*,
Expand Down Expand Up @@ -81,11 +131,63 @@ def sessions(self) -> SessionsClient:
if self._sessions is None:
from hai_agents_local.sessions import LocalSessionsClient

self._sessions = LocalSessionsClient(client_wrapper=self._client_wrapper)
self._sessions = LocalSessionsClient(
client_wrapper=self._client_wrapper, runtime=self.local_runtime, auto_bridges=self._auto_bridges
)
return self._sessions


class AsyncClient(AsyncBaseClient):
local_runtime: typing.Optional[LocalRuntime] = None
_owns_runtime = False
_auto_bridges = True

@classmethod
async def local(
cls,
*,
runtime: typing.Optional[LocalRuntime] = None,
inference: typing.Optional[Inference] = None,
local_options: typing.Optional[typing.Dict[str, typing.Any]] = None,
auto_bridges: bool = True,
timeout: typing.Optional[float] = None,
) -> AsyncClient:
"""A client on a local agent runtime: ``runtime`` if given, else one this client starts and owns."""
from hai_agents_local.runtime import acquire_runtime_async

runtime, owned = await acquire_runtime_async(runtime, inference=inference, local_options=local_options)
try:
client = cls(
base_url=runtime.base_url, api_key=runtime.api_key, httpx_client=runtime.async_http_client(timeout)
)
except BaseException:
if owned:
await asyncio.to_thread(runtime.shutdown)
raise
client.local_runtime, client._owns_runtime, client._auto_bridges = runtime, owned, auto_bridges
return client

async def aclose(self) -> None:
"""Stop sessions this client bridged; a local client also stops its runtime once no other client uses it."""
try:
if self._sessions is not None:
await self._sessions.aclose()
finally:
if self.local_runtime is not None:
try:
if self._owns_runtime:
await asyncio.to_thread(
self.local_runtime.shutdown_if_idle, getattr(self._sessions, "own_session_ids", ())
)
finally:
await self._client_wrapper.httpx_client.httpx_client.aclose()

async def __aenter__(self) -> AsyncClient:
return self

async def __aexit__(self, *exc: typing.Any) -> None:
await self.aclose()

async def run_session(
self,
*,
Expand Down Expand Up @@ -138,5 +240,7 @@ def sessions(self) -> AsyncSessionsClient:
if self._sessions is None:
from hai_agents_local.sessions import LocalAsyncSessionsClient

self._sessions = LocalAsyncSessionsClient(client_wrapper=self._client_wrapper)
self._sessions = LocalAsyncSessionsClient(
client_wrapper=self._client_wrapper, runtime=self.local_runtime, auto_bridges=self._auto_bridges
)
return self._sessions
36 changes: 32 additions & 4 deletions src/hai_agents_local/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
import httpx

from .config import default_base_url
from .errors import RateLimitedError, SessionNotFoundError
from .errors import ChannelClosedError, RateLimitedError, SessionNotFoundError
from .runtime import identity
from .transport import Command, CommandExchange, Json, deserialize_args, serialize_result

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,6 +62,8 @@ class LocalBridge(ABC, Generic[DriverT]):
environment_kind: ClassVar[str]
startup_hint: ClassVar[str | None] = None
"""Appended to the manager's not-ready timeout error; names the common cause of a hung startup."""
verify_runtime: bool = False
"""Reject responses not HMAC-proven with api_key; requires api_key to be the local runtime's token string."""

def __init__(
self,
Expand Down Expand Up @@ -110,13 +113,23 @@ def request_stop(self) -> None:
"""Signal the poll loop to stop; safe to call from a signal handler."""
self._stop_event.set()

async def interrupt_driver(self) -> None:
"""Stop run-owned work; drivers without owned processes need no special action."""

async def run(self) -> None:
"""Serve commands until stopped; raises AuthError on a bad key."""
# An asyncio.Event binds to the loop it is first awaited on; a restarted bridge runs on a new loop.
self._stop_event = asyncio.Event()
options: dict[str, Any] = {
"headers": {"Accept": "application/json"},
"auth": _BearerAuth(self.api_key),
"follow_redirects": True,
}
try:
async with httpx.AsyncClient(
headers={"Accept": "application/json"}, auth=_BearerAuth(self.api_key), follow_redirects=True
async with (
identity.async_http_client(self.api_key, **options)
if self.verify_runtime
else httpx.AsyncClient(**options)
) as client:
exchange = CommandExchange(client, self.base_url)
if not await self._open_channel(exchange):
Expand Down Expand Up @@ -195,6 +208,9 @@ async def _poll_loop(self, exchange: CommandExchange) -> None:
):
# Instant empty polls are paced so a misbehaving server cannot cause a busy loop.
break
except ChannelClosedError:
logger.info("channel %s closed; the session ended", self.session_id)
return
except SessionNotFoundError:
# Channel was garbage-collected server-side; recreate on the next iteration so
# rate limits and transient errors during recreation hit the handlers below.
Expand Down Expand Up @@ -256,7 +272,19 @@ async def _process_commands(self, exchange: CommandExchange, commands: list[Comm
self._results.move_to_end(cmd.command_uid)
result, error = self._results[cmd.command_uid]
else:
result, error = await asyncio.to_thread(self._dispatch, cmd.name, cmd.args)
dispatch = asyncio.create_task(asyncio.to_thread(self._dispatch, cmd.name, cmd.args))
stopped = asyncio.create_task(self._stop_event.wait())
try:
await asyncio.wait((dispatch, stopped), return_when=asyncio.FIRST_COMPLETED)
if self._stop_event.is_set():
await self.interrupt_driver()
await dispatch
return
result, error = await dispatch
finally:
stopped.cancel()
with contextlib.suppress(asyncio.CancelledError):
await stopped
self._results[cmd.command_uid] = (result, error)
while len(self._results) > RESULT_CACHE_SIZE:
self._results.popitem(last=False)
Expand Down
2 changes: 1 addition & 1 deletion src/hai_agents_local/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,6 @@ def _ensure_local_chrome(self) -> None:

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
return httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0, trust_env=False).status_code == 200
except httpx.HTTPError:
return False
2 changes: 1 addition & 1 deletion src/hai_agents_local/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Environment variables read by hai_agents.local."""
"""Environment variables read by hai_agents_local."""

from __future__ import annotations

Expand Down
7 changes: 7 additions & 0 deletions src/hai_agents_local/desktop.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import sys
import threading
from typing import TYPE_CHECKING, Literal

from .bridge import LocalBridge, TokenSource
Expand Down Expand Up @@ -28,6 +30,7 @@ def ensure_macos_input_permissions(prompt: bool = True) -> None:
from ApplicationServices import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt
from Quartz import CGPreflightScreenCaptureAccess, CGRequestScreenCaptureAccess

prompt = prompt and threading.current_thread() is threading.main_thread()
missing = []
if not AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: prompt}):
missing.append("Accessibility (moves the mouse and types)")
Expand Down Expand Up @@ -98,6 +101,10 @@ def create_driver(self) -> DesktopDriverInterface:
quality=self.quality,
)

async def interrupt_driver(self) -> None:
if self._driver is not None:
await asyncio.to_thread(self._driver.close)

def driver_interface(self) -> type:
# Runtime import: hai-drivers is absent unless installed with hai-agents[desktop].
from hai_drivers.desktop.interface import DesktopDriverInterface
Expand Down
Loading
Loading