Implements all 19 tasks of the cloud-planner-proxy OpenSpec change:
- Cloud API: cloud.planner_config (CloudPlannerConfig, load/build helpers)
reusing runtime.tool_calling_client provider clients (no new dependency
needed -- device-cloud-platform already depends on device-agent-runtime).
- Cloud API: new host-scoped POST /internal/v1/hosts/{host_id}/planner/decide
internal endpoint, reusing existing bearer auth; logs only metadata
(host id, tool name, latency, error class), never prompt/screenshot
content.
- Host Agent: new AI_PLANNER_TRANSPORT config (direct default | cloud) and
host_agent/cloud_planner_client.py::CloudProxyToolCallingClient, a
synchronous ToolCallingClient implementation (structural, not importing
runtime) that calls the new endpoint via its own httpx.Client -- avoids
bridging the async HostAgentClient across the worker-thread boundary
that AIPlanner.plan() runs in (asyncio.to_thread in lease.py).
- Host Agent wiring: create_execution_factories()/_host_agent_planner()
select the cloud-proxy client only when AI_PLANNER_TRANSPORT=cloud;
direct/unset transport is unchanged (still the default).
- Tests: 22 new tests across Cloud API config, the new endpoint, the new
client, and transport-selection wiring; full non-integration suite
(492 tests) passes with no regressions.
- Docs: docs/CLOUD_DEPLOYMENT.md documents the cloud transport, its
trade-offs, and the credential split between Host Agent and Cloud API.
proposal.md/design.md were corrected during implementation to reflect two
findings: no new anthropic/openai dependency is actually needed, and
CloudProxyToolCallingClient uses its own sync httpx.Client rather than a
new HostAgentClient method, per the thread-boundary reasoning above.
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, replace
|
|
|
|
from device.manager import DeviceManager
|
|
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
|
|
from host_agent.config import HostAgentConfig, load_host_agent_config
|
|
from runtime.ai_planner import AIPlanner
|
|
from runtime.executor import Executor, default_tool_registry
|
|
from runtime.planner import Planner
|
|
from runtime.planner_config import PlannerConfig, load_config as load_planner_config
|
|
from runtime.task import TaskRunner
|
|
from storage.task_metadata import TaskMetadataStore
|
|
from storage.timeline import Timeline
|
|
from workflow.runner import WorkflowRunner
|
|
from workflow.store import WorkflowStore
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecutionFactories:
|
|
task_runner_factory: Callable[[], TaskRunner]
|
|
workflow_runner_factory: Callable[[], WorkflowRunner]
|
|
workflow_store: WorkflowStore
|
|
|
|
|
|
def create_execution_factories(
|
|
manager: DeviceManager,
|
|
*,
|
|
workflow_store: WorkflowStore | None = None,
|
|
metadata_store: TaskMetadataStore | None = None,
|
|
timeline: Timeline | None = None,
|
|
host_agent_config: HostAgentConfig | None = None,
|
|
) -> ExecutionFactories:
|
|
shared_workflow_store = workflow_store or WorkflowStore()
|
|
resolved_host_agent_config = host_agent_config
|
|
|
|
def create_task_runner() -> TaskRunner:
|
|
return TaskRunner(
|
|
executor=Executor(tools=default_tool_registry(manager=manager)),
|
|
metadata_store=metadata_store,
|
|
timeline=timeline,
|
|
planner=_host_agent_planner(resolved_host_agent_config),
|
|
planner_config=_host_agent_planner_config(),
|
|
)
|
|
|
|
def create_workflow_runner() -> WorkflowRunner:
|
|
return WorkflowRunner(
|
|
shared_workflow_store,
|
|
task_runner_factory=create_task_runner,
|
|
)
|
|
|
|
return ExecutionFactories(
|
|
task_runner_factory=create_task_runner,
|
|
workflow_runner_factory=create_workflow_runner,
|
|
workflow_store=shared_workflow_store,
|
|
)
|
|
|
|
|
|
def _host_agent_planner_config() -> PlannerConfig:
|
|
"""Host Agent defaults to the AI planner unless an operator opts out.
|
|
|
|
`runtime.planner_config` defaults `enabled=False` for the shared Runtime
|
|
library (local dev/tests/cloud dispatcher keep the deterministic stub
|
|
planner unless asked). The Host Agent is the actual device-control path,
|
|
so it flips that default on here -- an explicit `AI_PLANNER_ENABLED=false`
|
|
still disables it.
|
|
"""
|
|
config = load_planner_config()
|
|
if os.environ.get("AI_PLANNER_ENABLED") is None:
|
|
config = replace(config, enabled=True)
|
|
return config
|
|
|
|
|
|
def _host_agent_planner(
|
|
host_agent_config: HostAgentConfig | None,
|
|
) -> Planner | None:
|
|
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is
|
|
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
|
|
instead of a local Anthropic/OpenAI SDK client.
|
|
|
|
Returns `None` (letting `TaskRunner` fall back to its own
|
|
`_default_planner()`) for the `direct` transport, which preserves the
|
|
existing default-enabled/direct-to-provider behavior unchanged.
|
|
"""
|
|
planner_config = _host_agent_planner_config()
|
|
if not planner_config.enabled:
|
|
return None
|
|
|
|
resolved_config = host_agent_config or load_host_agent_config()
|
|
if resolved_config.ai_planner_transport != "cloud":
|
|
return None
|
|
|
|
return AIPlanner(
|
|
client=CloudProxyToolCallingClient(resolved_config),
|
|
config=planner_config,
|
|
)
|