Skip to content
Draft
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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,25 @@ The trigger and the lever are decoupled through a single one-line file, `~/.holo
- **Reading it (the lever):** every turn records its own `started_at` and, while running, polls the file ~4×/s. It acts **only if the file's timestamp is newer than its `started_at`** — then it pauses, then cancels at the next action boundary.
- **Clearing it:** the file is *never deleted*. It's cleared by time — the next turn starts later, so a leftover request is automatically stale and can't kill it. This is also why a `holo stop` fired *before* a run begins is ignored: nothing was running to stop.

`holo stop --force` is the exception to the step-bounded model: it reads the runtime's pid file (`~/.holo/agent-pid-<port>`) and SIGKILLs the process directly, so it doesn't wait for an action boundary.
`holo stop --force` is the exception to the step-bounded model: it discovers the runtime through the hai-agents SDK (`LocalRuntime.attach(...).force_kill()`) and SIGKILLs the process directly, so it doesn't wait for an action boundary. For one release it also honours pre-SDK pid files (`~/.holo/agent-pid-<port>`) so an upgrade mid-session can still be stopped.

## Use from Python

`holo_desktop.agent_client` is the same client every CLI surface is built on: it spawns (or attaches to) the `hai-agent-runtime` binary on loopback and drives sessions over the agent API.
`holo_desktop.agent_client` is the same client every CLI surface is built on: it spawns (or attaches to) the `hai-agent-runtime` binary on loopback via the hai-agents local runtime and drives sessions over the agent API.

```python
import asyncio

from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_running
from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_local_runtime
from holo_desktop.agent_client.requests import build_session_request
from holo_desktop.settings import load_holo_settings


async def main() -> None:
daemon = await ensure_running(SpawnConfig(port=18795))
settings = load_holo_settings()
runtime = await asyncio.to_thread(ensure_local_runtime, SpawnConfig(port=18795), settings=settings)
try:
async with AgentApiClient(daemon.base_url, daemon.token) as client:
async with AgentApiClient(runtime) as client:
request = build_session_request(
task="Tell me how many unread emails I have", max_steps=None, max_time_s=None
)
Expand All @@ -119,7 +121,8 @@ async def main() -> None:
print(event.type)
print(stream.answer)
finally:
await daemon.aclose()
if runtime.owned:
await asyncio.to_thread(runtime.shutdown)


asyncio.run(main())
Expand Down
6 changes: 3 additions & 3 deletions examples/expense_report/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ holo-desktop-cli is a thin client: the agent lives behind the closed `hai-agent-
```mermaid
flowchart LR
cli["expense-report-demo run"] --> setup["stage files + launch apps<br/>(open / osascript)"]
setup --> rt["session.Runtime<br/>ensure_running(SpawnConfig)"]
setup --> rt["session.Runtime<br/>ensure_local_runtime(SpawnConfig)"]
rt --> bin["hai-agent-runtime<br/>(closed binary, loopback HTTP)"]
bin --> events["events.jsonl + TaskResult"]
events --> verify["verifiers<br/>(openpyxl, AppleScript)"]
Expand All @@ -24,7 +24,7 @@ flowchart LR

