diff --git a/apps/device-host-agent/host_agent/execution.py b/apps/device-host-agent/host_agent/execution.py index da0ee73..22aa7d6 100644 --- a/apps/device-host-agent/host_agent/execution.py +++ b/apps/device-host-agent/host_agent/execution.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from device.manager import DeviceManager from runtime.executor import Executor, default_tool_registry +from runtime.planner_config import PlannerConfig, load_config as load_planner_config from runtime.task import TaskRunner from storage.task_metadata import TaskMetadataStore from storage.timeline import Timeline @@ -33,6 +35,7 @@ def create_execution_factories( executor=Executor(tools=default_tool_registry(manager=manager)), metadata_store=metadata_store, timeline=timeline, + planner_config=_host_agent_planner_config(), ) def create_workflow_runner() -> WorkflowRunner: @@ -46,3 +49,18 @@ def create_execution_factories( workflow_runner_factory=create_workflow_runner, workflow_store=shared_workflow_store, ) + + +def _host_agent_planner_config() -> PlannerConfig: + """Host Agent defaults to the AI planner unless an operator opts out. + + `runtime.planner_config` defaults `enabled=False` for the shared Runtime + library (local dev/tests/cloud dispatcher keep the deterministic stub + planner unless asked). The Host Agent is the actual device-control path, + so it flips that default on here -- an explicit `AI_PLANNER_ENABLED=false` + still disables it. + """ + config = load_planner_config() + if os.environ.get("AI_PLANNER_ENABLED") is None: + config = replace(config, enabled=True) + return config diff --git a/apps/device-host-agent/tests/test_execution.py b/apps/device-host-agent/tests/test_execution.py index 0d39ffd..de7cfb7 100644 --- a/apps/device-host-agent/tests/test_execution.py +++ b/apps/device-host-agent/tests/test_execution.py @@ -2,8 +2,12 @@ from __future__ import annotations from pathlib import Path +import pytest + from device.manager import DeviceManager from host_agent.execution import create_execution_factories +from runtime.ai_planner import AIPlanner +from runtime.planner import Planner from runtime.task import TaskRunner from workflow.runner import WorkflowRunner from workflow.store import WorkflowStore @@ -26,6 +30,34 @@ def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) -> assert isinstance(workflow_runner.task_runner_factory(), TaskRunner) +def test_created_task_runner_defaults_to_ai_planner( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False) + manager = DeviceManager() + factories = create_execution_factories( + manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") + ) + + task_runner = factories.task_runner_factory() + + assert isinstance(task_runner.planner, AIPlanner) + + +def test_created_task_runner_honors_explicit_ai_planner_opt_out( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AI_PLANNER_ENABLED", "false") + manager = DeviceManager() + factories = create_execution_factories( + manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") + ) + + task_runner = factories.task_runner_factory() + + assert type(task_runner.planner) is Planner + + def test_runtime_owned_packages_do_not_import_host_or_cloud_concerns() -> None: root = Path(__file__).resolve().parents[3] forbidden = ("import cloud", "from cloud", "import host_agent", "from host_agent") diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 427cfb8..dc29d7b 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -346,18 +346,24 @@ back to the upstream. ## Runtime AI Planner -The Host Agent reuses the local Runtime planner. AI planning is disabled by -default. Configure it in the Host Agent environment when goal assignments must -use a model: +The Host Agent reuses the local Runtime planner. Unlike the shared Runtime +library (whose own default is the deterministic stub planner), the **Host +Agent defaults `AI_PLANNER_ENABLED` to on** -- it is the actual device-control +path, so goal assignments use a model unless an operator explicitly opts out. +Provide provider credentials before deploying: ```text -AI_PLANNER_ENABLED=true AI_PLANNER_PROVIDER=anthropic AI_PLANNER_MODEL=claude-sonnet-5 AI_PLANNER_TIMEOUT_SECONDS=30 ANTHROPIC_API_KEY= ``` +Without a valid API key, every planning step raises immediately and the task +fails on its first step (no silent fallback to the stub planner). Set +`AI_PLANNER_ENABLED=false` to opt back out to the deterministic stub planner +(e.g. for offline/dev hosts with no provider credentials). + For OpenAI, set `AI_PLANNER_PROVIDER=openai`, choose the deployed model through `AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. Provider credentials belong only on the Host Agent; the Cloud API does not need them. diff --git a/openspec/changes/cloud-planner-proxy/.openspec.yaml b/openspec/changes/cloud-planner-proxy/.openspec.yaml new file mode 100644 index 0000000..b119b63 --- /dev/null +++ b/openspec/changes/cloud-planner-proxy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/cloud-planner-proxy/design.md b/openspec/changes/cloud-planner-proxy/design.md new file mode 100644 index 0000000..ecf3641 --- /dev/null +++ b/openspec/changes/cloud-planner-proxy/design.md @@ -0,0 +1,210 @@ +## 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/` +(e.g. `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. + +### 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 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] 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. diff --git a/openspec/changes/cloud-planner-proxy/proposal.md b/openspec/changes/cloud-planner-proxy/proposal.md new file mode 100644 index 0000000..f5acc76 --- /dev/null +++ b/openspec/changes/cloud-planner-proxy/proposal.md @@ -0,0 +1,70 @@ +## 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 new opt-in transport setting (proxy vs. direct-to-provider) + and a new `ToolCallingClient` implementation that calls the cloud endpoint + instead of constructing a local Anthropic/OpenAI SDK client. The existing + direct-to-provider transport remains fully supported and is the default, + so hosts that already run with a local provider key keep working + unchanged. +- 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, + 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 + existing hexagonal boundary that forbids `runtime` from importing `cloud` + or `host_agent`), and a new transport configuration setting. +- `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. diff --git a/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md b/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md new file mode 100644 index 0000000..933921c --- /dev/null +++ b/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md @@ -0,0 +1,43 @@ +## 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: Direct transport remains available and default +- **WHEN** no transport is explicitly configured +- **THEN** the AI Planner uses the direct-to-provider transport, matching + its behavior before the cloud-proxy transport existed + +#### 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 diff --git a/openspec/changes/cloud-planner-proxy/specs/cloud-planner-proxy/spec.md b/openspec/changes/cloud-planner-proxy/specs/cloud-planner-proxy/spec.md new file mode 100644 index 0000000..3eb61f2 --- /dev/null +++ b/openspec/changes/cloud-planner-proxy/specs/cloud-planner-proxy/spec.md @@ -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 diff --git a/openspec/changes/cloud-planner-proxy/tasks.md b/openspec/changes/cloud-planner-proxy/tasks.md new file mode 100644 index 0000000..1e1fd14 --- /dev/null +++ b/openspec/changes/cloud-planner-proxy/tasks.md @@ -0,0 +1,92 @@ +## 1. Cloud API: planner configuration + +- [ ] 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). + +## 2. Cloud API: planner-decision internal endpoint + +- [ ] 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 + screenshot bytes; only metadata (host id, resolved tool name, + latency, error class) may be logged. + +## 3. Host Agent: cloud-proxy transport + +- [ ] 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` + 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. + +## 4. Host Agent: wiring + +- [ ] 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. + +## 5. Tests + +- [ ] 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 + 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 + round-trip, and each failure mode raises `ToolCallUnavailable`. +- [ ] 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 + "not integration"`) passes with no regressions. + +## 6. Documentation + +- [ ] 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 + +- [ ] 7.1 `openspec validate --strict` passes for this change. +- [ ] 7.2 Ruff check/format and `compileall` pass for all touched packages.