Implement cloud-planner-proxy: AI planner routes through Cloud API
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.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.store import CloudStore
|
||||
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
|
||||
from runtime.tool_specs import ToolSpec
|
||||
|
||||
|
||||
class _FakeToolCallingClient:
|
||||
"""Stand-in ``ToolCallingClient`` for endpoint tests -- never calls a real
|
||||
LLM provider."""
|
||||
|
||||
def __init__(
|
||||
self, decision: ToolCallDecision | None = None, error: str | None = None
|
||||
):
|
||||
self.decision = decision
|
||||
self.error = error
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
screenshot: bytes | None,
|
||||
tools: list[ToolSpec],
|
||||
timeout: float,
|
||||
) -> ToolCallDecision:
|
||||
self.calls.append(
|
||||
{
|
||||
"system_prompt": system_prompt,
|
||||
"user_prompt": user_prompt,
|
||||
"screenshot": screenshot,
|
||||
"tools": tools,
|
||||
"timeout": timeout,
|
||||
}
|
||||
)
|
||||
if self.error is not None:
|
||||
raise ToolCallUnavailable(self.error)
|
||||
assert self.decision is not None
|
||||
return self.decision
|
||||
|
||||
|
||||
def _build_client(
|
||||
tmp_path, *, fake_client: _FakeToolCallingClient
|
||||
) -> tuple[TestClient, _FakeToolCallingClient]:
|
||||
pool = DevicePool(
|
||||
CloudStore(tmp_path / "internal.sqlite3"),
|
||||
CloudConfig(stale_after_seconds=60),
|
||||
)
|
||||
auth_provider = ConfiguredBearerAuthProvider(
|
||||
[
|
||||
BearerCredential(principal_id="agent-a", token="token-a", host_id="host-a"),
|
||||
BearerCredential(principal_id="agent-b", token="token-b", host_id="host-b"),
|
||||
]
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_internal_router(
|
||||
pool=pool,
|
||||
auth_provider=auth_provider,
|
||||
planner_client_factory=lambda: fake_client,
|
||||
)
|
||||
)
|
||||
return TestClient(app), fake_client
|
||||
|
||||
|
||||
def _decision_payload(**overrides: object) -> dict[str, object]:
|
||||
payload = {
|
||||
"host_id": "host-a",
|
||||
"system_prompt": "you are a planner",
|
||||
"user_prompt": "tap the login button",
|
||||
"screenshot_base64": None,
|
||||
"tools": [
|
||||
{
|
||||
"name": "tap",
|
||||
"description": "tap an element",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
"timeout_seconds": 30.0,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"tool_name": "tap", "arguments": {"x": 1, "y": 2}}
|
||||
assert len(fake_client.calls) == 1
|
||||
assert fake_client.calls[0]["system_prompt"] == "you are a planner"
|
||||
assert fake_client.calls[0]["timeout"] == 30.0
|
||||
|
||||
|
||||
def test_planner_decision_decodes_screenshot_base64(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(screenshot_base64="aGVsbG8="),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fake_client.calls[0]["screenshot"] == b"hello"
|
||||
|
||||
|
||||
def test_invalid_screenshot_base64_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(screenshot_base64="not-valid-base64!!"),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert fake_client.calls == []
|
||||
|
||||
|
||||
def test_unauthenticated_request_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
json=_decision_payload(),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert fake_client.calls == []
|
||||
|
||||
|
||||
def test_foreign_host_token_cannot_request_another_hosts_decision(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-b"},
|
||||
json=_decision_payload(),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert fake_client.calls == []
|
||||
|
||||
|
||||
def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(host_id="host-b"),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert fake_client.calls == []
|
||||
|
||||
|
||||
def test_provider_failure_returns_structured_error_without_crashing(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(error="anthropic timed out")
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {
|
||||
"code": "planner_unavailable",
|
||||
"detail": "anthropic timed out",
|
||||
}
|
||||
Reference in New Issue
Block a user