Add local host mode and configurable LLM providers
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user