diff --git a/.env.example b/.env.example index 21d12e9..31681e2 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,13 @@ CLOUD_API_PORT=8001 # undecryptable): # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" CLOUD_LLM_PROVIDER_ENCRYPTION_KEY=change-me-generate-a-fernet-key + +# Cloud-proxy planner budget reservations. These apply only to Hosts reporting +# AI_PLANNER_TRANSPORT=cloud. +CLOUD_PLANNER_TOKEN_RESERVATION_CEILING=4096 +CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS=300 + +# Successful Cloud-proxy planner decisions with task context retain prompt and +# tool-call history until their terminal task exceeds this retention window. +CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS=3600 +CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS=7 diff --git a/apps/cloud-api/tests/test_llm_provider_planner_resolution.py b/apps/cloud-api/tests/test_llm_provider_planner_resolution.py index 120e212..71f66e8 100644 --- a/apps/cloud-api/tests/test_llm_provider_planner_resolution.py +++ b/apps/cloud-api/tests/test_llm_provider_planner_resolution.py @@ -10,10 +10,10 @@ from cloud_api.app import create_app class _FakePlannerClient: def __init__(self) -> None: - self.calls = 0 + self.calls: list[dict[str, object]] = [] - def decide(self, **_kwargs) -> ToolCallDecision: - self.calls += 1 + def decide(self, **kwargs) -> ToolCallDecision: + self.calls.append(kwargs) return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}) @@ -38,7 +38,12 @@ def _login_admin(client: TestClient) -> dict[str, str]: def _create_profile( - client: TestClient, headers: dict[str, str], *, name: str, model: str + client: TestClient, + headers: dict[str, str], + *, + name: str, + model: str, + timeout_seconds: float, ) -> dict: response = client.post( "/v1/planner/providers", @@ -48,7 +53,7 @@ def _create_profile( "provider_type": "openai-compatible", "model": model, "base_url": "https://compat.example/v1", - "timeout_seconds": 30, + "timeout_seconds": timeout_seconds, "api_key": f"key-for-{name}", }, ) @@ -95,7 +100,11 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None: with TestClient(app) as client: admin_headers = _login_admin(client) first = _create_profile( - client, admin_headers, name="First", model="first-model" + client, + admin_headers, + name="First", + model="first-model", + timeout_seconds=41, ) assert ( client.post( @@ -115,9 +124,14 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None: assert first_decision.status_code == 200, first_decision.text assert resolved_profiles[-1].profile.model == "first-model" assert resolved_profiles[-1].api_key == "key-for-First" + assert fake.calls[-1]["timeout"] == 41 second = _create_profile( - client, admin_headers, name="Second", model="second-model" + client, + admin_headers, + name="Second", + model="second-model", + timeout_seconds=57, ) settings = client.get("/v1/planner/providers").json()["settings"] activated = client.post( @@ -134,7 +148,8 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None: ) assert second_decision.status_code == 200, second_decision.text assert resolved_profiles[-1].profile.model == "second-model" - assert fake.calls == 2 + assert fake.calls[-1]["timeout"] == 57 + assert len(fake.calls) == 2 def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None: diff --git a/apps/device-host-agent/host_agent/cloud_planner_client.py b/apps/device-host-agent/host_agent/cloud_planner_client.py index 1bd63e5..aef2304 100644 --- a/apps/device-host-agent/host_agent/cloud_planner_client.py +++ b/apps/device-host-agent/host_agent/cloud_planner_client.py @@ -28,10 +28,21 @@ import httpx from cloud.internal_api.models import PlannerDecisionError, PlannerDecisionResponse from host_agent.config import HostAgentConfig from host_agent.planner_context import current_planner_execution_context -from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable, ToolCallUsage +from runtime.tool_calling_client import ( + ToolCallDecision, + ToolCallUnavailable, + ToolCallUsage, +) from runtime.tool_specs import ToolSpec +_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS = 120.0 +_CLOUD_PROXY_TRANSPORT_GRACE_SECONDS = 5.0 +_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS = ( + _CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS + _CLOUD_PROXY_TRANSPORT_GRACE_SECONDS +) + + class CloudProxyToolCallingClient: def __init__( self, @@ -69,7 +80,9 @@ class CloudProxyToolCallingClient: } for spec in tools ], - "timeout_seconds": timeout, + # Legacy Cloud API versions use this field. Current Cloud API versions + # resolve the provider timeout from the active Provider profile. + "timeout_seconds": min(timeout, _CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS), } context = current_planner_execution_context() if context is not None: @@ -85,7 +98,7 @@ class CloudProxyToolCallingClient: f"/internal/v1/hosts/{self.config.host_id}/planner/decide", json=payload, headers={"Authorization": f"Bearer {self.config.token}"}, - timeout=timeout + 5, + timeout=_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS, ) except httpx.HTTPError as exc: raise ToolCallUnavailable(str(exc)) from exc diff --git a/apps/device-host-agent/tests/test_cloud_planner_client.py b/apps/device-host-agent/tests/test_cloud_planner_client.py index 90bc19c..7157455 100644 --- a/apps/device-host-agent/tests/test_cloud_planner_client.py +++ b/apps/device-host-agent/tests/test_cloud_planner_client.py @@ -5,7 +5,10 @@ import json import httpx import pytest -from host_agent.cloud_planner_client import CloudProxyToolCallingClient +from host_agent.cloud_planner_client import ( + _CLOUD_PROXY_HTTP_TIMEOUT_SECONDS, + CloudProxyToolCallingClient, +) from host_agent.config import HostAgentConfig from host_agent.planner_context import PlannerExecutionContext, _context from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable @@ -79,6 +82,29 @@ def test_decide_base64_encodes_screenshot() -> None: assert body["screenshot_base64"] == "aGVsbG8=" +def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None: + seen_requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_requests.append(request) + return httpx.Response(200, json={"tool_name": "tap", "arguments": {}}) + + client = _client(handler) + + client.decide( + system_prompt="sp", + user_prompt="up", + screenshot=None, + tools=_TOOLS, + timeout=180.0, + ) + + request = seen_requests[0] + body = json.loads(request.content) + assert body["timeout_seconds"] == 120.0 + assert request.extensions["timeout"]["read"] == _CLOUD_PROXY_HTTP_TIMEOUT_SECONDS + + def test_decide_includes_bound_assignment_context() -> None: seen_requests: list[httpx.Request] = [] diff --git a/cloud-console/README.md b/cloud-console/README.md index 539ff40..095e8fe 100644 --- a/cloud-console/README.md +++ b/cloud-console/README.md @@ -27,6 +27,12 @@ budgets are enforced only for Hosts reporting `AI_PLANNER_TRANSPORT=cloud`; direct-provider Hosts are labelled **unmetered** rather than budget compliant. The configured proxy reservation ceiling must fit within any daily budget. +Task readers can inspect full per-step LLM interaction history for +cloud-transport tasks in the task detail view. This history includes prompts +and resolved tool calls, excludes screenshot bytes, and is unavailable by +design for direct-provider Hosts. Retention is configured on the Cloud API via +`CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` and its prune interval. + ## Local development ```bash diff --git a/compose.deploy.yaml b/compose.deploy.yaml index 1ffce31..98f484e 100644 --- a/compose.deploy.yaml +++ b/compose.deploy.yaml @@ -27,6 +27,10 @@ services: CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false} CLOUD_LLM_PROVIDER_ENCRYPTION_KEY: ${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY} + CLOUD_PLANNER_TOKEN_RESERVATION_CEILING: ${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096} + CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS: ${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300} + CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS: ${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600} + CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS: ${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7} ports: - "${CLOUD_API_PORT:-8001}:8001" depends_on: diff --git a/compose.yaml b/compose.yaml index d4e3316..e11392a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,10 @@ services: CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false} CLOUD_LLM_PROVIDER_ENCRYPTION_KEY: ${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY} + CLOUD_PLANNER_TOKEN_RESERVATION_CEILING: ${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096} + CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS: ${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300} + CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS: ${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600} + CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS: ${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7} ports: - "${CLOUD_API_PORT:-8001}:8001" depends_on: diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index acc379c..3aa080d 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -404,8 +404,11 @@ mode: sign in to `/console/` as an administrator and create an active entry under **LLM providers**. Provider API keys are encrypted in the database and are never returned by the API or Console. Edge Hosts do not hold Provider keys. - The Cloud API does not read `AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`, - `AI_PLANNER_TIMEOUT_SECONDS`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`. + The profile's provider, model, base URL, and timeout (1 to 120 seconds) are + the authority for every Cloud-proxy call. The Cloud API does not read + `AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`, + `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`; those variables apply only to the + explicit `direct` transport. - **Profile types:** choose **Anthropic** for native Anthropic tool use, or **OpenAI-compatible** for the OpenAI Chat Completions tool-calling protocol. Both accept an optional absolute HTTP(S) Base URL; leave it blank for the @@ -413,9 +416,9 @@ mode: provider's existing request schema and authentication; custom headers or incompatible parameter dialects are not supported by this path. - **Activation is immediate:** a newly activated enabled profile becomes the - Provider/model for the next Cloud-proxy planner decision. A Cloud-planner - request fails closed until one enabled profile is active; it never falls back - to a Cloud API environment credential. + Provider/model/timeout for the next Cloud-proxy planner decision. A + Cloud-planner request fails closed until one enabled profile is active; it + never falls back to a Cloud API environment credential. - **Trade-offs to accept before enabling:** - *Latency*: every planning step now makes a round trip to the Cloud API in addition to the LLM provider call. @@ -423,10 +426,19 @@ mode: Cloud API outages via retry/backoff), a planning step fails immediately if the Cloud API or its configured provider is unreachable -- there is no fallback to the stub planner or to a local direct call. - - *Expanded data path*: goal/scene prompts and screenshots now transit the - Cloud API. The endpoint logs only metadata (host id, resolved tool name, - latency, error class) and never prompt text or screenshot bytes, but the - request bodies themselves do cross the network to the control plane. + - *Expanded data path and retained history*: goal/scene prompts and + screenshots transit the Cloud API. Application logs retain only metadata + (host id, resolved tool name, latency, error class), but every successful + Cloud-proxy decision with task context is also stored as system prompt, + user prompt, resolved tool name, arguments, and step index. The Cloud + Console task detail exposes that history to authorized task readers. The + decision log never stores screenshot bytes; direct-transport Hosts produce + no Cloud-side LLM history. + - *Retention*: `CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` defaults to `7`. + The Cloud prunes a terminal task's decision rows after that window; + `CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS` defaults to `3600`. + Prompt retention is therefore a deliberate operational and data-handling + choice, not merely transient request processing. ### Direct transport (explicit opt-out) @@ -443,7 +455,8 @@ 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. +rotation. `AI_PLANNER_TIMEOUT_SECONDS` controls the provider call only in this +direct mode. ### Host governance and Cloud-proxy token budgets @@ -460,8 +473,10 @@ current UTC day. Set `CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS` (default `300`) to bound an unknown-usage reservation after provider/transport failure. The daily Host budget must accommodate the reservation ceiling; otherwise the proxy rejects before calling the provider. On a provider response, the -reservation is settled to reported usage and the Console retains only timestamp, -provider/model, token counts, and optional task/attempt identifiers. +reservation is settled to reported usage and the separate usage ledger retains +only timestamp, provider/model, token counts, and optional task/attempt +identifiers. This ledger is distinct from the bounded planner-decision history +described above. Hosts reporting `AI_PLANNER_TRANSPORT=direct` are explicitly shown as **unmetered**. Cloud cannot enforce or verify their provider token use. Do not diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index a9cdfd5..a5d2293 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -387,7 +387,9 @@ uv run --package device-host-agent device-host-agent setup Host Agent 的 Planner transport 默认是 `cloud`。启动前在 Cloud Console 创建并激活 LLM Provider profile;Provider API key 只由 Cloud API 加密保存,边缘 Host 不需要也 -不应配置厂商 API key。 +不应配置厂商 API key。`cloud` transport 下的 provider、model、base URL 与 timeout +均以激活 Profile 为准,Host 上的 `AI_PLANNER_*` Provider/model/timeout 配置不会覆盖 +云端 Profile。 ```bash export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json" @@ -402,6 +404,10 @@ uv run --package device-host-agent device-host-agent `AI_PLANNER_TRANSPORT=direct`,并在该 Host 上配置 `AI_PLANNER_PROVIDER`、`AI_PLANNER_MODEL` 与对应的厂商 API key。 +Cloud transport 的成功规划决策会在云端按任务保存 system/user prompt、工具调用和步骤序号, +供有任务读取权限的 Cloud Console 用户排障;截图不会写入该云端决策记录。默认在任务终态 +7 天后清理,具体配置和数据处理边界见 `docs/CLOUD_DEPLOYMENT.md` 的 Runtime AI Planner 一节。 + 首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备 换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。 Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks/host_identity.json` 和 diff --git a/openspec/changes/cloud-console-governance/design.md b/openspec/changes/cloud-console-governance/design.md index 89fd769..5d3dfdf 100644 --- a/openspec/changes/cloud-console-governance/design.md +++ b/openspec/changes/cloud-console-governance/design.md @@ -40,8 +40,9 @@ or `tools`. control layer. - General remote Host control, inbound connections, or allowing one Host to enqueue work for another Host. -- Charging, invoice generation, provider price catalogues, prompt/screenshot - retention, or an attempt to hard-limit direct-to-provider planner calls. +- Charging, invoice generation, provider price catalogues, defining planner + prompt/screenshot retention (owned by `task-execution-progress-visibility`), + or an attempt to hard-limit direct-to-provider planner calls. - Cloud workflow-definition distribution. Host-self submission is goal-only because a workflow definition is still stored locally on each Host. @@ -162,9 +163,10 @@ when `tasks:submit` is present. Policy and usage administration render only for `governance:admin`; backend scope and policy checks are authoritative. Every policy change records a safe audit event with actor, target, old/new -revision, and non-secret values. The Console clears any password inputs from -the existing Users flow and never displays provider credentials, prompts, -screenshots, cookies, or lease secrets. +revision, and non-secret values. The governance views clear password inputs and +never display provider credentials, screenshots, cookies, or lease secrets; +the separate task-detail planner-history view is governed by +`task-execution-progress-visibility`. ## Risks / Trade-offs diff --git a/openspec/changes/cloud-console-governance/specs/cloud-planner-proxy/spec.md b/openspec/changes/cloud-console-governance/specs/cloud-planner-proxy/spec.md index c01cd92..d62094a 100644 --- a/openspec/changes/cloud-console-governance/specs/cloud-planner-proxy/spec.md +++ b/openspec/changes/cloud-console-governance/specs/cloud-planner-proxy/spec.md @@ -18,18 +18,19 @@ the remaining budget. - **THEN** the endpoint returns a structured failure without invoking the configured provider -### Requirement: Planner proxy settles provider-reported token usage without persisting prompts +### Requirement: Planner proxy settles provider-reported token usage without duplicating planner decision content The Cloud planner-decision endpoint SHALL settle its reservation to the provider-reported token usage when available and SHALL retain only non-secret -metering metadata, never the raw prompt, screenshot, provider credentials, or -session/lease secret. +metering metadata in its usage event, never raw prompt text, screenshot bytes, +provider credentials, or session/lease secrets. This does not constrain the +separate bounded planner-decision history defined by `cloud-planner-proxy`. #### Scenario: Provider response includes usage - **WHEN** the configured provider returns a valid tool-call decision and token-usage metadata - **THEN** the endpoint records and returns the decision, settles the Host's - reservation to the reported usage, and does not durably store request text - or screenshot bytes + reservation to the reported usage, and records a usage event without request + text or screenshot bytes #### Scenario: Usage is indeterminate after failure - **WHEN** a reservation exists but the endpoint cannot determine provider diff --git a/openspec/changes/task-execution-progress-visibility/specs/cloud-planner-proxy/spec.md b/openspec/changes/task-execution-progress-visibility/specs/cloud-planner-proxy/spec.md index 1e940c4..ca65a1e 100644 --- a/openspec/changes/task-execution-progress-visibility/specs/cloud-planner-proxy/spec.md +++ b/openspec/changes/task-execution-progress-visibility/specs/cloud-planner-proxy/spec.md @@ -1,3 +1,7 @@ +## REMOVED Requirements + +### Requirement: Planner-decision requests are not durably persisted + ## ADDED Requirements ### Requirement: Cloud Control Plane persists each resolved planner decision for later retrieval diff --git a/openspec/specs/cloud-planner-proxy/spec.md b/openspec/specs/cloud-planner-proxy/spec.md index e0d225f..caccb53 100644 --- a/openspec/specs/cloud-planner-proxy/spec.md +++ b/openspec/specs/cloud-planner-proxy/spec.md @@ -25,7 +25,7 @@ The Cloud Control Plane SHALL expose an internal endpoint that accepts an AI Pla 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. The endpoint SHALL resolve the active database-managed Provider profile for every request and use its provider, model, timeout, configured base URL, and encrypted cloud-held credential. It SHALL NOT read Cloud API planner Provider/model/timeout/API-key environment variables. +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 active database-managed Provider profile's timeout. The endpoint SHALL resolve the active Provider profile for every request and use its provider, model, timeout, configured base URL, and encrypted cloud-held credential. It SHALL NOT read Cloud API planner Provider/model/timeout/API-key environment variables. #### Scenario: Provider returns a usable decision - **WHEN** the configured provider responds to a planner-decision request @@ -44,6 +44,12 @@ The Cloud Control Plane SHALL use its own configured LLM provider, model, and cr - **THEN** subsequent planner-decision requests use only the active database profile and do not read a legacy Provider credential for that decision +#### Scenario: Host supplies a different timeout +- **WHEN** a Host Agent's planner-decision request carries a timeout that differs + from the active Provider profile +- **THEN** the Cloud Control Plane uses the active Provider profile's timeout + for the provider call + ### 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. @@ -53,14 +59,48 @@ A Host Agent using the cloud-proxy transport for its AI Planner SHALL be able to - **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. +### Requirement: Cloud Control Plane persists each resolved planner decision for later retrieval +The Cloud Control Plane SHALL, for each planner-decision request it successfully resolves to a tool-call decision, persist the request's system prompt, user prompt, resolved tool name and arguments, and an assigned step index scoped to the request's `task_id` and `attempt`, in addition to returning the decision to the requesting Host Agent. The Cloud Control Plane SHALL NOT persist screenshot bytes from these requests. -#### 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 +#### Scenario: A planner-decision request resolves successfully +- **WHEN** the Cloud Control Plane resolves a planner-decision request with task + context to a tool-call decision +- **THEN** it persists the request's system prompt, user prompt, resolved tool + name and arguments, and a step index for that task and attempt before + returning the decision to the Host Agent + +#### Scenario: A planner-decision request fails +- **WHEN** the configured provider call fails and the endpoint returns a + structured failure response +- **THEN** no planner-decision row is persisted for that request + +#### Scenario: A screenshot was included in the request +- **WHEN** a planner-decision request includes a screenshot +- **THEN** the screenshot bytes are used only to call the LLM provider and are + not written to the persisted decision log + +### Requirement: Persisted planner decisions are retained within a bounded window +The Cloud Control Plane SHALL prune persisted planner decisions once their owning task has been in a terminal state for longer than a configurable retention window, so that indefinite operation does not cause unbounded growth of the decision log. + +#### Scenario: A task's retention window has elapsed since reaching a terminal state +- **WHEN** a task reached a terminal state more than the configured retention + window ago +- **THEN** the Cloud Control Plane removes that task's persisted planner + decisions + +#### Scenario: A task is still active or within its retention window +- **WHEN** a task is still active, or reached a terminal state less than the + configured retention window ago +- **THEN** its persisted planner decisions remain available for query + +### Requirement: Cloud-side planner decision history is scoped to cloud-proxy transport +The Cloud Control Plane's persisted planner-decision log SHALL contain entries only for Hosts whose planner calls were routed through cloud-proxy transport; it SHALL NOT contain entries, synthesized or otherwise, for Hosts using direct-to-provider transport. + +#### Scenario: A Host uses direct-to-provider transport +- **WHEN** a Host Agent configured for direct-to-provider transport executes an + assignment +- **THEN** no planner-decision entries for that assignment appear in the Cloud + Control Plane's persisted decision log ### 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. diff --git a/openspec/specs/cloud-task-progress-visibility/spec.md b/openspec/specs/cloud-task-progress-visibility/spec.md new file mode 100644 index 0000000..0c5a7ae --- /dev/null +++ b/openspec/specs/cloud-task-progress-visibility/spec.md @@ -0,0 +1,44 @@ +# cloud-task-progress-visibility Specification + +## Purpose +Define how the Cloud Control Plane stores and exposes the latest bounded +execution-progress summary reported by an active Host Agent assignment. + +## Requirements + +### Requirement: Cloud Control Plane exposes the latest in-progress step status for an active assignment +The Cloud Control Plane's existing task query surface SHALL include the latest reported step index, step status, and summary for any assignment that has an in-progress Host Agent execution, alongside the task's existing status fields. + +#### Scenario: An assignment has reported progress +- **WHEN** an operator queries a task that has an active, in-progress assignment with previously reported step progress +- **THEN** the response includes that step's index, status, and summary alongside the task's existing fields + +#### Scenario: An assignment has no reported progress yet +- **WHEN** an operator queries a task whose active assignment has not yet reported any progress +- **THEN** the response omits progress fields rather than showing stale or default values + +#### Scenario: An assignment has reached a terminal state +- **WHEN** an operator queries a task whose assignment has already completed (succeeded or failed) +- **THEN** the response does not present the last in-progress step as current status; the task's terminal status and result take precedence + +### Requirement: Cloud Console renders live step progress for in-progress tasks +Cloud Console SHALL display the current step index, status, and summary for a task with an active, in-progress Host Agent execution, refreshed on its existing polling interval, without requiring a new push channel. + +#### Scenario: Operator views an in-progress task +- **WHEN** an operator opens a task detail view for a task with an active in-progress assignment +- **THEN** Cloud Console shows the latest known step index, status, and summary, updating on subsequent polls as new progress is reported + +#### Scenario: Operator views a task with no in-progress execution +- **WHEN** an operator opens a task detail view for a queued, terminal, or otherwise not-currently-executing task +- **THEN** Cloud Console does not display stale in-progress step information + +### Requirement: Cloud Console displays a task's full LLM interaction history +Cloud Console SHALL provide a view, for a given task, listing each persisted planner decision in step order, including its full prompt and resulting decision, sourced from the Cloud Control Plane's persisted planner-decision log. + +#### Scenario: Task has persisted planner decisions +- **WHEN** an operator opens the LLM interaction history view for a task that has one or more persisted planner decisions +- **THEN** Cloud Console shows each decision in step order with its prompt and resulting tool call + +#### Scenario: Task's Host used direct-to-provider transport +- **WHEN** an operator opens the LLM interaction history view for a task whose Host used direct-to-provider transport +- **THEN** Cloud Console indicates that no LLM interaction history is available because the Host does not report it, rather than showing an empty history with no explanation diff --git a/openspec/specs/host-agent-console-task-pages/spec.md b/openspec/specs/host-agent-console-task-pages/spec.md new file mode 100644 index 0000000..742a05c --- /dev/null +++ b/openspec/specs/host-agent-console-task-pages/spec.md @@ -0,0 +1,40 @@ +# host-agent-console-task-pages Specification + +## Purpose +Define the authenticated, same-origin task-history views served by the Host +Agent local console. + +## Requirements + +### Requirement: Host Agent local console exposes step-level status for the current assignment +The Host Agent's local console SHALL display, for its currently executing assignment, the current step index, step status, and a short summary, sourced from the Host Agent's local task metadata store, refreshed on the console's existing polling interval. + +#### Scenario: An assignment is currently executing +- **WHEN** an operator views the Host Agent local console dashboard while an assignment is executing +- **THEN** the dashboard shows the current step index, step status, and a short summary for that assignment, updating on subsequent polls + +#### Scenario: No assignment is currently executing +- **WHEN** an operator views the dashboard while the Host Agent is idle +- **THEN** the dashboard shows no in-progress step information + +### Requirement: Host Agent local console exposes read-only task history with per-step detail and screenshots +The Host Agent's local console SHALL provide authenticated, read-only pages listing recently executed tasks and, for a selected task, its full per-step history including any captured screenshots, sourced from the Host Agent's local task metadata store and timeline. + +#### Scenario: Operator lists recent tasks +- **WHEN** an authenticated operator opens the Host Agent local console's task list page +- **THEN** it shows tasks from the local task metadata store, most recent first, including tasks that have already reached a terminal state + +#### Scenario: Operator inspects a completed task's step history +- **WHEN** an authenticated operator opens the detail page for a specific completed task +- **THEN** the page shows each recorded step in order, including its tool call, result, and any captured screenshot + +#### Scenario: Unauthenticated request +- **WHEN** a request to the task list or task detail pages is made without a valid Host Agent console session +- **THEN** the Host Agent rejects the request the same way it rejects unauthenticated requests to its other console pages + +### Requirement: Host Agent local console task pages require no new cross-origin surface +The Host Agent local console's task pages SHALL be served same-origin from the Host Agent's existing web application, without introducing new CORS allowances or a dependency on the separate Runtime `console/` frontend. + +#### Scenario: Task pages are requested +- **WHEN** an operator's browser requests the Host Agent local console's task pages +- **THEN** the pages are served by the Host Agent's own application using its existing session/CSRF protections, with no additional cross-origin configuration required diff --git a/openspec/specs/host-agent-protocol/spec.md b/openspec/specs/host-agent-protocol/spec.md index 18256e1..4502afc 100644 --- a/openspec/specs/host-agent-protocol/spec.md +++ b/openspec/specs/host-agent-protocol/spec.md @@ -62,6 +62,28 @@ The Host Agent SHALL renew the active assignment lease before expiry while execu - **WHEN** a Host Agent attempts to renew an expired, replaced, or differently owned lease - **THEN** the control plane returns a conflict and does not revive or alter the current attempt +### Requirement: Host Agent reports execution progress alongside lease renewal +The Host Agent SHALL optionally include a bounded, screenshot-free progress summary (current step index, step status, and a short plain-text summary) in its periodic lease-renewal request for an active assignment, and the control plane SHALL accept and store only the most recent such summary per active assignment. + +#### Scenario: Progress is available at renewal time +- **WHEN** the Host Agent renews the lease for an in-progress assignment and has a current step index, status, and summary available +- **THEN** the renewal request includes that progress summary and the control plane overwrites any previously stored progress for that assignment with it + +#### Scenario: Progress is not available at renewal time +- **WHEN** the Host Agent renews a lease without a progress summary available (for example, before the first step completes) +- **THEN** the renewal request omits the progress field and any previously stored progress for that assignment is left unchanged + +#### Scenario: Assignment reaches a terminal state +- **WHEN** an assignment's terminal result is recorded +- **THEN** the control plane's stored progress for that assignment is no longer treated as current and is not exposed as an in-progress status + +### Requirement: Progress reports exclude screenshot and scene payloads +The control plane SHALL reject or ignore any progress field on a renewal request that includes screenshot, scene, or other bulk payload data beyond the bounded step index, status, and short text summary. + +#### Scenario: Renewal request includes an oversized or non-text summary +- **WHEN** a Host Agent submits a progress summary exceeding the configured length bound +- **THEN** the control plane truncates or rejects the oversized field without failing the underlying lease renewal + ### Requirement: Host execution composes existing Runtime and workflow runners The Host Agent SHALL execute goal assignments through the existing `TaskRunner` and workflow assignments through the existing `WorkflowRunner`, using its local `DeviceManager` and Runtime configuration rather than reimplementing execution behavior. diff --git a/openspec/specs/host-agent-task-progress/spec.md b/openspec/specs/host-agent-task-progress/spec.md new file mode 100644 index 0000000..c91f806 --- /dev/null +++ b/openspec/specs/host-agent-task-progress/spec.md @@ -0,0 +1,35 @@ +# host-agent-task-progress Specification + +## Purpose +Define durable, bounded task and per-step history owned by the Host Agent. + +## Requirements + +### Requirement: Host Agent records step-level execution detail for its in-process TaskRunner +The Host Agent SHALL construct its in-process `TaskRunner` with a durable metadata store and timeline so that every step transition (status, index, the actual prompt submitted to the LLM for that step, the model's resulting decision, result, and screenshot when captured) is persisted as it happens, rather than discarded when the assignment completes. The persisted prompt SHALL be the prompt actually sent to the LLM for that specific step, not the task's overall goal. + +#### Scenario: A step completes during goal execution +- **WHEN** the Host Agent's `TaskRunner` completes a step while executing an assigned goal +- **THEN** the step's status, index, actual per-step LLM prompt and response, tool call, result, and any captured screenshot are persisted to the Host Agent's local task metadata store and timeline before the next step begins + +#### Scenario: An assignment finishes +- **WHEN** an assignment reaches a terminal state (succeeded or failed) +- **THEN** its full step history remains queryable from the Host Agent's local store after the in-memory `Task` object is discarded + +### Requirement: Host-Agent-local task history is retained within a bounded window +The Host Agent SHALL prune persisted task metadata, timeline records, and associated screenshot artifacts once they exceed a configurable retention window or count, so that indefinite process uptime does not cause unbounded local disk growth. + +#### Scenario: Retention window is exceeded +- **WHEN** a persisted task's age or position exceeds the configured retention threshold +- **THEN** the Host Agent removes that task's metadata row, timeline records, and screenshot artifacts from local storage + +#### Scenario: Retention has not been exceeded +- **WHEN** a persisted task is within the configured retention threshold +- **THEN** its metadata, timeline records, and screenshot artifacts remain available for query + +### Requirement: Host Agent local task storage is isolated from an unrelated local Runtime +The Host Agent SHALL use a configurable, Host-Agent-specific database and artifact path for its task metadata store and timeline, distinct from any local Runtime API's own task storage path, so that the two processes cannot silently collide or share state when run on the same machine. + +#### Scenario: Host Agent and local Runtime run on the same machine +- **WHEN** both a Host Agent process and a local Runtime API process run on the same machine with their default configurations +- **THEN** each process reads and writes its own task metadata store and timeline without observing or modifying the other's data diff --git a/openspec/specs/llm-provider-management/spec.md b/openspec/specs/llm-provider-management/spec.md index 4571087..4aa6b0a 100644 --- a/openspec/specs/llm-provider-management/spec.md +++ b/openspec/specs/llm-provider-management/spec.md @@ -90,6 +90,12 @@ reconfiguration. timeout, base URL, and API key while the Host Agent continues using the same Cloud Control Plane endpoint +#### Scenario: A Host sends a different local planner timeout +- **WHEN** a cloud-transport Host Agent includes a timeout different from the + active Provider profile in its planner-decision request +- **THEN** the Cloud Control Plane calls the provider with the active Provider + profile's timeout and does not let the Host override it + #### Scenario: Active OpenAI-compatible profile is used - **WHEN** the active profile is OpenAI-compatible and includes a base URL - **THEN** the Cloud Control Plane makes the existing OpenAI Chat Completions diff --git a/packages/cloud-platform/cloud/internal_api/api.py b/packages/cloud-platform/cloud/internal_api/api.py index 433198c..e802916 100644 --- a/packages/cloud-platform/cloud/internal_api/api.py +++ b/packages/cloud-platform/cloud/internal_api/api.py @@ -415,11 +415,13 @@ def create_internal_router( client = planner_client_factory() resolved_provider = "test" resolved_model = "test" + planner_timeout = payload.timeout_seconds elif planner_provider_service is not None: resolved = planner_provider_service.resolve_active_profile() client = build_cloud_planner_client(resolved) resolved_provider = resolved.profile.provider_type resolved_model = resolved.profile.model + planner_timeout = resolved.profile.timeout_seconds else: raise LlmProviderResolutionError( "no database Provider resolver configured" @@ -461,7 +463,7 @@ def create_internal_router( user_prompt=payload.user_prompt, screenshot=screenshot, tools=tools, - timeout=payload.timeout_seconds, + timeout=planner_timeout, ) except ToolCallUnavailable as exc: logger.info( diff --git a/tests/test_deployment_config.py b/tests/test_deployment_config.py index 6fd2241..de1668b 100644 --- a/tests/test_deployment_config.py +++ b/tests/test_deployment_config.py @@ -41,16 +41,23 @@ def test_compose_defines_database_control_plane_and_outbound_host_agent() -> Non assert services["host-agent"]["environment"]["HOST_AGENT_IDENTITY_PATH"] == ( "${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}" ) - assert not ( - set(services["cloud-api"]["environment"]) - | set(services["host-agent"]["environment"]) - ) & REMOVED_CREDENTIAL_SETTINGS + assert ( + services["cloud-api"]["environment"][ + "CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS" + ] + == "${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7}" + ) + assert ( + not ( + set(services["cloud-api"]["environment"]) + | set(services["host-agent"]["environment"]) + ) + & REMOVED_CREDENTIAL_SETTINGS + ) def test_deploy_compose_has_only_cloud_services_and_required_environment() -> None: - compose = yaml.safe_load( - (ROOT / "compose.deploy.yaml").read_text(encoding="utf-8") - ) + compose = yaml.safe_load((ROOT / "compose.deploy.yaml").read_text(encoding="utf-8")) services = compose["services"] assert set(services) == {"postgres", "cloud-api"} @@ -63,10 +70,16 @@ def test_deploy_compose_has_only_cloud_services_and_required_environment() -> No ), "CLOUD_TRUST_PROXY_HEADERS": "${CLOUD_TRUST_PROXY_HEADERS:-false}", "CLOUD_LLM_PROVIDER_ENCRYPTION_KEY": "${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY}", + "CLOUD_PLANNER_TOKEN_RESERVATION_CEILING": "${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096}", + "CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS": "${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300}", + "CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS": "${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600}", + "CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS": "${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7}", } -def test_container_uses_locked_workspace_install_migrations_and_static_console() -> None: +def test_container_uses_locked_workspace_install_migrations_and_static_console() -> ( + None +): dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") compose = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) cloud_command = compose["services"]["cloud-api"]["command"][-1] @@ -92,6 +105,14 @@ def test_example_environment_contains_no_static_credentials() -> None: "POSTGRES_PASSWORD", "CLOUD_API_PORT", "CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", + "CLOUD_PLANNER_TOKEN_RESERVATION_CEILING", + "CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS", + "CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS", + "CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS", } assert not set(values) & REMOVED_CREDENTIAL_SETTINGS assert values["CLOUD_LLM_PROVIDER_ENCRYPTION_KEY"].startswith("change-me-") + assert values["CLOUD_PLANNER_TOKEN_RESERVATION_CEILING"] == "4096" + assert values["CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS"] == "300" + assert values["CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS"] == "3600" + assert values["CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS"] == "7"