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:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user