Add local host mode and configurable LLM providers

This commit is contained in:
showtan001
2026-08-24 08:49:18 +08:00
parent 433ab41f95
commit 050d1329c4
8 changed files with 185 additions and 26 deletions
+20
View File
@@ -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 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. 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 ## Project Direction
The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture
+30 -17
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass, replace
import uvicorn import uvicorn
@@ -22,6 +22,7 @@ from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceLock from host_agent.instance_lock import InstanceLock
from host_agent.lease import ActiveAssignmentRunner from host_agent.lease import ActiveAssignmentRunner
from host_agent.local_account import LocalAccountStore 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_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore from host_agent.mcp_token import McpTokenStore
from host_agent.policy_cache import HostPolicyCacheStore from host_agent.policy_cache import HostPolicyCacheStore
@@ -173,25 +174,37 @@ def create_application(
startup_config.identity_path startup_config.identity_path
) )
owned_enrollment_client = enrollment_client is None owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient( bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
startup_config
)
try: try:
resolved_config = resolve_host_identity( if startup_config.mode == "local":
startup_config, resolved_config = replace(
identity_store=resolved_identity_store, startup_config, control_plane_url="", host_id="local-host"
client=bootstrap_client, )
) resolved_manager = manager or _configured_device_manager(
bootstrap_client.config = resolved_config config_store, config=resolved_config, enrollment_client=None
resolved_manager = manager or _configured_device_manager( )
config_store, else:
config=resolved_config, resolved_config = resolve_host_identity(
enrollment_client=bootstrap_client, 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: finally:
if owned_enrollment_client: if owned_enrollment_client:
bootstrap_client.close() 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( history_store = ConsoleHistoryStore(
resolved_config.identity_path.parent / "host_console_history.sqlite3", resolved_config.identity_path.parent / "host_console_history.sqlite3",
@@ -352,7 +365,7 @@ def _configured_device_manager(
config_store: DeviceConfigStore, config_store: DeviceConfigStore,
*, *,
config: HostAgentConfig, config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient, enrollment_client: HostAgentEnrollmentClient | None,
) -> DeviceManager: ) -> DeviceManager:
manager = DeviceManager() manager = DeviceManager()
for device in config_store.list(): for device in config_store.list():
+19
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import argparse import argparse
import getpass import getpass
import os
import sys import sys
from pathlib import Path
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import replace from dataclasses import replace
@@ -18,6 +20,7 @@ class LocalAccountSetupError(RuntimeError):
def main(argv: Sequence[str] | None = None) -> None: def main(argv: Sequence[str] | None = None) -> None:
_load_dotenv()
parser = argparse.ArgumentParser(description="Run the Device Host Agent") parser = argparse.ArgumentParser(description="Run the Device Host Agent")
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("setup", help="Create the local operator account") subparsers.add_parser("setup", help="Create the local operator account")
@@ -96,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore):
if password != confirm: if password != confirm:
raise LocalAccountSetupError("passwords do not match") raise LocalAccountSetupError("passwords do not match")
return store.create(username, password) 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
+11 -7
View File
@@ -13,6 +13,7 @@ class HostAgentConfigurationError(ValueError):
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) _LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"}) _AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = ( _REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
"HOST_AGENT_RUNTIME_SUPERVISED", "HOST_AGENT_RUNTIME_SUPERVISED",
"HOST_AGENT_RUNTIME_HOST", "HOST_AGENT_RUNTIME_HOST",
@@ -22,7 +23,8 @@ _REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
@dataclass(frozen=True) @dataclass(frozen=True)
class HostAgentConfig: class HostAgentConfig:
control_plane_url: str control_plane_url: str = ""
mode: str = "cloud"
host_id: str = "" host_id: str = ""
token: str = field(default="", repr=False) token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json") identity_path: Path = Path("tasks/host_identity.json")
@@ -57,16 +59,19 @@ def load_host_agent_config(
) -> HostAgentConfig: ) -> HostAgentConfig:
values = os.environ if env is None else env values = os.environ if env is None else env
_reject_removed_runtime_supervision_settings(values) _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 = ( control_plane_url = (
values.get( values.get(
"HOST_AGENT_CONTROL_PLANE_URL", "HOST_AGENT_CONTROL_PLANE_URL",
"https://amcp.home.jerryyan.top", "https://amcp.home.jerryyan.top" if mode == "cloud" else "",
) )
.strip() .strip()
.rstrip("/") .rstrip("/")
) )
parsed_url = urlparse(control_plane_url) 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( raise HostAgentConfigurationError(
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL" "HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
) )
@@ -82,9 +87,10 @@ def load_host_agent_config(
config = HostAgentConfig( config = HostAgentConfig(
control_plane_url=control_plane_url, control_plane_url=control_plane_url,
mode=mode,
identity_path=identity_path, identity_path=identity_path,
local_account_path=local_account_path, local_account_path=local_account_path,
enrollment_managed=True, enrollment_managed=mode == "cloud",
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float( heartbeat_interval_seconds=_positive_float(
values, values,
@@ -128,9 +134,7 @@ def load_host_agent_config(
"HOST_AGENT_CONSOLE_HISTORY_LIMIT", "HOST_AGENT_CONSOLE_HISTORY_LIMIT",
200, 200,
), ),
ai_planner_transport=_parse_ai_planner_transport( ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
values.get("AI_PLANNER_TRANSPORT")
),
dependency_supervisor_enabled=_truthy( dependency_supervisor_enabled=_truthy(
values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False 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" 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: def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
config = load_host_agent_config( config = load_host_agent_config(
{ {
+18
View File
@@ -8,6 +8,7 @@ DEFAULT_PROVIDER = "anthropic"
DEFAULT_MODEL_BY_PROVIDER = { DEFAULT_MODEL_BY_PROVIDER = {
"anthropic": "claude-sonnet-5", "anthropic": "claude-sonnet-5",
"openai": "gpt-5.6", "openai": "gpt-5.6",
"openai_compatible": "local-model",
} }
DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_TIMEOUT_SECONDS = 30.0
@@ -16,6 +17,8 @@ PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL" MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS" TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS" 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) SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@@ -27,6 +30,8 @@ class PlannerConfig:
model: str = "" model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS timeout: float = DEFAULT_TIMEOUT_SECONDS
thinking_budget_tokens: int | None = None thinking_budget_tokens: int | None = None
api_key: str | None = None
base_url: str | None = None
def resolved_model(self) -> str: def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider] 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 "", model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)), timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_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: if value is None:
return DEFAULT_PROVIDER return DEFAULT_PROVIDER
provider = value.strip().lower() 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 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: except ValueError:
return None return None
return budget if budget > 0 else 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
+6 -2
View File
@@ -288,10 +288,14 @@ class OpenAIToolCallingClient:
def build_client(config: PlannerConfig) -> ToolCallingClient: def build_client(config: PlannerConfig) -> ToolCallingClient:
model = config.resolved_model() model = config.resolved_model()
if config.provider == "openai": if config.provider in {"openai", "openai_compatible"}:
return OpenAIToolCallingClient(model=model) return OpenAIToolCallingClient(
model=model, api_key=config.api_key, base_url=config.base_url
)
return AnthropicToolCallingClient( return AnthropicToolCallingClient(
model=model, model=model,
api_key=config.api_key,
base_url=config.base_url,
thinking_budget_tokens=config.thinking_budget_tokens, thinking_budget_tokens=config.thinking_budget_tokens,
) )