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:
2026-07-13 21:27:48 +08:00
parent 1107ace89c
commit a68f609453
15 changed files with 1009 additions and 60 deletions
@@ -0,0 +1,80 @@
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"]
+6 -1
View File
@@ -196,7 +196,12 @@ def create_application(
else None
),
)
executor = AssignmentExecutor(create_execution_factories(resolved_manager))
executor = AssignmentExecutor(
create_execution_factories(
resolved_manager,
host_agent_config=resolved_config,
)
)
active_runner = ActiveAssignmentRunner(client, executor)
processor = AssignmentProcessor(
client,
@@ -0,0 +1,109 @@
"""``ToolCallingClient`` implementation that proxies AI Planner decisions
through the Cloud Control Plane instead of calling an LLM provider directly.
Lives in ``host_agent``, not ``runtime`` (design decision D3): the shared
Runtime package's boundary test
(``apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns``)
forbids ``runtime`` from importing ``cloud`` or ``host_agent``, so this class
satisfies ``runtime.tool_calling_client.ToolCallingClient`` structurally
from outside that package instead of living inside it.
Uses its own synchronous ``httpx.Client`` (mirroring
``HostAgentEnrollmentClient``'s pattern) rather than wrapping the async
``HostAgentClient``: ``ToolCallingClient.decide()`` is a synchronous Protocol
method invoked from a worker thread via ``asyncio.to_thread`` (see
``host_agent/lease.py``), off the main event loop, so reusing an
``httpx.AsyncClient`` bound to that loop would require event-loop bridging
for no real benefit over a plain synchronous client with the same
host-scoped bearer auth.
"""
from __future__ import annotations
import base64
from typing import Any
import httpx
from cloud.internal_api.models import PlannerDecisionError, PlannerDecisionResponse
from host_agent.config import HostAgentConfig
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
from runtime.tool_specs import ToolSpec
class CloudProxyToolCallingClient:
def __init__(
self,
config: HostAgentConfig,
*,
http_client: httpx.Client | None = None,
) -> None:
self.config = config
self._owns_client = http_client is None
self._client = http_client or httpx.Client(base_url=config.control_plane_url)
def decide(
self,
*,
system_prompt: str,
user_prompt: str,
screenshot: bytes | None,
tools: list[ToolSpec],
timeout: float,
) -> ToolCallDecision:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"screenshot_base64": (
base64.b64encode(screenshot).decode("ascii")
if screenshot is not None
else None
),
"tools": [
{
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
}
for spec in tools
],
"timeout_seconds": timeout,
}
try:
response = self._client.post(
f"/internal/v1/hosts/{self.config.host_id}/planner/decide",
json=payload,
headers={"Authorization": f"Bearer {self.config.token}"},
timeout=timeout + 5,
)
except httpx.HTTPError as exc:
raise ToolCallUnavailable(str(exc)) from exc
if response.is_success:
decoded = PlannerDecisionResponse.model_validate(response.json())
return ToolCallDecision(
tool_name=decoded.tool_name,
arguments=dict(decoded.arguments),
)
raise ToolCallUnavailable(_error_detail(response))
def close(self) -> None:
if self._owns_client:
self._client.close()
def _error_detail(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return f"planner-decision request failed with status {response.status_code}"
try:
error = PlannerDecisionError.model_validate(payload)
except Exception:
detail = payload.get("detail") if isinstance(payload, dict) else None
return (
detail
or f"planner-decision request failed with status {response.status_code}"
)
return error.detail
@@ -12,6 +12,7 @@ class HostAgentConfigurationError(ValueError):
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
@dataclass(frozen=True)
@@ -34,6 +35,7 @@ class HostAgentConfig:
console_allow_non_loopback: bool = False
console_session_ttl_seconds: float = 43200.0
console_history_limit: int = 200
ai_planner_transport: str = "direct"
def load_host_agent_config(
@@ -112,6 +114,9 @@ def load_host_agent_config(
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
200,
),
ai_planner_transport=_parse_ai_planner_transport(
values.get("AI_PLANNER_TRANSPORT")
),
)
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
raise HostAgentConfigurationError(
@@ -129,6 +134,18 @@ def load_host_agent_config(
return config
def _parse_ai_planner_transport(value: str | None) -> str:
if value is None:
return "direct"
transport = value.strip().lower()
if transport not in _AI_PLANNER_TRANSPORTS:
raise HostAgentConfigurationError(
"AI_PLANNER_TRANSPORT must be one of "
f"{', '.join(sorted(_AI_PLANNER_TRANSPORTS))}"
)
return transport
def _positive_float(
values: Mapping[str, str],
name: str,
@@ -5,7 +5,11 @@ 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
@@ -27,14 +31,17 @@ def create_execution_factories(
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(),
)
@@ -64,3 +71,28 @@ def _host_agent_planner_config() -> PlannerConfig:
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,
)
@@ -0,0 +1,152 @@
from __future__ import annotations
import json
import httpx
import pytest
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
from host_agent.config import HostAgentConfig
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
from runtime.tool_specs import ToolSpec
_CONFIG = HostAgentConfig(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
)
_TOOLS = [ToolSpec(name="tap", description="tap an element", parameters={})]
def _client(handler) -> CloudProxyToolCallingClient:
transport = httpx.MockTransport(handler)
http_client = httpx.Client(base_url=_CONFIG.control_plane_url, transport=transport)
return CloudProxyToolCallingClient(_CONFIG, http_client=http_client)
def test_decide_returns_tool_call_decision_on_success() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(
200,
json={"tool_name": "tap", "arguments": {"x": 1, "y": 2}},
)
client = _client(handler)
decision = client.decide(
system_prompt="you are a planner",
user_prompt="tap login",
screenshot=None,
tools=_TOOLS,
timeout=30.0,
)
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
assert len(seen_requests) == 1
request = seen_requests[0]
assert request.url.path == "/internal/v1/hosts/host-a/planner/decide"
assert request.headers["Authorization"] == "Bearer token-a"
body = json.loads(request.content)
assert body["host_id"] == "host-a"
assert body["system_prompt"] == "you are a planner"
assert body["screenshot_base64"] is None
assert body["timeout_seconds"] == 30.0
def test_decide_base64_encodes_screenshot() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=b"hello",
tools=_TOOLS,
timeout=10.0,
)
body = json.loads(seen_requests[0].content)
assert body["screenshot_base64"] == "aGVsbG8="
def test_decide_raises_tool_call_unavailable_on_network_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
client = _client(handler)
with pytest.raises(ToolCallUnavailable):
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=5.0,
)
def test_decide_raises_tool_call_unavailable_on_structured_error_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
502,
json={"code": "planner_unavailable", "detail": "provider timed out"},
)
client = _client(handler)
with pytest.raises(ToolCallUnavailable, match="provider timed out"):
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=5.0,
)
def test_decide_raises_tool_call_unavailable_on_unstructured_error_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal server error")
client = _client(handler)
with pytest.raises(ToolCallUnavailable):
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=5.0,
)
def test_close_closes_an_internally_constructed_http_client() -> None:
client = CloudProxyToolCallingClient(_CONFIG)
client.close()
assert client._client.is_closed
def test_close_leaves_an_externally_supplied_http_client_open() -> None:
"""Only a client built internally (no ``http_client`` override) is closed
by ``close()``; a caller-supplied ``http_client`` is left open for the
caller to manage."""
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
external_http_client = httpx.Client(
base_url=_CONFIG.control_plane_url, transport=transport
)
client = CloudProxyToolCallingClient(_CONFIG, http_client=external_http_client)
client.close()
assert not external_http_client.is_closed
external_http_client.close()
@@ -5,6 +5,8 @@ from pathlib import Path
import pytest
from device.manager import DeviceManager
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
from host_agent.config import HostAgentConfig
from host_agent.execution import create_execution_factories
from runtime.ai_planner import AIPlanner
from runtime.planner import Planner
@@ -58,6 +60,54 @@ def test_created_task_runner_honors_explicit_ai_planner_opt_out(
assert type(task_runner.planner) is Planner
def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
host_agent_config = HostAgentConfig(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
ai_planner_transport="cloud",
)
factories = create_execution_factories(
manager,
workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3"),
host_agent_config=host_agent_config,
)
task_runner = factories.task_runner_factory()
assert isinstance(task_runner.planner, AIPlanner)
assert isinstance(task_runner.planner.client, CloudProxyToolCallingClient)
assert task_runner.planner.client.config is host_agent_config
@pytest.mark.parametrize("transport", [None, "direct"])
def test_direct_transport_preserves_existing_local_provider_construction(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
host_agent_config = HostAgentConfig(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
**({} if transport is None else {"ai_planner_transport": transport}),
)
factories = create_execution_factories(
manager,
workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3"),
host_agent_config=host_agent_config,
)
task_runner = factories.task_runner_factory()
assert isinstance(task_runner.planner, AIPlanner)
assert not isinstance(task_runner.planner.client, CloudProxyToolCallingClient)
def test_runtime_owned_packages_do_not_import_host_or_cloud_concerns() -> None:
root = Path(__file__).resolve().parents[3]
forbidden = ("import cloud", "from cloud", "import host_agent", "from host_agent")
+30 -2
View File
@@ -365,8 +365,36 @@ fails on its first step (no silent fallback to the stub planner). Set
(e.g. for offline/dev hosts with no provider credentials).
For OpenAI, set `AI_PLANNER_PROVIDER=openai`, choose the deployed model through
`AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. Provider credentials belong
only on the Host Agent; the Cloud API does not need them.
`AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. This is the **`direct`
transport** (the default): the Host Agent holds provider credentials and
calls Anthropic/OpenAI itself.
### Cloud-proxy transport (`AI_PLANNER_TRANSPORT=cloud`)
Set `AI_PLANNER_TRANSPORT=cloud` on the Host Agent to instead route every
planning decision through the Cloud API's
`POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
mode:
- **Credentials move to the Cloud API.** Configure `AI_PLANNER_PROVIDER`,
`AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`, and
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY` on the Cloud API process instead of the
Host Agent -- edge hosts no longer need provider keys at all.
- **Trade-offs to accept before enabling:**
- *Latency*: every planning step now makes a round trip to the Cloud API in
addition to the LLM provider call.
- *Availability coupling*: unlike heartbeat/claim (which tolerate transient
Cloud API outages via retry/backoff), a planning step fails immediately if
the Cloud API or its configured provider is unreachable -- there is no
fallback to the stub planner or to a local direct call.
- *Expanded data path*: goal/scene prompts and screenshots now transit the
Cloud API. The endpoint logs only metadata (host id, resolved tool name,
latency, error class) and never prompt text or screenshot bytes, but the
request bodies themselves do cross the network to the control plane.
`AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing
direct-to-provider behavior with no change.
## Operational Limitations
+30 -12
View File
@@ -91,15 +91,29 @@ reason about ("provider" would mean different things on each side).
### D3: `CloudProxyToolCallingClient` lives in `host_agent`, not `runtime`
`runtime/tool_calling_client.py`'s `ToolCallingClient` stays a plain
`Protocol`; the new client is added in `apps/device-host-agent/host_agent/`
(e.g. `host_agent/cloud_planner_client.py`) and satisfies that Protocol
(`host_agent/cloud_planner_client.py`) and satisfies that Protocol
structurally. This preserves the existing boundary enforced by
`apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`,
which forbids `runtime` (and `core`/`device`/`driver`/`tools`) from
importing `cloud` or `host_agent`. The new client wraps a new method on the
existing `host_agent/client.py::HostAgentClient` (which already holds the
authenticated `httpx` session and imports `cloud.internal_api.models`), so
it reuses the same request/auth/retry plumbing as heartbeat/claim/renew/result
instead of opening a second HTTP client type.
importing `cloud` or `host_agent`.
Implementation note (revised during implementation from the original plan
of wrapping `HostAgentClient`): `ToolCallingClient.decide()` is a
**synchronous** Protocol method, and `AIPlanner.plan()` -> `TaskRunner`'s
step loop runs inside a worker thread spawned via `asyncio.to_thread` (see
`host_agent/lease.py`'s `ActiveAssignmentRunner`), off the main event loop.
`HostAgentClient` holds an `httpx.AsyncClient` bound to that main loop, so
calling it from the worker thread would require event-loop bridging
(`asyncio.run_coroutine_threadsafe` or similar) for no benefit over a
simpler alternative. Instead, `CloudProxyToolCallingClient` holds its own
synchronous `httpx.Client`, mirroring the existing
`host_agent/client.py::HostAgentEnrollmentClient` pattern (same
`Authorization: Bearer` header construction, same base URL from
`HostAgentConfig`), rather than reusing `HostAgentClient`'s async session.
It still imports request/response models directly from
`cloud.internal_api.models` -- no duplicated schemas -- so the "no new
wire-format definitions" intent of D1 is preserved even though the HTTP
transport itself isn't literally shared with `HostAgentClient`.
### D4: Reuse the existing host-scoped bearer credential; no new auth scope
The new endpoint sits on the same internal router and auth dependency as
@@ -157,12 +171,16 @@ proposal does not change that risk profile, only where the call happens.
Control Plane** -> Mitigation: D6 (no durable persistence); still an
expansion of the data path operators should account for versus
direct-to-provider, which never touches the cloud.
- **[Risk] Cloud API gains a new dependency on `anthropic`/`openai` SDKs
and becomes a second place holding provider credentials** -> Mitigation:
reusing `runtime.tool_calling_client` (D1) keeps this to configuration
and routing, not new provider-integration code; credential handling
follows the same "environment or secret manager" pattern already
documented for the Host Agent in `docs/CLOUD_DEPLOYMENT.md`.
- **[Risk] Cloud API becomes a second place holding provider credentials**
-> Mitigation: reusing `runtime.tool_calling_client` (D1) keeps this to
configuration and routing, not new provider-integration code; credential
handling follows the same "environment or secret manager" pattern already
documented for the Host Agent in `docs/CLOUD_DEPLOYMENT.md`. Note: this is
not a *new* package-dependency footprint -- `device-cloud-platform`
already depends unconditionally on `device-agent-runtime`, which declares
`anthropic`/`openai`, so both SDKs are already installed wherever the
Cloud API runs today (verified with `uv run`); only the credentials
themselves are new.
- **[Risk] Any authenticated host can drive cloud-held LLM spend** (D4) ->
Mitigation: none in this proposal beyond existing per-host authentication;
flagged as an Open Question rather than silently accepted, since it's a
@@ -54,15 +54,20 @@ provider/model or rotate a key without touching any Host.
## Impact
- `packages/cloud-platform/cloud` / `apps/cloud-api`: new internal API
route + request/response models, new provider/credential configuration,
and a new direct dependency on the `anthropic`/`openai` SDKs (currently
only the root Runtime package depends on them -- this is new dependency
and attack surface for the Cloud API).
- `apps/device-host-agent`: new `HostAgentClient` method for the
planner-decide call, a new `ToolCallingClient` implementation
(constructed in the `host_agent` package, not `runtime`, to preserve the
route + request/response models, new provider/credential configuration.
No new package dependency: `device-cloud-platform` already depends
unconditionally on `device-agent-runtime` (which declares `anthropic`/
`openai`), so both SDKs are already present wherever the Cloud API runs --
confirmed by `uv run python -c "import anthropic, openai"` succeeding in
the workspace venv without any pyproject change.
- `apps/device-host-agent`: a new `ToolCallingClient` implementation
(`host_agent/cloud_planner_client.py::CloudProxyToolCallingClient`,
constructed in the `host_agent` package, not `runtime`, to preserve the
existing hexagonal boundary that forbids `runtime` from importing `cloud`
or `host_agent`), and a new transport configuration setting.
or `host_agent`) with its own synchronous `httpx.Client` (matching
`HostAgentEnrollmentClient`'s pattern, since `ToolCallingClient.decide()`
runs synchronously off the main event loop), and a new transport
configuration setting (`AI_PLANNER_TRANSPORT`).
- `runtime/tool_calling_client.py`: no changes to the `ToolCallingClient`
Protocol itself; the new implementation satisfies it structurally from
outside the `runtime` package.
+83 -37
View File
@@ -1,85 +1,124 @@
## 1. Cloud API: planner configuration
- [ ] 1.1 Add Cloud API-side planner configuration (provider, model, timeout,
- [x] 1.1 Add Cloud API-side planner configuration (provider, model, timeout,
provider API keys) analogous to `runtime/planner_config.py`, loaded
from the Cloud API's own process environment (e.g.
`AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/`AI_PLANNER_TIMEOUT_SECONDS`,
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY`), living in
`packages/cloud-platform/cloud` or `apps/cloud-api`.
- [ ] 1.2 Add `anthropic`/`openai` as explicit dependencies of the package
that hosts this configuration (confirm whether `packages/cloud-platform`
or `apps/cloud-api` is the right home, matching where the new endpoint
handler will live).
Done: `packages/cloud-platform/cloud/planner_config.py`
(`CloudPlannerConfig`/`load_cloud_planner_config`/
`build_cloud_planner_client`, reusing `runtime.tool_calling_client`
per D1).
- [x] 1.2 ~~Add `anthropic`/`openai` as explicit dependencies~~ -- not
needed: `packages/cloud-platform` (`device-cloud-platform`) already
depends unconditionally on `device-agent-runtime`, which declares
both SDKs, so they are already installed wherever the Cloud API
runs. Verified with `uv run python -c "import anthropic, openai"`
in the workspace venv.
## 2. Cloud API: planner-decision internal endpoint
- [ ] 2.1 Add request/response Pydantic models to `cloud.internal_api.models`
- [x] 2.1 Add request/response Pydantic models to `cloud.internal_api.models`
for a planner-decision call: request carries `system_prompt`,
`user_prompt`, optional base64 screenshot, tool specs, timeout;
response carries resolved `tool_name`/`arguments` or a structured
error.
- [ ] 2.2 Add the internal route (e.g. `POST /internal/v1/planner/decide`)
to the existing internal router, reusing the current host-scoped
bearer auth dependency used by heartbeat/claim/renew/result -- no new
scope.
- [ ] 2.3 Implement the route handler by constructing
`runtime.tool_calling_client.AnthropicToolCallingClient` or
`OpenAIToolCallingClient` (per D1) from the Cloud API's own planner
configuration, calling `.decide(...)`, and translating
`ToolCallDecision`/`ToolCallUnavailable` into the response model.
- [ ] 2.4 Ensure the handler does not log or persist raw prompt text or
Done: `PlannerDecisionRequest`/`PlannerDecisionResponse`/
`PlannerDecisionError`/`PlannerToolSpecModel`.
- [x] 2.2 Add the internal route (`POST
/internal/v1/hosts/{host_id}/planner/decide`) to the existing
internal router, reusing the current host-scoped bearer auth
dependency used by heartbeat/claim/renew/result -- no new scope.
(Host-scoped, like renew/result, rather than un-scoped, so a
request's `host_id` is verified against the caller's own principal.)
- [x] 2.3 Implement the route handler by constructing a
`runtime.tool_calling_client.ToolCallingClient` (per D1, via
`cloud.planner_config.build_cloud_planner_client`) from the Cloud
API's own planner configuration, calling `.decide(...)`, and
translating `ToolCallDecision`/`ToolCallUnavailable` into the
response model (502 + `PlannerDecisionError` on failure).
- [x] 2.4 Ensure the handler does not log or persist raw prompt text or
screenshot bytes; only metadata (host id, resolved tool name,
latency, error class) may be logged.
latency, error class) may be logged. Done: handler's `logger.info`
calls only ever include `host_id`/`tool_name`/`latency_seconds`/
`error_class`.
## 3. Host Agent: cloud-proxy transport
- [ ] 3.1 Add `AI_PLANNER_TRANSPORT` (`direct` default | `cloud`) to
- [x] 3.1 Add `AI_PLANNER_TRANSPORT` (`direct` default | `cloud`) to
`apps/device-host-agent/host_agent/config.py`.
- [ ] 3.2 Add a `request_planner_decision(...)` method to
`host_agent/client.py::HostAgentClient` that calls the new Cloud API
endpoint using the existing authenticated `httpx` session, importing
the new request/response models from `cloud.internal_api.models`.
- [ ] 3.3 Add `host_agent/cloud_planner_client.py::CloudProxyToolCallingClient`
Done: `HostAgentConfig.ai_planner_transport` +
`_parse_ai_planner_transport`.
- [x] 3.2 ~~Add a `request_planner_decision(...)` method to
`HostAgentClient`~~ -- revised during implementation (see design.md
D3 implementation note): `ToolCallingClient.decide()` is synchronous
and runs off the main event loop via `asyncio.to_thread`
(`host_agent/lease.py`), so wrapping the async `HostAgentClient`
would need event-loop bridging. Skipped in favor of 3.3's own
synchronous `httpx.Client`, mirroring
`HostAgentEnrollmentClient`'s pattern.
- [x] 3.3 Add `host_agent/cloud_planner_client.py::CloudProxyToolCallingClient`
implementing the `runtime.tool_calling_client.ToolCallingClient`
Protocol structurally (no import of `host_agent`/`cloud` from
`runtime`), wrapping `HostAgentClient.request_planner_decision(...)`
and raising `ToolCallUnavailable` on any failure (network error, auth
rejection, non-2xx, provider error, timeout) -- matching D7.
`runtime`), calling the planner-decide endpoint via its own
synchronous `httpx.Client` (host-scoped bearer auth, same as
`HostAgentEnrollmentClient`) and raising `ToolCallUnavailable` on any
failure (network error, non-2xx/error response) -- matching D7.
## 4. Host Agent: wiring
- [ ] 4.1 Update `apps/device-host-agent/host_agent/execution.py`'s
- [x] 4.1 Update `apps/device-host-agent/host_agent/execution.py`'s
`_host_agent_planner_config()`/`create_task_runner()` so that when
`AI_PLANNER_TRANSPORT=cloud`, the constructed `TaskRunner`'s
`AIPlanner` is built with a `CloudProxyToolCallingClient` instead of
the default local provider client, while leaving the existing
default-enabled/direct-transport behavior unchanged when
`AI_PLANNER_TRANSPORT` is unset or `direct`.
- [ ] 4.2 Confirm `apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`-style
boundary checks still pass with the new module in place.
Done: new `_host_agent_planner()` helper + `host_agent_config` param
threaded through `create_execution_factories()` from
`app.py::create_application()`. Manually verified both transports
construct the expected client.
- [x] 4.2 Confirm `apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`-style
boundary checks still pass with the new module in place. Confirmed:
`pytest tests/test_execution.py` (4 passed), boundary test
specifically re-run and green.
## 5. Tests
- [ ] 5.1 Unit tests for Cloud API planner configuration loading
- [x] 5.1 Unit tests for Cloud API planner configuration loading
(defaults, provider/model/timeout parsing) mirroring
`tests/test_planner_config.py`.
- [ ] 5.2 Unit tests for the planner-decision endpoint: authorized request
Done: `apps/cloud-api/tests/test_cloud_planner_config.py` (8 tests,
all passing).
- [x] 5.2 Unit tests for the planner-decision endpoint: authorized request
resolves a decision, unauthenticated/foreign-host request is
rejected, provider failure returns a structured error without
crashing.
- [ ] 5.3 Unit tests for `CloudProxyToolCallingClient`: successful decision
Done: `tests/test_cloud_planner_decision_endpoint.py` (7 tests --
success, screenshot decoding, invalid base64, unauthenticated,
foreign-host, host_id mismatch, provider failure -- via injected
fake `planner_client_factory`, mirroring
`tests/test_host_agent_internal_api.py`'s router-level pattern).
- [x] 5.3 Unit tests for `CloudProxyToolCallingClient`: successful decision
round-trip, and each failure mode raises `ToolCallUnavailable`.
- [ ] 5.4 Unit tests for Host Agent wiring: `AI_PLANNER_TRANSPORT=cloud`
Done: `apps/device-host-agent/tests/test_cloud_planner_client.py`
(7 tests, using `httpx.MockTransport` -- success, screenshot
base64-encoding, network error, structured 502 error, unstructured
error response, and client-ownership on `close()`).
- [x] 5.4 Unit tests for Host Agent wiring: `AI_PLANNER_TRANSPORT=cloud`
constructs an `AIPlanner` using `CloudProxyToolCallingClient`;
`AI_PLANNER_TRANSPORT` unset or `direct` preserves existing
direct-to-provider construction (no regression to the
already-implemented default-enabled behavior).
- [ ] 5.5 Full non-integration suite (`uv run --all-packages pytest -m
Done: added to `apps/device-host-agent/tests/test_execution.py`
(2 new tests, one parametrized over `None`/`"direct"`).
- [x] 5.5 Full non-integration suite (`uv run --all-packages pytest -m
"not integration"`) passes with no regressions.
## 6. Documentation
- [ ] 6.1 Update `docs/CLOUD_DEPLOYMENT.md`'s Runtime AI Planner section
- [x] 6.1 Update `docs/CLOUD_DEPLOYMENT.md`'s Runtime AI Planner section
with the `cloud` transport configuration path, its trade-offs
(latency, cloud-availability coupling, expanded data path for
screenshots/prompts), and the credential split (Cloud API holds
@@ -88,5 +127,12 @@
## 7. Validation
- [ ] 7.1 `openspec validate --strict` passes for this change.
- [ ] 7.2 Ruff check/format and `compileall` pass for all touched packages.
- [x] 7.1 `openspec validate --strict` passes for this change.
Done: `openspec validate cloud-planner-proxy --strict` -> "Change
'cloud-planner-proxy' is valid".
- [x] 7.2 Ruff check/format and `compileall` pass for all touched packages.
Done: `ruff check` clean; `ruff format` applied to 5 files (import
wrapping/line-length only, no logic changes) and re-verified via the
full non-integration suite (492 passed); `python -m compileall` clean
for `packages/cloud-platform/cloud`, `apps/cloud-api`,
`apps/device-host-agent`.
@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
import base64
import logging
from collections.abc import Awaitable, Callable
from datetime import timedelta
from time import monotonic
@@ -27,19 +29,27 @@ from cloud.internal_api.models import (
HostEnrollmentResponse,
LeaseRenewalRequest,
LeaseRenewalResponse,
PlannerDecisionError,
PlannerDecisionRequest,
PlannerDecisionResponse,
StaleLeaseConflict,
TerminalResultRequest,
TerminalResultResponse,
)
from cloud.planner_config import build_cloud_planner_client, load_cloud_planner_config
from cloud.repository import (
DeviceEnrollmentConflictError,
HostEnrollmentConflictError,
)
from core.models import Device, utc_now
from runtime.tool_calling_client import ToolCallingClient, ToolCallUnavailable
from runtime.tool_specs import ToolSpec
if TYPE_CHECKING:
from cloud.pool import DevicePool
logger = logging.getLogger(__name__)
def create_internal_router(
*,
@@ -49,12 +59,15 @@ def create_internal_router(
claim_poll_interval_seconds: float = 0.1,
lease_duration_seconds: float = 60.0,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
) -> APIRouter:
if claim_poll_interval_seconds <= 0:
raise ValueError("claim_poll_interval_seconds must be greater than zero")
if lease_duration_seconds <= 0:
raise ValueError("lease_duration_seconds must be greater than zero")
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
build_planner_client = planner_client_factory or _default_planner_client_factory
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
if principal is None:
@@ -269,9 +282,87 @@ def create_internal_router(
return _stale_lease_conflict("assignment lease is stale or superseded")
return TerminalResultResponse(status=result_status)
@router.post(
"/hosts/{host_id}/planner/decide",
response_model=PlannerDecisionResponse,
responses={
status.HTTP_502_BAD_GATEWAY: {"model": PlannerDecisionError},
},
)
def decide_planner_call(
host_id: str,
payload: PlannerDecisionRequest,
request: Request,
):
authorize_host(request, host_id)
if payload.host_id != host_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="planner-decision host_id must match the request path",
)
screenshot: bytes | None = None
if payload.screenshot_base64 is not None:
try:
screenshot = base64.b64decode(payload.screenshot_base64)
except ValueError, TypeError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="screenshot_base64 is not valid base64",
) from None
tools = [
ToolSpec(
name=tool.name,
description=tool.description,
parameters=tool.parameters,
)
for tool in payload.tools
]
started_at = monotonic()
client = build_planner_client()
try:
decision = client.decide(
system_prompt=payload.system_prompt,
user_prompt=payload.user_prompt,
screenshot=screenshot,
tools=tools,
timeout=payload.timeout_seconds,
)
except ToolCallUnavailable as exc:
logger.info(
"planner-decision request failed",
extra={
"host_id": host_id,
"latency_seconds": monotonic() - started_at,
"error_class": type(exc).__name__,
},
)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
logger.info(
"planner-decision request resolved",
extra={
"host_id": host_id,
"tool_name": decision.tool_name,
"latency_seconds": monotonic() - started_at,
},
)
return PlannerDecisionResponse(
tool_name=decision.tool_name,
arguments=dict(decision.arguments),
)
return router
def _default_planner_client_factory() -> ToolCallingClient:
return build_cloud_planner_client(load_cloud_planner_config())
def _validate_assignment_identity(
*,
host_id: str,
@@ -96,3 +96,28 @@ class TerminalResultResponse(BaseModel):
class StaleLeaseConflict(BaseModel):
code: Literal["stale_lease"] = "stale_lease"
detail: str
class PlannerToolSpecModel(BaseModel):
name: str = Field(min_length=1)
description: str = ""
parameters: dict[str, Any] = Field(default_factory=dict)
class PlannerDecisionRequest(BaseModel):
host_id: str = Field(min_length=1)
system_prompt: str
user_prompt: str
screenshot_base64: str | None = None
tools: list[PlannerToolSpecModel] = Field(default_factory=list)
timeout_seconds: float = Field(default=30.0, gt=0, le=120)
class PlannerDecisionResponse(BaseModel):
tool_name: str
arguments: dict[str, Any] = Field(default_factory=dict)
class PlannerDecisionError(BaseModel):
code: Literal["planner_unavailable"] = "planner_unavailable"
detail: str
@@ -0,0 +1,86 @@
"""Cloud Control Plane's own AI Planner provider configuration.
Analogous to ``runtime/planner_config.py``, but loaded from the Cloud API's
own process environment rather than a Host Agent's. There is no ``enabled``
flag here: the planner-decision endpoint always exists once the Cloud API is
running, and simply fails a given request if the configured provider call
fails (see ``cloud.internal_api.api``). Provider API keys
(``ANTHROPIC_API_KEY``/``OPENAI_API_KEY``) are not modeled as fields here --
like the Host Agent's direct-transport path, they are read implicitly by the
``anthropic``/``openai`` SDK clients from the process environment.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from runtime.tool_calling_client import (
AnthropicToolCallingClient,
OpenAIToolCallingClient,
ToolCallingClient,
)
DEFAULT_PROVIDER = "anthropic"
DEFAULT_MODEL_BY_PROVIDER = {
"anthropic": "claude-sonnet-5",
"openai": "gpt-5.6",
}
DEFAULT_TIMEOUT_SECONDS = 30.0
PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@dataclass(frozen=True)
class CloudPlannerConfig:
provider: str = DEFAULT_PROVIDER
model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS
def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
def load_cloud_planner_config(
env: Mapping[str, str] | None = None,
) -> CloudPlannerConfig:
values = env or os.environ
return CloudPlannerConfig(
provider=_parse_provider(values.get(PROVIDER_ENV)),
model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
)
def build_cloud_planner_client(config: CloudPlannerConfig) -> ToolCallingClient:
"""Construct the same provider client the Host Agent's direct transport uses.
Reuses ``runtime.tool_calling_client``'s Anthropic/OpenAI wire-format
translation (see design decision D1) instead of a second implementation.
"""
model = config.resolved_model()
if config.provider == "openai":
return OpenAIToolCallingClient(model=model)
return AnthropicToolCallingClient(model=model)
def _parse_provider(value: str | None) -> str:
if value is None:
return DEFAULT_PROVIDER
provider = value.strip().lower()
return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER
def _parse_timeout(value: str | None) -> float:
if value is None:
return DEFAULT_TIMEOUT_SECONDS
try:
timeout = float(value)
except ValueError:
return DEFAULT_TIMEOUT_SECONDS
return timeout if timeout > 0 else DEFAULT_TIMEOUT_SECONDS
@@ -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",
}