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