macOS only for now. Requires:
- Python >= 3.12 and [uv](https://docs.astral.sh/uv/).
- The `hai-agent-runtime` binary (holo-desktop-cli's launcher resolves a managed install under `~/.holo/runtime/` or `PATH`; running `holo run "hi"` once installs it).
- The `hai-agent-runtime` binary (the `hai-agents` SDK resolves a verified managed install or a binary on `PATH`; running `holo run "hi"` once installs it).
- An H Company API key (`holo login`, or `HAI_API_KEY` in `~/.holo/.env`). Not needed with `--fake` or `--base-url`.
- [LibreOffice](https://www.libreoffice.org/download/download/) installed at `/Applications/LibreOffice.app` (bundle id `org.libreoffice.script`).

Expand Down Expand Up @@ -72,7 +72,7 @@ Teardown quarantines the staged files into `~/.holo/runs/expense_report-quaranti

```
src/expense_report_demo/
session.py # Runtime (ensure_running + AgentApiClient), TaskResult, events.jsonl persistence
session.py # Runtime (ensure_local_runtime + AgentApiClient), TaskResult, events.jsonl persistence
apps.py # launch_app, wait_for_app, activate_app, kill_all (open/osascript)
metrics.py # Stat, StepStats, Metrics, read_events (over TrajectoryEvent JSONL)
fixtures.py # OSWorld HF downloader, sha256 cache, TOML pin
Expand Down
2 changes: 1 addition & 1 deletion examples/expense_report/src/expense_report_demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import Annotated

import tyro
from holo_desktop.agent_client.launcher import port_from_env
from holo_desktop.agent_client.sdk_runtime import port_from_env
from holo_desktop.cli.bootstrap import load_holo_env
from holo_desktop.settings import load_holo_settings

Expand Down
16 changes: 13 additions & 3 deletions examples/expense_report/src/expense_report_demo/demos/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
from statistics import fmean
from typing import Literal

from holo_desktop.agent_client import runtime_log_path, runtime_log_tail
from holo_desktop.agent_client.event_timings import StepTiming, StepTimingsSummary, render_step_timings
from holo_desktop.killswitch import (
KILL_SWITCH_ARMED_HINT,
Expand Down Expand Up @@ -101,6 +100,7 @@ class DemoRun(BaseModel):
answer: str | None
verify_checks: list[VerifyCheck] | None
events_path: str
runtime_log_path: str | None
metrics: Metrics | None


Expand Down Expand Up @@ -144,6 +144,7 @@ def run_demo(
session_id: str | None = None
verify_checks: list[VerifyCheck] | None = None
session_ran = False
runtime_log_path: str | None = None

run_id = started_at.strftime("%Y%m%d-%H%M%S")
events_path = out_root / demo.slug / run_id / "events.jsonl"
Expand All @@ -155,6 +156,7 @@ def run_demo(
if setup_fn is not None:
setup_fn(demo)
with Runtime(holo_kwargs.runtime_config()) as runtime:
runtime_log_path = str(runtime.log_path) if runtime.log_path is not None else None
for spec in demo.pre_launch:
_launch_one(spec)
apps.activate_app(demo.focus_bundle_id)
Expand Down Expand Up @@ -221,6 +223,7 @@ def run_demo(
answer=answer,
verify_checks=verify_checks,
events_path=str(events_path),
runtime_log_path=runtime_log_path,
metrics=metrics,
)
_persist(run, out_root)
Expand Down Expand Up @@ -361,9 +364,15 @@ def _print_failure_detail(run: DemoRun) -> None:
"""On a non-ok run, echo the error and the runtime's stderr tail so the cause isn't buried."""
if run.error:
print(f" [error] {run.error}", file=sys.stderr)
log_path = runtime_log_path(run.holo_kwargs.port)
if run.runtime_log_path is None:
return
log_path = Path(run.runtime_log_path)
print(f" [error] runtime log: {log_path}", file=sys.stderr)
tail = runtime_log_tail(run.holo_kwargs.port)
try:
tail = log_path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f" [runtime] log unreadable: {exc}", file=sys.stderr)
return
for line in tail.splitlines()[-_LOG_TAIL_LINES:]:
print(f" [runtime] {line}", file=sys.stderr)

Expand Down Expand Up @@ -392,5 +401,6 @@ def _dry_run(
answer=None,
verify_checks=None,
events_path="",
runtime_log_path=None,
metrics=None,
)
46 changes: 25 additions & 21 deletions examples/expense_report/src/expense_report_demo/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
from typing import Literal

from agp_types import TrajectoryEvent, TrajectoryStatus
from holo_desktop.agent_client import AgentApiClient, AgentDaemon, SpawnConfig, ensure_running
from hai_agents.local import LocalRuntime
from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_local_runtime
from holo_desktop.agent_client.events import is_policy_event
from holo_desktop.agent_client.session_runner import Session, TurnOutcome, run_turn
from holo_desktop.cli.bootstrap import load_holo_env, require_api_key
Expand Down Expand Up @@ -63,7 +64,7 @@ class Runtime:

def __init__(self, config: RuntimeConfig) -> None:
self._config = config
self._daemon: AgentDaemon | None = None
self._runtime: LocalRuntime | None = None

def __enter__(self) -> Runtime:
# Per-request HTTP logs are implementation detail, not run output.
Expand All @@ -72,16 +73,14 @@ def __enter__(self) -> Runtime:
settings = load_holo_settings()
if not self._config.fake:
require_api_key(explicit_base_url=self._config.base_url, settings=settings)
self._daemon = asyncio.run(
ensure_running(
SpawnConfig(
port=self._config.port,
model=self._config.model,
base_url=self._config.base_url,
fake=self._config.fake,
),
settings=settings,
)
self._runtime = ensure_local_runtime(
SpawnConfig(
port=self._config.port,
model=self._config.model,
base_url=self._config.base_url,
fake=self._config.fake,
),
settings=settings,
)
return self

Expand All @@ -91,21 +90,26 @@ def __exit__(
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
daemon = self._daemon
self._daemon = None
if daemon is not None:
asyncio.run(daemon.aclose())
runtime = self._runtime
self._runtime = None
if runtime is not None and runtime.owned:
runtime.shutdown()

@property
def log_path(self) -> Path | None:
"""Runtime stderr log, when the SDK owns or discovered one."""
return self._runtime.log_path if self._runtime is not None else None

def run_task(
self, *, task: str, max_steps: int, max_time_s: float, events_path: Path, expand_feed: bool
) -> TaskResult:
"""Run one session to end-of-turn, persisting every event to `events_path`."""
daemon = self._daemon
if daemon is None:
runtime = self._runtime
if runtime is None:
raise RuntimeError("Runtime.run_task called outside the Runtime context")
return asyncio.run(
_drive(
daemon,
runtime,
task=task,
max_steps=max_steps,
max_time_s=max_time_s,
Expand Down Expand Up @@ -134,7 +138,7 @@ def _project_result(outcome: TurnOutcome, *, n_steps: int) -> TaskResult:


async def _drive(
daemon: AgentDaemon,
runtime: LocalRuntime,
*,
task: str,
max_steps: int,
Expand All @@ -145,7 +149,7 @@ async def _drive(
events_path.parent.mkdir(parents=True, exist_ok=True)
feed = LiveFeed(Console(stderr=True), expand=expand_feed)
n_steps = 0
async with AgentApiClient(daemon.base_url, daemon.token) as client:
async with AgentApiClient(runtime) as client:
session = Session()
with events_path.open("w", encoding="utf-8") as sink:

Expand Down
13 changes: 11 additions & 2 deletions examples/expense_report/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Shared test fixtures for the expense-report demo suite.

`fake_agent_server` stands up a minimal agent-API on a free loopback port so
the real `port_from_env` / `require_api_key` / `ensure_running` wiring runs
the real `port_from_env` / `require_api_key` / `ensure_local_runtime` wiring runs
end-to-end without the `hai-agent-runtime` binary. A dropped `settings=` on any
of those calls then surfaces as a loud `TypeError`.
"""
Expand Down Expand Up @@ -52,13 +52,22 @@ def do_GET(self) -> None:
self.end_headers()
elif "/changes" in self.path:
self._json(_COMPLETED_CHANGES)
elif self.path.endswith("/status"):
self._json({"status": "completed"})
else:
self.send_response(404)
self.end_headers()

def do_POST(self) -> None:
if self.path.endswith("/sessions"):
self._json({"id": "fake-session"})
self._json(
{
"id": "fake-session",
"request": {"agent": "holo"},
"status": {"status": "pending"},
"created_at": "2026-07-13T00:00:00Z",
}
)
else:
self.send_response(404)
self.end_headers()
Expand Down
3 changes: 3 additions & 0 deletions examples/expense_report/tests/test_runner_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def test_persist_writes_task_json_and_appends_csv(tmp_path: Path) -> None:
answer="hello world",
verify_checks=checks,
events_path="runs/test_demo/20260612-120000/events.jsonl",
runtime_log_path=None,
metrics=None,
)

Expand Down Expand Up @@ -160,6 +161,8 @@ class _FakeRuntime:

def __init__(self, *_args: object, **_kwargs: object) -> None: ...

log_path = Path("fake-runtime.log")

def __enter__(self) -> _FakeRuntime:
return self

Expand Down
14 changes: 4 additions & 10 deletions examples/expense_report/tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,18 @@
import json
import shutil
import socket
import sys
from pathlib import Path

import pytest
from agp_types import TrajectoryStatus
from holo_desktop.agent_client import launcher, runtime_install
from hai_agents.local.install import installed_binary
from hai_agents.local.manifest import BINARY_NAME, PINNED_RUNTIME_VERSION
from holo_desktop.agent_client.session_runner import TurnOutcome
from holo_desktop.settings import AUTH_TOKEN_ENV

from expense_report_demo.session import Runtime, RuntimeConfig, TaskResult, _project_result

_RUNTIME_AVAILABLE = (
shutil.which("hai-agent-runtime") is not None
or runtime_install.installed_binary(runtime_install.PINNED_RUNTIME_VERSION) is not None
)
_RUNTIME_AVAILABLE = shutil.which(BINARY_NAME) is not None or installed_binary(PINNED_RUNTIME_VERSION) is not None

needs_runtime = pytest.mark.skipif(not _RUNTIME_AVAILABLE, reason="hai-agent-runtime binary not installed")

Expand Down Expand Up @@ -82,13 +79,10 @@ def test_runtime_attaches_to_fake_server_non_fake(
monkeypatch: pytest.MonkeyPatch, fake_agent_server: int, tmp_path: Path
) -> None:
"""Non-fake `Runtime` against a fake agent server: exercises the real
`require_api_key(settings=)` and `ensure_running(settings=)` wiring without
`require_api_key(settings=)` and `ensure_local_runtime(settings=)` wiring without
the runtime binary."""
monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token")
monkeypatch.setenv("HAI_API_KEY", "test-key") # satisfy require_api_key without interactive login
# Crash-only stub: attaching to the live server must never fall through to spawning a binary.
monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, "-c", "raise SystemExit(2)"])

config = RuntimeConfig(port=fake_agent_server, model=None, base_url=None, fake=False)
with Runtime(config) as runtime:
result = runtime.run_task(
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dependencies = [
# Agent-API wire contract (Pydantic specs + agp_types) for talking to the closed
# hai-agent-runtime binary; the wheel vendors agent_interface + agp_types as the only source of the spec types.
"hai-agent-api>=0.1.15",
# SDK local mode (plan 002): owns runtime download/spawn/auth/discovery; Holo delegates lifecycle to it.
"hai-agents[local]>=0.1",
# CLI / serve (A2A) / acp / mcp adapter surface.
"a2a-sdk[http-server]>=1.0.3",
"agent-client-protocol>=0.10",
Expand Down Expand Up @@ -78,6 +80,12 @@ dev = [
[tool.uv.workspace]
members = ["examples/expense_report"]

[tool.uv.sources]
# SHIP-GATE: replace with the published hai-agents[local] version before merge.
# Review-time override onto the public plan-002 branch so every CI runner can
# resolve the same SDK implementation. uv.lock pins the resolved commit.
hai-agents = { git = "https://github.com/hcompai/hai-agents-python.git", branch = "claude/sdk-local-mode" }

[tool.hatch.build.targets.wheel]
packages = ["src/holo_desktop"]

Expand Down
Loading
Loading