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.
81 lines
2.5 KiB
Python
81 lines
2.5 KiB
Python
from __future__ import annotations
|
|
|
|
from cloud.planner_config import (
|
|
DEFAULT_MODEL_BY_PROVIDER,
|
|
DEFAULT_PROVIDER,
|
|
DEFAULT_TIMEOUT_SECONDS,
|
|
CloudPlannerConfig,
|
|
build_cloud_planner_client,
|
|
load_cloud_planner_config,
|
|
)
|
|
from runtime.tool_calling_client import (
|
|
AnthropicToolCallingClient,
|
|
OpenAIToolCallingClient,
|
|
)
|
|
|
|
_NO_RELEVANT_VARS = {"UNRELATED": "1"}
|
|
|
|
|
|
def test_load_cloud_planner_config_defaults_when_unset() -> None:
|
|
config = load_cloud_planner_config(_NO_RELEVANT_VARS)
|
|
|
|
assert config == CloudPlannerConfig(
|
|
provider=DEFAULT_PROVIDER,
|
|
model="",
|
|
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
)
|
|
assert config.resolved_model() == DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER]
|
|
|
|
|
|
def test_load_cloud_planner_config_selects_provider_and_resolves_default_model() -> (
|
|
None
|
|
):
|
|
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "openai"})
|
|
|
|
assert config.provider == "openai"
|
|
assert config.resolved_model() == "gpt-5.6"
|
|
|
|
|
|
def test_load_cloud_planner_config_falls_back_to_default_provider_when_unsupported() -> (
|
|
None
|
|
):
|
|
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "not-a-real-provider"})
|
|
|
|
assert config.provider == DEFAULT_PROVIDER
|
|
|
|
|
|
def test_load_cloud_planner_config_model_override_wins_regardless_of_provider() -> None:
|
|
config = load_cloud_planner_config(
|
|
{"AI_PLANNER_PROVIDER": "openai", "AI_PLANNER_MODEL": "custom-model"}
|
|
)
|
|
|
|
assert config.resolved_model() == "custom-model"
|
|
|
|
|
|
def test_load_cloud_planner_config_parses_valid_timeout() -> None:
|
|
config = load_cloud_planner_config({"AI_PLANNER_TIMEOUT_SECONDS": "12.5"})
|
|
|
|
assert config.timeout == 12.5
|
|
|
|
|
|
def test_load_cloud_planner_config_falls_back_to_default_timeout_when_invalid() -> None:
|
|
for value in ["not-a-number", "0", "-5"]:
|
|
config = load_cloud_planner_config(
|
|
{"AI_PLANNER_TIMEOUT_SECONDS": value, **_NO_RELEVANT_VARS}
|
|
)
|
|
assert config.timeout == DEFAULT_TIMEOUT_SECONDS
|
|
|
|
|
|
def test_build_cloud_planner_client_selects_anthropic_by_default() -> None:
|
|
client = build_cloud_planner_client(CloudPlannerConfig())
|
|
|
|
assert isinstance(client, AnthropicToolCallingClient)
|
|
assert client.model == DEFAULT_MODEL_BY_PROVIDER["anthropic"]
|
|
|
|
|
|
def test_build_cloud_planner_client_selects_openai() -> None:
|
|
client = build_cloud_planner_client(CloudPlannerConfig(provider="openai"))
|
|
|
|
assert isinstance(client, OpenAIToolCallingClient)
|
|
assert client.model == DEFAULT_MODEL_BY_PROVIDER["openai"]
|