Files
agentic-mobile-control/openspec/changes/cloud-planner-proxy/design.md
T
q792602257 a68f609453 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.
2026-07-13 21:27:48 +08:00

229 lines
13 KiB
Markdown

## 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 today's direct-to-provider path as a fully supported, still-default
option, so existing Host Agent deployments with local keys are unaffected.
**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` (`direct` default | `cloud`),
independent of `AI_PLANNER_PROVIDER`. `direct` is today's behavior
unchanged (Host Agent builds the SDK client itself). `cloud` builds a new
`CloudProxyToolCallingClient` instead; provider/model selection and
credentials for that path live in the Cloud API's own
`AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/`ANTHROPIC_API_KEY`/`OPENAI_API_KEY`
configuration, not the Host Agent's.
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 for hosts on the `cloud` transport** -> Mitigation: `direct`
transport remains the default and fully supported; operators who need
offline/low-latency operation simply don't opt in. 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 configuration (`AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/
`AI_PLANNER_TIMEOUT_SECONDS`/provider API keys) and the new internal
route, guarded by the existing host-scoped auth. Off by default in the
sense that no Host Agent calls it until configured to use `cloud`
transport.
2. Add the Host Agent's `AI_PLANNER_TRANSPORT` setting (default `direct`)
and `CloudProxyToolCallingClient`. Existing deployments are unaffected
until an operator sets `AI_PLANNER_TRANSPORT=cloud` and removes the
local provider key.
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` (or unsetting it) 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.