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_allow_non_loopback: bool = False
console_session_ttl_seconds: float = 43200.0 console_session_ttl_seconds: float = 43200.0
console_history_limit: int = 200 console_history_limit: int = 200
ai_planner_transport: str = "direct" ai_planner_transport: str = "cloud"
dependency_supervisor_enabled: bool = False dependency_supervisor_enabled: bool = False
appium_supervised: bool = False appium_supervised: bool = False
appium_host: str = "127.0.0.1" 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: def _parse_ai_planner_transport(value: str | None) -> str:
if value is None: if value is None:
return "direct" return "cloud"
transport = value.strip().lower() transport = value.strip().lower()
if transport not in _AI_PLANNER_TRANSPORTS: if transport not in _AI_PLANNER_TRANSPORTS:
raise HostAgentConfigurationError( raise HostAgentConfigurationError(
@@ -87,8 +87,7 @@ def _host_agent_planner(
instead of a local Anthropic/OpenAI SDK client. instead of a local Anthropic/OpenAI SDK client.
Returns `None` (letting `TaskRunner` fall back to its own Returns `None` (letting `TaskRunner` fall back to its own
`_default_planner()`) for the `direct` transport, which preserves the `_default_planner()`) only for the explicit `direct` transport.
existing default-enabled/direct-to-provider behavior unchanged.
""" """
planner_config = _host_agent_planner_config() planner_config = _host_agent_planner_config()
if not planner_config.enabled: 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: def test_load_host_agent_config_uses_managed_cloud_defaults() -> None:
assert load_host_agent_config({}) == HostAgentConfig( config = load_host_agent_config({})
assert config == HostAgentConfig(
control_plane_url="https://amcp.home.jerryyan.top", control_plane_url="https://amcp.home.jerryyan.top",
enrollment_managed=True, 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: 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 assert type(task_runner.planner) is Planner
def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client( @pytest.mark.parametrize("transport", [None, "cloud"])
tmp_path, monkeypatch: pytest.MonkeyPatch def test_default_and_explicit_cloud_transport_build_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
) -> None: ) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False) monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager() manager = DeviceManager()
@@ -167,7 +168,7 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
control_plane_url="https://control-plane.example", control_plane_url="https://control-plane.example",
host_id="host-a", host_id="host-a",
token="token-a", token="token-a",
ai_planner_transport="cloud", **({} if transport is None else {"ai_planner_transport": transport}),
) )
factories = create_execution_factories( factories = create_execution_factories(
manager, 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 assert task_runner.planner.client.config is host_agent_config
@pytest.mark.parametrize("transport", [None, "direct"]) def test_explicit_direct_transport_preserves_local_provider_construction(
def test_direct_transport_preserves_existing_local_provider_construction( tmp_path, monkeypatch: pytest.MonkeyPatch
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
) -> None: ) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False) monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager() manager = DeviceManager()
@@ -192,7 +192,7 @@ def test_direct_transport_preserves_existing_local_provider_construction(
control_plane_url="https://control-plane.example", control_plane_url="https://control-plane.example",
host_id="host-a", host_id="host-a",
token="token-a", token="token-a",
**({} if transport is None else {"ai_planner_transport": transport}), ai_planner_transport="direct",
) )
factories = create_execution_factories( factories = create_execution_factories(
manager, 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 library (whose own default is the deterministic stub planner), the **Host
Agent defaults `AI_PLANNER_ENABLED` to on** -- it is the actual device-control 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. 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 ### Cloud-proxy transport (default)
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 With `AI_PLANNER_TRANSPORT` unset or set to `cloud`, every planning decision
fails on its first step (no silent fallback to the stub planner). Set routes through the Cloud API's
`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
`POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same `POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same
host-scoped bearer credential used for heartbeat/claim/renew/result). In this host-scoped bearer credential used for heartbeat/claim/renew/result). In this
mode: mode:
@@ -440,8 +428,22 @@ mode:
latency, error class) and never prompt text or screenshot bytes, but the latency, error class) and never prompt text or screenshot bytes, but the
request bodies themselves do cross the network to the control plane. request bodies themselves do cross the network to the control plane.
`AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing ### Direct transport (explicit opt-out)
direct-to-provider behavior with no change.
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 ### 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 Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token: 使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
Host Agent 的 Planner transport 默认是 `cloud`。启动前在 Cloud Console 创建并激活
LLM Provider profile;Provider API key 只由 Cloud API 加密保存,边缘 Host 不需要也
不应配置厂商 API key。
```bash ```bash
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json" export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01" export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
export AI_PLANNER_ENABLED="true" export AI_PLANNER_ENABLED="true"
export AI_PLANNER_PROVIDER="anthropic"
export ANTHROPIC_API_KEY="<secret>"
uv run --package device-host-agent device-host-agent 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`、为每个本地设备 首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。 换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks/host_identity.json` 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 - Reuse existing Host<->Cloud authentication and existing provider
wire-format code; add no new auth scope and no duplicated Anthropic/OpenAI wire-format code; add no new auth scope and no duplicated Anthropic/OpenAI
translation logic. translation logic.
- Preserve today's direct-to-provider path as a fully supported, still-default - Preserve the direct-to-provider path as a fully supported explicit opt-out
option, so existing Host Agent deployments with local keys are unaffected. for Host Agent deployments that require local provider keys.
**Non-Goals:** **Non-Goals:**
- Not moving prompt construction (`runtime/planner_prompts.py`, Scene - 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. APIs evolve.
### D2: New transport axis, orthogonal to provider selection ### D2: New transport axis, orthogonal to provider selection
Host Agent config gains `AI_PLANNER_TRANSPORT` (`direct` default | `cloud`), Host Agent config gains `AI_PLANNER_TRANSPORT` (`cloud` default | `direct`),
independent of `AI_PLANNER_PROVIDER`. `direct` is today's behavior independent of `AI_PLANNER_PROVIDER`. `cloud` builds a new
unchanged (Host Agent builds the SDK client itself). `cloud` builds a new
`CloudProxyToolCallingClient` instead; provider/model selection and `CloudProxyToolCallingClient` instead; provider/model selection and
credentials for that path live in the Cloud API's own credentials for that path live in the Cloud API's own
`AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/`ANTHROPIC_API_KEY`/`OPENAI_API_KEY` active Provider profile, not the Host Agent's. `direct` remains an explicit
configuration, not the Host Agent's. opt-out that builds the provider SDK client locally.
Alternative considered: overload `AI_PLANNER_PROVIDER=cloud` as a third Alternative considered: overload `AI_PLANNER_PROVIDER=cloud` as a third
provider value. Rejected: provider and transport are different axes (a 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 ## Risks / Trade-offs
- **[Risk] Cloud Control Plane is now in the hot path of every planning - **[Risk] Cloud Control Plane is now in the hot path of every planning
step for hosts on the `cloud` transport** -> Mitigation: `direct` step by default** -> Mitigation: `direct` transport remains fully
transport remains the default and fully supported; operators who need supported as an explicit opt-out for operators who need offline or
offline/low-latency operation simply don't opt in. Bounded by the low-latency operation. Bounded by the
existing `AI_PLANNER_TIMEOUT_SECONDS`, same as today. existing `AI_PLANNER_TIMEOUT_SECONDS`, same as today.
- **[Risk] Cloud Control Plane outage now stalls planning (not just new - **[Risk] Cloud Control Plane outage now stalls planning (not just new
task assignment) for opted-in hosts** -> Mitigation: same 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 ## Migration Plan
1. Add Cloud API configuration (`AI_PLANNER_PROVIDER`/`AI_PLANNER_MODEL`/ 1. Add Cloud API Provider profile management and the new internal route,
`AI_PLANNER_TIMEOUT_SECONDS`/provider API keys) and the new internal guarded by the existing host-scoped auth. Configure and activate a
route, guarded by the existing host-scoped auth. Off by default in the Cloud-held Provider key before running a Host Agent.
sense that no Host Agent calls it until configured to use `cloud` 2. Add the Host Agent's `AI_PLANNER_TRANSPORT` setting (default `cloud`)
transport. and `CloudProxyToolCallingClient`. Existing deployments that require a
2. Add the Host Agent's `AI_PLANNER_TRANSPORT` setting (default `direct`) local provider key set `AI_PLANNER_TRANSPORT=direct` explicitly.
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 3. Update `docs/CLOUD_DEPLOYMENT.md` with the proxy configuration path and
its trade-offs (latency, availability coupling, data-path expansion). its trade-offs (latency, availability coupling, data-path expansion).
4. Rollback is setting `AI_PLANNER_TRANSPORT=direct` (or unsetting it) on 4. Rollback is setting `AI_PLANNER_TRANSPORT=direct` on affected Hosts and
affected hosts and restoring their local provider key; the Cloud API restoring their local provider key; the Cloud API route can remain
route can remain deployed but unused. deployed but unused.
## Open Questions ## 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 - 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 as its own configuration; these are no longer required on the Host Agent
when the new proxy transport is used. 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 and a new `ToolCallingClient` implementation that calls the cloud endpoint
instead of constructing a local Anthropic/OpenAI SDK client. The existing instead of constructing a local Anthropic/OpenAI SDK client. Cloud-proxy is
direct-to-provider transport remains fully supported and is the default, the default; the existing direct-to-provider transport remains fully
so hosts that already run with a local provider key keep working supported as an explicit opt-out for hosts with local provider keys.
unchanged.
- Reuse the existing Host-scoped bearer credential (already used for - Reuse the existing Host-scoped bearer credential (already used for
heartbeat/claim/renew/result) for the new endpoint; no new auth scope. heartbeat/claim/renew/result) for the new endpoint; no new auth scope.
- Cloud API does not durably persist screenshot bytes or full prompt text - 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, and its own decision logic is unchanged regardless of which
transport is in effect transport is in effect
#### Scenario: Direct transport remains available and default #### Scenario: Cloud-proxy transport is the default
- **WHEN** no transport is explicitly configured - **WHEN** no transport is explicitly configured
- **THEN** the AI Planner uses the direct-to-provider transport, matching - **THEN** the AI Planner uses the cloud-proxy transport and the Cloud
its behavior before the cloud-proxy transport existed 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 #### Scenario: Cloud-proxy transport resolves a decision without a local provider client
- **WHEN** the Host Agent is configured with the cloud-proxy transport - **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 ## 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`. `apps/device-host-agent/host_agent/config.py`.
Done: `HostAgentConfig.ai_planner_transport` + Done: `HostAgentConfig.ai_planner_transport` +
`_parse_ai_planner_transport`. `_parse_ai_planner_transport`.
@@ -70,11 +70,10 @@
- [x] 4.1 Update `apps/device-host-agent/host_agent/execution.py`'s - [x] 4.1 Update `apps/device-host-agent/host_agent/execution.py`'s
`_host_agent_planner_config()`/`create_task_runner()` so that when `_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 `AIPlanner` is built with a `CloudProxyToolCallingClient` instead of
the default local provider client, while leaving the existing the local provider client, while `AI_PLANNER_TRANSPORT=direct` remains
default-enabled/direct-transport behavior unchanged when an explicit direct-to-provider fallback.
`AI_PLANNER_TRANSPORT` is unset or `direct`.
Done: new `_host_agent_planner()` helper + `host_agent_config` param Done: new `_host_agent_planner()` helper + `host_agent_config` param
threaded through `create_execution_factories()` from threaded through `create_execution_factories()` from
`app.py::create_application()`. Manually verified both transports `app.py::create_application()`. Manually verified both transports
@@ -106,11 +105,10 @@
(7 tests, using `httpx.MockTransport` -- success, screenshot (7 tests, using `httpx.MockTransport` -- success, screenshot
base64-encoding, network error, structured 502 error, unstructured base64-encoding, network error, structured 502 error, unstructured
error response, and client-ownership on `close()`). error response, and client-ownership on `close()`).
- [x] 5.4 Unit tests for Host Agent wiring: `AI_PLANNER_TRANSPORT=cloud` - [x] 5.4 Unit tests for Host Agent wiring: `AI_PLANNER_TRANSPORT` unset or
constructs an `AIPlanner` using `CloudProxyToolCallingClient`; set to `cloud` constructs an `AIPlanner` using
`AI_PLANNER_TRANSPORT` unset or `direct` preserves existing `CloudProxyToolCallingClient`; explicit `direct` preserves local
direct-to-provider construction (no regression to the provider construction.
already-implemented default-enabled behavior).
Done: added to `apps/device-host-agent/tests/test_execution.py` Done: added to `apps/device-host-agent/tests/test_execution.py`
(2 new tests, one parametrized over `None`/`"direct"`). (2 new tests, one parametrized over `None`/`"direct"`).
- [x] 5.5 Full non-integration suite (`uv run --all-packages pytest -m - [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 full non-integration suite (492 passed); `python -m compileall` clean
for `packages/cloud-platform/cloud`, `apps/cloud-api`, for `packages/cloud-platform/cloud`, `apps/cloud-api`,
`apps/device-host-agent`. `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.