diff --git a/README.md b/README.md index fbfc718..11b7e34 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,26 @@ operator view for the tasks that actually execute on that Host, including per-step screenshots, OCR observations, and UI-tree results. The Cloud Console remains the fleet-level view for dispatch status and Cloud-proxy planner history. +## Local-only Host Agent + +Run without a Cloud Control Plane by setting `HOST_AGENT_MODE=local`. Tasks +submitted in the Host console are queued and executed in the same process: + +```bash +export HOST_AGENT_MODE=local +export AI_PLANNER_ENABLED=true +export AI_PLANNER_PROVIDER=openai-compatible +export AI_PLANNER_MODEL=qwen2.5 +export AI_PLANNER_API_KEY=local-key +export AI_PLANNER_BASE_URL=http://127.0.0.1:11434/v1 +uv run --package device-host-agent device-host-agent setup +uv run --package device-host-agent device-host-agent +``` + +`openai-compatible` works with Ollama, LM Studio, vLLM, or another server that +implements OpenAI `/chat/completions`. Hosted `openai` and `anthropic` providers +also accept `AI_PLANNER_API_KEY` and their conventional API key variables. + ## Project Direction The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture diff --git a/apps/device-host-agent/host_agent/app.py b/apps/device-host-agent/host_agent/app.py index 0ad401c..b02f324 100644 --- a/apps/device-host-agent/host_agent/app.py +++ b/apps/device-host-agent/host_agent/app.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging from contextlib import suppress -from dataclasses import dataclass +from dataclasses import dataclass, replace import uvicorn @@ -22,6 +22,7 @@ from host_agent.identity import HostIdentityStore from host_agent.instance_lock import InstanceLock from host_agent.lease import ActiveAssignmentRunner from host_agent.local_account import LocalAccountStore +from host_agent.local_client import LocalHostAgentClient from host_agent.mcp_lock import McpBusyTracker from host_agent.mcp_token import McpTokenStore from host_agent.policy_cache import HostPolicyCacheStore @@ -173,25 +174,37 @@ def create_application( startup_config.identity_path ) owned_enrollment_client = enrollment_client is None - bootstrap_client = enrollment_client or HostAgentEnrollmentClient( - startup_config - ) + bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config) try: - resolved_config = resolve_host_identity( - startup_config, - identity_store=resolved_identity_store, - client=bootstrap_client, - ) - bootstrap_client.config = resolved_config - resolved_manager = manager or _configured_device_manager( - config_store, - config=resolved_config, - enrollment_client=bootstrap_client, - ) + if startup_config.mode == "local": + resolved_config = replace( + startup_config, control_plane_url="", host_id="local-host" + ) + resolved_manager = manager or _configured_device_manager( + config_store, config=resolved_config, enrollment_client=None + ) + else: + resolved_config = resolve_host_identity( + startup_config, + identity_store=resolved_identity_store, + client=bootstrap_client, + ) + bootstrap_client.config = resolved_config + resolved_manager = manager or _configured_device_manager( + config_store, + config=resolved_config, + enrollment_client=bootstrap_client, + ) finally: if owned_enrollment_client: bootstrap_client.close() - client = HostAgentClient(resolved_config) + if resolved_config.mode == "local": + client = LocalHostAgentClient( + host_id="local-host", + device_ids=lambda: [device.id for device in resolved_manager.list_devices()], + ) + else: + client = HostAgentClient(resolved_config) history_store = ConsoleHistoryStore( resolved_config.identity_path.parent / "host_console_history.sqlite3", @@ -352,7 +365,7 @@ def _configured_device_manager( config_store: DeviceConfigStore, *, config: HostAgentConfig, - enrollment_client: HostAgentEnrollmentClient, + enrollment_client: HostAgentEnrollmentClient | None, ) -> DeviceManager: manager = DeviceManager() for device in config_store.list(): diff --git a/apps/device-host-agent/host_agent/cli.py b/apps/device-host-agent/host_agent/cli.py index 67650da..0c1651b 100644 --- a/apps/device-host-agent/host_agent/cli.py +++ b/apps/device-host-agent/host_agent/cli.py @@ -2,7 +2,9 @@ from __future__ import annotations import argparse import getpass +import os import sys +from pathlib import Path from collections.abc import Sequence from dataclasses import replace @@ -18,6 +20,7 @@ class LocalAccountSetupError(RuntimeError): def main(argv: Sequence[str] | None = None) -> None: + _load_dotenv() parser = argparse.ArgumentParser(description="Run the Device Host Agent") subparsers = parser.add_subparsers(dest="command") subparsers.add_parser("setup", help="Create the local operator account") @@ -96,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore): if password != confirm: raise LocalAccountSetupError("passwords do not match") return store.create(username, password) + + +def _load_dotenv() -> None: + """Load a simple repository-root .env without overriding shell values.""" + path = Path.cwd() / ".env" + if not path.is_file(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + name = name.strip() + value = value.strip() + if name and name not in os.environ: + os.environ[name] = value diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index 13ef349..95dc02f 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -13,6 +13,7 @@ class HostAgentConfigurationError(ValueError): _LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) _AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"}) +_HOST_AGENT_MODES = frozenset({"cloud", "local"}) _REMOVED_RUNTIME_SUPERVISION_SETTINGS = ( "HOST_AGENT_RUNTIME_SUPERVISED", "HOST_AGENT_RUNTIME_HOST", @@ -22,7 +23,8 @@ _REMOVED_RUNTIME_SUPERVISION_SETTINGS = ( @dataclass(frozen=True) class HostAgentConfig: - control_plane_url: str + control_plane_url: str = "" + mode: str = "cloud" host_id: str = "" token: str = field(default="", repr=False) identity_path: Path = Path("tasks/host_identity.json") @@ -57,16 +59,19 @@ def load_host_agent_config( ) -> HostAgentConfig: values = os.environ if env is None else env _reject_removed_runtime_supervision_settings(values) + mode = values.get("HOST_AGENT_MODE", "cloud").strip().lower() + if mode not in _HOST_AGENT_MODES: + raise HostAgentConfigurationError("HOST_AGENT_MODE must be cloud or local") control_plane_url = ( values.get( "HOST_AGENT_CONTROL_PLANE_URL", - "https://amcp.home.jerryyan.top", + "https://amcp.home.jerryyan.top" if mode == "cloud" else "", ) .strip() .rstrip("/") ) parsed_url = urlparse(control_plane_url) - if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + if mode == "cloud" and (parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc): raise HostAgentConfigurationError( "HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL" ) @@ -82,9 +87,10 @@ def load_host_agent_config( config = HostAgentConfig( control_plane_url=control_plane_url, + mode=mode, identity_path=identity_path, local_account_path=local_account_path, - enrollment_managed=True, + enrollment_managed=mode == "cloud", display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, heartbeat_interval_seconds=_positive_float( values, @@ -128,9 +134,7 @@ def load_host_agent_config( "HOST_AGENT_CONSOLE_HISTORY_LIMIT", 200, ), - ai_planner_transport=_parse_ai_planner_transport( - values.get("AI_PLANNER_TRANSPORT") - ), + ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))), dependency_supervisor_enabled=_truthy( values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False ), diff --git a/apps/device-host-agent/host_agent/local_client.py b/apps/device-host-agent/host_agent/local_client.py new file mode 100644 index 0000000..e7bbc49 --- /dev/null +++ b/apps/device-host-agent/host_agent/local_client.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import asyncio +import uuid +from collections.abc import Callable +from datetime import UTC, datetime, timedelta + +from cloud.internal_api.models import ( + ClaimResponse, + HeartbeatResponse, + HostTaskCancellationResponse, + HostTaskSubmissionResponse, + LeaseRenewalResponse, + TerminalResultResponse, +) +from host_agent.progress import TaskProgressSnapshot + + +class LocalHostAgentClient: + """In-process task broker used when the Host Agent runs without Cloud.""" + + def __init__(self, *, host_id: str, device_ids: Callable[[], list[str]]) -> None: + self.host_id = host_id + self._device_ids = device_ids + self._queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue() + self._cancelled: set[str] = set() + + async def submit_self_task(self, *, goal: str, device_id: str | None = None) -> HostTaskSubmissionResponse: + task_id = f"local-{uuid.uuid4().hex}" + await self._queue.put((task_id, goal, device_id)) + return HostTaskSubmissionResponse(task_id=task_id) + + async def claim(self): + task_id, goal, requested_device = await self._queue.get() + devices = self._device_ids() + device_id = requested_device or (devices[0] if devices else "") + if not device_id: + return None + from cloud.internal_api.models import AssignmentModel + + return AssignmentModel( + task_id=task_id, + attempt=1, + lease_id=f"local-lease-{uuid.uuid4().hex}", + lease_expires_at=datetime.now(UTC) + timedelta(days=3650), + host_id=self.host_id, + device_id=device_id, + goal=goal, + ) + + async def heartbeat(self, *args, **kwargs) -> HeartbeatResponse: + return HeartbeatResponse( + host_id=self.host_id, + accepted_devices=len(self._device_ids()), + received_at=datetime.now(UTC), + ) + + async def renew(self, assignment, *, progress: TaskProgressSnapshot | None = None) -> LeaseRenewalResponse: + return LeaseRenewalResponse( + status="renewed", + lease_expires_at=datetime.now(UTC) + timedelta(days=3650), + ) + + async def report_result(self, assignment, *, status: str, failure_reason: str | None = None, result: dict | None = None) -> TerminalResultResponse: + return TerminalResultResponse(status="recorded") + + async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse: + self._cancelled.add(task_id) + return HostTaskCancellationResponse(task_id=task_id, status="cancel_requested") + + async def aclose(self) -> None: + return None diff --git a/apps/device-host-agent/tests/test_config.py b/apps/device-host-agent/tests/test_config.py index 28a78d5..86e0658 100644 --- a/apps/device-host-agent/tests/test_config.py +++ b/apps/device-host-agent/tests/test_config.py @@ -27,6 +27,15 @@ def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> No assert config.ai_planner_transport == "direct" +def test_load_host_agent_config_supports_local_mode() -> None: + config = load_host_agent_config({"HOST_AGENT_MODE": "local"}) + + assert config.mode == "local" + assert config.control_plane_url == "" + assert config.enrollment_managed is False + assert config.ai_planner_transport == "direct" + + def test_load_host_agent_config_parses_poll_and_retry_values() -> None: config = load_host_agent_config( { diff --git a/runtime/planner_config.py b/runtime/planner_config.py index 28c1bb3..24f9c99 100644 --- a/runtime/planner_config.py +++ b/runtime/planner_config.py @@ -8,6 +8,7 @@ DEFAULT_PROVIDER = "anthropic" DEFAULT_MODEL_BY_PROVIDER = { "anthropic": "claude-sonnet-5", "openai": "gpt-5.6", + "openai_compatible": "local-model", } DEFAULT_TIMEOUT_SECONDS = 30.0 @@ -16,6 +17,8 @@ PROVIDER_ENV = "AI_PLANNER_PROVIDER" MODEL_ENV = "AI_PLANNER_MODEL" TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS" THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS" +API_KEY_ENV = "AI_PLANNER_API_KEY" +BASE_URL_ENV = "AI_PLANNER_BASE_URL" SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER) @@ -27,6 +30,8 @@ class PlannerConfig: model: str = "" timeout: float = DEFAULT_TIMEOUT_SECONDS thinking_budget_tokens: int | None = None + api_key: str | None = None + base_url: str | None = None def resolved_model(self) -> str: return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider] @@ -40,6 +45,8 @@ def load_config(env: Mapping[str, str] | None = None) -> PlannerConfig: model=values.get(MODEL_ENV) or "", timeout=_parse_timeout(values.get(TIMEOUT_ENV)), thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_ENV)), + api_key=values.get(API_KEY_ENV) or _provider_key(values), + base_url=values.get(BASE_URL_ENV) or None, ) @@ -53,6 +60,8 @@ def _parse_provider(value: str | None) -> str: if value is None: return DEFAULT_PROVIDER provider = value.strip().lower() + if provider in {"openai-compatible", "openai_compatible", "local"}: + return "openai_compatible" return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER @@ -74,3 +83,12 @@ def _parse_thinking_budget(value: str | None) -> int | None: except ValueError: return None return budget if budget > 0 else None + + +def _provider_key(values: Mapping[str, str]) -> str | None: + provider = (values.get(PROVIDER_ENV) or DEFAULT_PROVIDER).strip().lower() + if provider == "openai": + return values.get("OPENAI_API_KEY") or None + if provider == "anthropic": + return values.get("ANTHROPIC_API_KEY") or None + return None diff --git a/runtime/tool_calling_client.py b/runtime/tool_calling_client.py index bdfbe46..d41e808 100644 --- a/runtime/tool_calling_client.py +++ b/runtime/tool_calling_client.py @@ -288,10 +288,14 @@ class OpenAIToolCallingClient: def build_client(config: PlannerConfig) -> ToolCallingClient: model = config.resolved_model() - if config.provider == "openai": - return OpenAIToolCallingClient(model=model) + if config.provider in {"openai", "openai_compatible"}: + return OpenAIToolCallingClient( + model=model, api_key=config.api_key, base_url=config.base_url + ) return AnthropicToolCallingClient( model=model, + api_key=config.api_key, + base_url=config.base_url, thinking_budget_tokens=config.thinking_budget_tokens, )