- Host Agent now defaults AI_PLANNER_ENABLED=true (opt-out via env), scoped to apps/device-host-agent/host_agent/execution.py only; the shared runtime.planner_config default (disabled) is unchanged. - Add openspec proposal for cloud-planner-proxy: centralize LLM provider config/credentials on the Cloud Control Plane and let the Host Agent proxy AI Planner decisions through it instead of holding provider API keys locally. Proposal only, no implementation yet.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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=<secret manager reference>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-13
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,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.
|
||||
Reference in New Issue
Block a user