feat(host-agent): default planner transport to cloud
Tests / Test passed: 794

This commit is contained in:
2026-07-14 20:42:27 +08:00
parent 6e511111c4
commit 30f09b6268
10 changed files with 98 additions and 78 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ class HostAgentConfig:
console_allow_non_loopback: bool = False
console_session_ttl_seconds: float = 43200.0
console_history_limit: int = 200
ai_planner_transport: str = "direct"
ai_planner_transport: str = "cloud"
dependency_supervisor_enabled: bool = False
appium_supervised: bool = False
appium_host: str = "127.0.0.1"
@@ -175,7 +175,7 @@ def load_host_agent_config(
def _parse_ai_planner_transport(value: str | None) -> str:
if value is None:
return "direct"
return "cloud"
transport = value.strip().lower()
if transport not in _AI_PLANNER_TRANSPORTS:
raise HostAgentConfigurationError(
@@ -87,8 +87,7 @@ def _host_agent_planner(
instead of a local Anthropic/OpenAI SDK client.
Returns `None` (letting `TaskRunner` fall back to its own
`_default_planner()`) for the `direct` transport, which preserves the
existing default-enabled/direct-to-provider behavior unchanged.
`_default_planner()`) only for the explicit `direct` transport.
"""
planner_config = _host_agent_planner_config()
if not planner_config.enabled:
+11 -2
View File
@@ -11,11 +11,20 @@ from host_agent.config import (
)
def test_load_host_agent_config_uses_managed_cloud_default() -> None:
assert load_host_agent_config({}) == HostAgentConfig(
def test_load_host_agent_config_uses_managed_cloud_defaults() -> None:
config = load_host_agent_config({})
assert config == HostAgentConfig(
control_plane_url="https://amcp.home.jerryyan.top",
enrollment_managed=True,
)
assert config.ai_planner_transport == "cloud"
def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> None:
config = load_host_agent_config({"AI_PLANNER_TRANSPORT": "direct"})
assert config.ai_planner_transport == "direct"
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
@@ -158,8 +158,9 @@ def test_created_task_runner_honors_explicit_ai_planner_opt_out(
assert type(task_runner.planner) is Planner
def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch
@pytest.mark.parametrize("transport", [None, "cloud"])
def test_default_and_explicit_cloud_transport_build_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
@@ -167,7 +168,7 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
ai_planner_transport="cloud",
**({} if transport is None else {"ai_planner_transport": transport}),
)
factories = create_execution_factories(
manager,
@@ -182,9 +183,8 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
assert task_runner.planner.client.config is host_agent_config
@pytest.mark.parametrize("transport", [None, "direct"])
def test_direct_transport_preserves_existing_local_provider_construction(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
def test_explicit_direct_transport_preserves_local_provider_construction(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
@@ -192,7 +192,7 @@ def test_direct_transport_preserves_existing_local_provider_construction(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
**({} if transport is None else {"ai_planner_transport": transport}),
ai_planner_transport="direct",
)
factories = create_execution_factories(
manager,
+25 -23
View File
@@ -384,29 +384,17 @@ 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:
With `AI_PLANNER_TRANSPORT` unset, the Host Agent uses the **`cloud`**
transport. Configure an active Cloud Provider profile before deploying; without
one, 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).
```text
AI_PLANNER_PROVIDER=anthropic
AI_PLANNER_MODEL=claude-sonnet-5
AI_PLANNER_TIMEOUT_SECONDS=30
ANTHROPIC_API_KEY=<secret manager reference>
```
### Cloud-proxy transport (default)
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`. This is the **`direct`
transport** (the default): the Host Agent holds provider credentials and
calls Anthropic/OpenAI itself.
### Cloud-proxy transport (`AI_PLANNER_TRANSPORT=cloud`)
Set `AI_PLANNER_TRANSPORT=cloud` on the Host Agent to instead route every
planning decision through the Cloud API's
With `AI_PLANNER_TRANSPORT` unset or set to `cloud`, every planning decision
routes through the Cloud API's
`POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
mode:
@@ -440,8 +428,22 @@ mode:
latency, error class) and never prompt text or screenshot bytes, but the
request bodies themselves do cross the network to the control plane.
`AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing
direct-to-provider behavior with no change.
### Direct transport (explicit opt-out)
Set `AI_PLANNER_TRANSPORT=direct` only for Hosts that must call a provider
without the Cloud proxy. Those Hosts hold their own provider credentials:
```text
AI_PLANNER_TRANSPORT=direct
AI_PLANNER_PROVIDER=anthropic
AI_PLANNER_MODEL=claude-sonnet-5
AI_PLANNER_TIMEOUT_SECONDS=30
ANTHROPIC_API_KEY=<secret manager reference>
```
For OpenAI, set `AI_PLANNER_PROVIDER=openai` and provide `OPENAI_API_KEY`.
Direct Hosts are not covered by Cloud token budgets or Cloud-side Provider key
rotation.
### Host governance and Cloud-proxy token budgets
+8 -2
View File
@@ -389,17 +389,23 @@ uv run --package device-host-agent device-host-agent setup
缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
Host Agent 的 Planner transport 默认是 `cloud`。启动前在 Cloud Console 创建并激活
LLM Provider profile;Provider API key 只由 Cloud API 加密保存,边缘 Host 不需要也
不应配置厂商 API key。
```bash
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
export AI_PLANNER_ENABLED="true"
export AI_PLANNER_PROVIDER="anthropic"
export ANTHROPIC_API_KEY="<secret>"
uv run --package device-host-agent device-host-agent
```
只有需要绕过 Cloud API 时,才显式设置
`AI_PLANNER_TRANSPORT=direct`,并在该 Host 上配置
`AI_PLANNER_PROVIDER``AI_PLANNER_MODEL` 与对应的厂商 API key。
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks/host_identity.json`
+18 -22
View File
@@ -36,8 +36,8 @@ which raises the stakes on where its provider credentials live.
- 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.
- 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
@@ -74,13 +74,12 @@ 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
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
`AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/`ANTHROPIC_API_KEY`/`OPENAI_API_KEY`
configuration, not the Host Agent's.
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
@@ -158,9 +157,9 @@ 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
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
@@ -195,20 +194,17 @@ proposal does not change that risk profile, only where the call happens.
## 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.
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` (or unsetting it) on
affected hosts and restoring their local provider key; the Cloud API
route can remain deployed but unused.
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
@@ -22,12 +22,11 @@ provider/model or rotate a key without touching any Host.
- 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)
- 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. 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.
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
@@ -31,10 +31,15 @@ to `AIPlanner`'s own decision logic.
transport, and its own decision logic is unchanged regardless of which
transport is in effect
#### Scenario: Direct transport remains available and default
#### Scenario: Cloud-proxy transport is the 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
- **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
+14 -10
View File
@@ -46,7 +46,7 @@
## 3. Host Agent: cloud-proxy transport
- [x] 3.1 Add `AI_PLANNER_TRANSPORT` (`direct` default | `cloud`) to
- [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`.
@@ -70,11 +70,10 @@
- [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=cloud`, the constructed `TaskRunner`'s
`AI_PLANNER_TRANSPORT` is unset or set to `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`.
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
@@ -106,11 +105,10 @@
(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=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).
- [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
@@ -136,3 +134,9 @@
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.