chore(openspec): archive cloud-planner-proxy

Change is complete (20/20 tasks). Deltas synced: MODIFIED the
agent-runtime "Pluggable dual-provider tool-calling abstraction"
requirement (added transport selection), and created a new main spec
openspec/specs/cloud-planner-proxy/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:30:25 +08:00
co-authored by Claude Opus 4.6
parent c01dd4c6b2
commit 8e37b965aa
8 changed files with 100 additions and 3 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,224 @@
## Context
The Host Agent's `AIPlanner` (`runtime/ai_planner.py`) delegates the actual
LLM call to a `ToolCallingClient` (`runtime/tool_calling_client.py`):
`AnthropicToolCallingClient`/`OpenAIToolCallingClient` each build a provider
SDK client that implicitly reads `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` from
the Host Agent's own process environment, and `build_client()` selects
between them based on `PlannerConfig.provider` (from
`runtime/planner_config.py`, itself read from the Host Agent's local env).
None of this touches the Cloud Control Plane today.
The Cloud Control Plane (`apps/cloud-api` + `packages/cloud-platform/cloud`)
already authenticates every Host Agent with a host-scoped bearer credential
for heartbeat, claim/long-poll, lease renewal, and terminal result reporting
(`cloud/internal_api/*`), with the Host Agent importing shared Pydantic
models directly from `cloud.internal_api.models` rather than duplicating
schemas. Separately, `packages/cloud-platform` already depends on the root
Runtime package (`device-agent-runtime`, workspace dependency), the same way
`apps/device-host-agent` does -- so cloud-side code can import
`runtime.tool_calling_client` directly instead of reimplementing
Anthropic/OpenAI wire-format handling a second time.
A prior change in this session made the Host Agent default
`AI_PLANNER_ENABLED` to on (`apps/device-host-agent/host_agent/execution.py`).
That means every Host Agent now needs a working planner path by default,
which raises the stakes on where its provider credentials live.
## Goals / Non-Goals
**Goals:**
- Let an operator configure LLM provider, model, and credentials once, on
the Cloud Control Plane, instead of per Host Agent.
- Let a Host Agent execute AI-planned tasks without holding
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY` locally, by proxying its planner
decision calls through the Cloud Control Plane.
- Reuse existing Host<->Cloud authentication and existing provider
wire-format code; add no new auth scope and no duplicated Anthropic/OpenAI
translation logic.
- Preserve the direct-to-provider path as a fully supported explicit opt-out
for Host Agent deployments that require local provider keys.
**Non-Goals:**
- Not moving prompt construction (`runtime/planner_prompts.py`, Scene
serialization, world/history summarization) to the cloud. The Host Agent
keeps building `system_prompt`/`user_prompt` locally; the cloud endpoint
is a thin "make this one LLM call and return one tool call" proxy, not a
re-implementation of planning.
- Not building per-tenant/per-host provider overrides, usage metering,
rate limiting, or a management UI for the proxy. Single cloud-wide
provider/model/credential configuration only, matching how
`AI_PLANNER_*` is configured today (env-based, one value cluster).
- Not changing the existing heartbeat/claim/renew/result protocol or its
spec (`host-agent-protocol`).
- Not deprecating or removing the direct-to-provider transport.
## Decisions
### D1: New endpoint reuses `runtime.tool_calling_client` server-side instead of reimplementing provider calls
`cloud.internal_api` adds a route whose handler constructs an
`AnthropicToolCallingClient`/`OpenAIToolCallingClient` (imported from
`runtime.tool_calling_client`, already an allowed dependency direction since
`packages/cloud-platform` depends on `device-agent-runtime`) using
Cloud-side provider/model/credential configuration, and calls `.decide(...)`
with the request payload. This eliminates a second implementation of
Anthropic/OpenAI tool-calling wire-format translation, which
`ai-planner-runtime/design.md` already flagged as a drift risk for the
*two-provider* case -- a *four-implementation* case (two providers x two
transports) would be worse.
Alternative considered: hand-roll a minimal cloud-side Anthropic/OpenAI
client in `cloud.internal_api`. Rejected: duplicates parsing logic
(`_decision_from_anthropic_response`, `_decision_from_openai_response`) that
must stay in lockstep with the Host Agent's local transport as providers'
APIs evolve.
### D2: New transport axis, orthogonal to provider selection
Host Agent config gains `AI_PLANNER_TRANSPORT` (`cloud` default | `direct`),
independent of `AI_PLANNER_PROVIDER`. `cloud` builds a new
`CloudProxyToolCallingClient` instead; provider/model selection and
credentials for that path live in the Cloud API's own
active Provider profile, not the Host Agent's. `direct` remains an explicit
opt-out that builds the provider SDK client locally.
Alternative considered: overload `AI_PLANNER_PROVIDER=cloud` as a third
provider value. Rejected: provider and transport are different axes (a
`cloud` transport still ultimately calls `anthropic` or `openai`), and
conflating them would make the Cloud API's own provider config harder to
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/`
(`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`.
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
heartbeat/claim/renew/result. A Host Agent that can already reach the Cloud
Control Plane for task assignment can reach the planner-decide endpoint;
there is no separate enrollment or credential to provision for this
capability.
Alternative considered: a distinct scope/credential just for planner-proxy
calls (defense in depth, so a compromised heartbeat credential couldn't run
up LLM cost). Rejected for this proposal to keep the change additive and
consistent with how the rest of the internal API already treats "any
authenticated host" uniformly; revisit if abuse/cost containment becomes a
concrete concern (see Open Questions).
### D5: Request/response payload mirrors the local `ToolCallingClient.decide()` signature
The proxy request carries `system_prompt`, `user_prompt`, an optional
base64 screenshot, the tool specs (already JSON-serializable
`ToolSpec`/dict shapes used to build provider tool schemas), and a timeout.
The response carries the resolved `tool_name`/`arguments` (or a structured
error). This keeps the Host Agent and Cloud Control Plane in the same
shape as today's local call, so `AIPlanner` itself needs zero changes --
only the client it's constructed with changes.
### D6: Cloud does not durably persist prompt text or screenshot bytes from proxy requests
The endpoint handler processes the request in memory and returns; only
metadata (host id, tool name decided, latency, error class if any) may be
logged for observability. This bounds the new sensitive-data surface
created by routing screenshots and prompts through the cloud (an accepted
trade-off from the earlier feasibility discussion) to "in transit and in
process," not "at rest in cloud logs/DB."
### D7: Proxy failures surface as `ToolCallUnavailable`, matching direct-transport failure behavior
Any failure calling the cloud endpoint (network error, auth rejection,
non-2xx, provider error surfaced by the cloud side, timeout) raises
`ToolCallUnavailable` from `CloudProxyToolCallingClient.decide()`, exactly
like today's direct-transport failures. This preserves the existing,
already-accepted behavior that a planner failure fails the current task
step immediately with no silent fallback to the stub planner -- this
proposal does not change that risk profile, only where the call happens.
## Risks / Trade-offs
- **[Risk] Cloud Control Plane is now in the hot path of every planning
step by default** -> Mitigation: `direct` transport remains fully
supported as an explicit opt-out for operators who need offline or
low-latency operation. Bounded by the
existing `AI_PLANNER_TIMEOUT_SECONDS`, same as today.
- **[Risk] Cloud Control Plane outage now stalls planning (not just new
task assignment) for opted-in hosts** -> Mitigation: same
`ToolCallUnavailable` -> task-fails-fast behavior as any other planner
error; no new silent-hang mode. Documented as an explicit trade-off of
opting into `cloud` transport.
- **[Risk] Screenshots and prompt text now transit through the Cloud
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 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
cost/abuse concern rather than a correctness one.
- **[Risk] `ai-planner-runtime`'s accepted spec text ("AI Planner is
disabled by default") is already stale relative to the Host Agent's
actual default (changed in a prior, separate session change without an
openspec artifact)** -> Not a risk introduced by this proposal, but this
proposal's delta spec is written against that same pending, unarchived
`agent-runtime` capability, so the discrepancy should be reconciled
once, when `ai-planner-runtime` is archived (see Open Questions).
## Migration Plan
1. Add Cloud API Provider profile management and the new internal route,
guarded by the existing host-scoped auth. Configure and activate a
Cloud-held Provider key before running a Host Agent.
2. Add the Host Agent's `AI_PLANNER_TRANSPORT` setting (default `cloud`)
and `CloudProxyToolCallingClient`. Existing deployments that require a
local provider key set `AI_PLANNER_TRANSPORT=direct` explicitly.
3. Update `docs/CLOUD_DEPLOYMENT.md` with the proxy configuration path and
its trade-offs (latency, availability coupling, data-path expansion).
4. Rollback is setting `AI_PLANNER_TRANSPORT=direct` on affected Hosts and
restoring their local provider key; the Cloud API route can remain
deployed but unused.
## Open Questions
- Should planner-proxy calls eventually get their own credential scope
separate from heartbeat/claim/renew/result, to bound blast radius and
allow independent cost/abuse controls? Deferred; revisit if this becomes
a real deployment.
- Should the Cloud API expose any per-request cost/usage observability
(e.g. token counts) given it now brokers every LLM call for `cloud`-
transport hosts? Out of scope for this proposal's tasks; worth deciding
before recommending `cloud` transport for cost-sensitive fleets.
- When `ai-planner-runtime` is archived, its "disabled by default" spec
text needs reconciling against both the Host Agent's actual default
(from the earlier, separate change) and this proposal's transport
addition -- noted here so it isn't lost, matching how
`edge-host-self-enrollment`'s design.md tracked its own dependency on an
unarchived pending spec.
@@ -0,0 +1,74 @@
## Why
Today every Host Agent must hold its own LLM provider credentials
(`ANTHROPIC_API_KEY`/`OPENAI_API_KEY`) and provider/model configuration
locally, because `runtime/ai_planner.py`'s `AIPlanner` builds an
Anthropic/OpenAI SDK client directly inside the Host Agent process
(`runtime/tool_calling_client.py`). With the Host Agent now defaulting AI
planning to on, this means every edge deployment must independently
provision, rotate, and secure a provider secret. Centralizing provider
configuration and credentials in the Cloud Control Plane -- which already
authenticates every Host Agent for heartbeat/claim/lease/result traffic --
removes per-edge secret sprawl and gives operators one place to change
provider/model or rotate a key without touching any Host.
## What Changes
- Add a new Cloud Control Plane internal endpoint that accepts an AI
Planner tool-calling decision request (system prompt, user prompt,
optional screenshot, tool specs, timeout) from an authenticated Host
Agent, calls the configured LLM provider using cloud-held credentials,
and returns the resulting single tool-call decision.
- Cloud API owns `AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/provider API keys
as its own configuration; these are no longer required on the Host Agent
when the new proxy transport is used.
- Host Agent gains a transport setting (cloud-proxy vs. direct-to-provider)
and a new `ToolCallingClient` implementation that calls the cloud endpoint
instead of constructing a local Anthropic/OpenAI SDK client. Cloud-proxy is
the default; the existing direct-to-provider transport remains fully
supported as an explicit opt-out for hosts with local provider keys.
- Reuse the existing Host-scoped bearer credential (already used for
heartbeat/claim/renew/result) for the new endpoint; no new auth scope.
- Cloud API does not durably persist screenshot bytes or full prompt text
from proxy requests beyond the lifetime of handling the request.
## Capabilities
### New Capabilities
- `cloud-planner-proxy`: Cloud Control Plane internal endpoint and
configuration that proxies AI Planner LLM tool-calling decisions on
behalf of authenticated Host Agents, holding provider selection and
credentials centrally instead of on each edge host.
### Modified Capabilities
- `agent-runtime` (capability defined by the not-yet-archived
`ai-planner-runtime` change; this delta is written against that pending
spec, matching the precedent set by `edge-host-self-enrollment` against
the pending `edge-host-enrollment` spec): the "Pluggable dual-provider
tool-calling abstraction" requirement is extended so the tool-calling
client is selectable by transport (direct-to-provider vs. cloud-proxy) as
well as by provider identity, and provider credentials become optional on
the Host Agent when the cloud-proxy transport is selected.
## Impact
- `packages/cloud-platform/cloud` / `apps/cloud-api`: new internal API
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`) 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.
- Docs: `docs/CLOUD_DEPLOYMENT.md` Runtime AI Planner section gains the
proxy-transport configuration path and its trade-offs.
@@ -0,0 +1,48 @@
## MODIFIED Requirements
### Requirement: Pluggable dual-provider tool-calling abstraction
The system SHALL support at least two interchangeable LLM providers
(Anthropic native tool use and OpenAI function calling) for the AI
Planner's decision calls, selectable via configuration, with both providers
constrained to return exactly one tool call per request. Independently of
provider selection, the system SHALL support at least two transports for
making that decision call -- direct-to-provider (the tool-calling client
calls the provider's SDK itself, using locally configured credentials) and
cloud-proxy (the tool-calling client calls the Cloud Control Plane's
planner-decision endpoint, which calls the provider using cloud-held
credentials) -- selectable via configuration without requiring any change
to `AIPlanner`'s own decision logic.
#### Scenario: Provider selected via configuration
- **WHEN** the AI Planner is configured with a given provider identifier
- **THEN** it constructs and uses the tool-calling client for that provider
without requiring any change to `AIPlanner`'s own decision logic
#### Scenario: Provider response resolves to a single decision
- **WHEN** either supported provider returns a response to a tool-calling
request
- **THEN** the response is parsed into exactly one tool name and one
arguments object, regardless of which provider produced it
#### Scenario: Transport selected via configuration
- **WHEN** the Host Agent is configured with a given transport (direct or
cloud-proxy)
- **THEN** `AIPlanner` is constructed with the tool-calling client for that
transport, and its own decision logic is unchanged regardless of which
transport is in effect
#### Scenario: Cloud-proxy transport is the default
- **WHEN** no transport is explicitly configured
- **THEN** the AI Planner uses the cloud-proxy transport and the Cloud
Control Plane's planner-decision endpoint
#### Scenario: Direct transport remains available by explicit configuration
- **WHEN** the Host Agent is configured with the direct transport
- **THEN** the AI Planner uses the direct-to-provider transport with locally
configured credentials
#### Scenario: Cloud-proxy transport resolves a decision without a local provider client
- **WHEN** the Host Agent is configured with the cloud-proxy transport
- **THEN** its tool-calling client sends the decision request to the Cloud
Control Plane's planner-decision endpoint instead of constructing a local
Anthropic or OpenAI SDK client
@@ -0,0 +1,59 @@
## ADDED Requirements
### Requirement: Cloud Control Plane exposes an authenticated planner-decision endpoint
The Cloud Control Plane SHALL expose an internal endpoint that accepts an AI Planner tool-calling decision request from an authenticated Host Agent, and SHALL require the same host-scoped bearer credential already used for heartbeat, claim, lease renewal, and result reporting -- no separate credential or enrollment step.
#### Scenario: Authenticated host requests a planner decision
- **WHEN** a Host Agent presents its existing valid host-scoped credentials
with a planner-decision request
- **THEN** the Cloud Control Plane accepts and processes the request for
that host
#### Scenario: Unauthenticated or foreign-host request is rejected
- **WHEN** a request omits valid host-scoped credentials, or presents
credentials bound to a different host than the one referenced in the
request
- **THEN** the Cloud Control Plane rejects the request without invoking any
LLM provider
### Requirement: Endpoint resolves exactly one tool-call decision using cloud-held provider configuration
The Cloud Control Plane SHALL use its own configured LLM provider, model, and credentials -- not any value supplied by the requesting Host Agent -- to resolve a planner-decision request to exactly one tool name and one arguments object, within the request's timeout.
#### Scenario: Provider returns a usable decision
- **WHEN** the configured provider responds to a planner-decision request
with a tool call
- **THEN** the Cloud Control Plane returns exactly one resolved tool name
and arguments object to the requesting Host Agent
#### Scenario: Configured provider is unreachable or misconfigured
- **WHEN** the Cloud Control Plane's configured provider call fails (for
example, invalid credentials, provider error, or timeout)
- **THEN** the endpoint returns a structured failure response rather than a
fabricated decision, and does not crash the Cloud Control Plane process
### Requirement: Cloud-proxy transport removes the need for Host Agent-held provider credentials
A Host Agent using the cloud-proxy transport for its AI Planner SHALL be able to execute AI-planned tasks without any locally configured LLM provider API key.
#### Scenario: Host configured for cloud-proxy transport has no local provider key
- **WHEN** a Host Agent is configured to use the cloud-proxy transport and
has no `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` set in its own environment
- **THEN** it can still obtain AI Planner decisions by calling the Cloud
Control Plane's planner-decision endpoint
### Requirement: Planner-decision requests are not durably persisted
The Cloud Control Plane SHALL process planner-decision requests, including any screenshot and prompt text they carry, without durably persisting that screenshot or prompt content; only request metadata (such as host identifier, resolved tool name, latency, and error classification) may be retained for observability.
#### Scenario: Request handling completes without storing prompt or screenshot content
- **WHEN** the Cloud Control Plane finishes handling a planner-decision
request
- **THEN** the raw prompt text and screenshot bytes from that request are
not present in any durable store or log the Cloud Control Plane retains
### Requirement: Planner-proxy failures do not silently substitute a default action
The Cloud Control Plane SHALL report a failure to the requesting Host Agent when it cannot resolve a planner-decision request to a valid tool call, rather than returning a default, guessed, or previously cached decision.
#### Scenario: Endpoint cannot resolve a decision
- **WHEN** the configured provider does not return a usable tool call for a
planner-decision request
- **THEN** the Cloud Control Plane's response indicates failure, and the
requesting Host Agent treats the planner call for that turn as failed
@@ -0,0 +1,142 @@
## 1. Cloud API: planner configuration
- [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`.
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
- [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.
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. Done: handler's `logger.info`
calls only ever include `host_id`/`tool_name`/`latency_seconds`/
`error_class`.
## 3. Host Agent: cloud-proxy transport
- [x] 3.1 Add `AI_PLANNER_TRANSPORT` (`cloud` default | `direct`) to
`apps/device-host-agent/host_agent/config.py`.
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`), 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
- [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` is unset or set to `cloud`, the constructed `TaskRunner`'s
`AIPlanner` is built with a `CloudProxyToolCallingClient` instead of
the local provider client, while `AI_PLANNER_TRANSPORT=direct` remains
an explicit direct-to-provider fallback.
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
- [x] 5.1 Unit tests for Cloud API planner configuration loading
(defaults, provider/model/timeout parsing) mirroring
`tests/test_planner_config.py`.
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.
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`.
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` unset or
set to `cloud` constructs an `AIPlanner` using
`CloudProxyToolCallingClient`; explicit `direct` preserves local
provider construction.
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
- [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
provider keys for `cloud` transport; Host Agent holds them for
`direct` transport).
## 7. Validation
- [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`.
## 8. Default transport amendment
- [x] 8.1 Make `cloud` the Host Agent default for
`AI_PLANNER_TRANSPORT`, retain `direct` as an explicit fallback, and
update the runtime contract, deployment guidance, and regression tests.