From 30f09b626847cdebedb66787ce18ab7f6086b9ba Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 14 Jul 2026 20:42:27 +0800 Subject: [PATCH] feat(host-agent): default planner transport to cloud --- apps/device-host-agent/host_agent/config.py | 4 +- .../device-host-agent/host_agent/execution.py | 3 +- apps/device-host-agent/tests/test_config.py | 13 ++++- .../device-host-agent/tests/test_execution.py | 14 +++--- docs/CLOUD_DEPLOYMENT.md | 48 ++++++++++--------- docs/MACOS_IPHONE_SETUP.md | 10 +++- .../changes/cloud-planner-proxy/design.md | 40 +++++++--------- .../changes/cloud-planner-proxy/proposal.md | 9 ++-- .../specs/agent-runtime/spec.md | 11 +++-- openspec/changes/cloud-planner-proxy/tasks.md | 24 ++++++---- 10 files changed, 98 insertions(+), 78 deletions(-) diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index 8868668..302989d 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -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( diff --git a/apps/device-host-agent/host_agent/execution.py b/apps/device-host-agent/host_agent/execution.py index d43f21b..f93bc32 100644 --- a/apps/device-host-agent/host_agent/execution.py +++ b/apps/device-host-agent/host_agent/execution.py @@ -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: diff --git a/apps/device-host-agent/tests/test_config.py b/apps/device-host-agent/tests/test_config.py index 68d8be9..526f457 100644 --- a/apps/device-host-agent/tests/test_config.py +++ b/apps/device-host-agent/tests/test_config.py @@ -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: diff --git a/apps/device-host-agent/tests/test_execution.py b/apps/device-host-agent/tests/test_execution.py index d815eca..61ebdf1 100644 --- a/apps/device-host-agent/tests/test_execution.py +++ b/apps/device-host-agent/tests/test_execution.py @@ -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, diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index d527195..2bead8c 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -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= -``` +### 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= +``` + +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 diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index 4de6d81..13aa274 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -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="" 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` 和 diff --git a/openspec/changes/cloud-planner-proxy/design.md b/openspec/changes/cloud-planner-proxy/design.md index 2ffce63..60252d7 100644 --- a/openspec/changes/cloud-planner-proxy/design.md +++ b/openspec/changes/cloud-planner-proxy/design.md @@ -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 diff --git a/openspec/changes/cloud-planner-proxy/proposal.md b/openspec/changes/cloud-planner-proxy/proposal.md index 430de97..e6dc4e3 100644 --- a/openspec/changes/cloud-planner-proxy/proposal.md +++ b/openspec/changes/cloud-planner-proxy/proposal.md @@ -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 diff --git a/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md b/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md index 933921c..3aa9beb 100644 --- a/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md +++ b/openspec/changes/cloud-planner-proxy/specs/agent-runtime/spec.md @@ -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 diff --git a/openspec/changes/cloud-planner-proxy/tasks.md b/openspec/changes/cloud-planner-proxy/tasks.md index ea5adfc..5678589 100644 --- a/openspec/changes/cloud-planner-proxy/tasks.md +++ b/openspec/changes/cloud-planner-proxy/tasks.md @@ -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.