Compare commits

..
13 Commits
Author SHA1 Message Date
showtan001 9076f8ddb0 feat: discover and add connected iOS devices
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-09-07 18:33:49 +08:00
showtan001 8c99dc015a feat: add on-demand device screenshots
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-31 10:43:37 +08:00
showtan001 60ee157e97 feat: preserve planner context across task steps
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:59:19 +08:00
showtan001 dd8df33910 Log task planner conversations locally
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:38:28 +08:00
showtan001 5458f3b8a4 Support Anthropic conversation logging and retries
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:29:19 +08:00
showtan001 44e1a6651a Support multimodal planner scenes without OCR 2026-08-30 21:56:11 +08:00
showtan001 5b8daab457 Recover task metadata store schema on startup 2026-08-30 21:50:57 +08:00
showtan001 697e54427b Bind chat agent sessions to individual devices 2026-08-30 21:48:39 +08:00
showtan001 fdaca7539b Show local conversation activity in web console 2026-08-30 21:46:50 +08:00
showtan001 3c9e65c78e Record local agent reasoning and tool activity
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-29 21:19:30 +08:00
showtan001 71fd182f50 Support multimodal images in local chat agent 2026-08-29 21:17:20 +08:00
showtan001 a315c62f3a Improve local agent chat and device health detection 2026-08-29 16:03:15 +08:00
showtan001 050d1329c4 Add local host mode and configurable LLM providers 2026-08-24 08:49:18 +08:00
38 changed files with 1710 additions and 137 deletions
+77
View File
@@ -60,6 +60,83 @@ operator view for the tasks that actually execute on that Host, including
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
remains the fleet-level view for dispatch status and Cloud-proxy planner history. remains the fleet-level view for dispatch status and Cloud-proxy planner history.
## Local-only Host Agent
Run without a Cloud Control Plane by setting `HOST_AGENT_MODE=local`. Tasks
submitted in the Host console are queued and executed in the same process:
```bash
export HOST_AGENT_MODE=local
export AI_PLANNER_ENABLED=true
export AI_PLANNER_PROVIDER=openai-compatible
export AI_PLANNER_MODEL=qwen2.5
export AI_PLANNER_API_KEY=local-key
export AI_PLANNER_BASE_URL=http://127.0.0.1:11434/v1
export AI_PLANNER_MULTIMODAL=true
uv run --package device-host-agent device-host-agent setup
uv run --package device-host-agent device-host-agent
```
`openai-compatible` works with Ollama, LM Studio, vLLM, or another server that
implements OpenAI `/chat/completions`. Hosted `openai` and `anthropic` providers
also accept `AI_PLANNER_API_KEY` and their conventional API key variables.
The local Host console also exposes an authenticated conversational Agent API:
```text
POST http://127.0.0.1:8765/api/chat
```
Send a JSON body containing `device_id` and `messages` (`user`/`assistant` roles). The Agent can
return ordinary assistant text or call the same device-operation tool contracts
used by the Runtime; each tool result is fed back to the model before the final
reply is returned. The endpoint uses the Host console session cookie, so it is
not an unauthenticated device-control endpoint.
Every chat request is bound to exactly one registered `device_id`. Keep a
separate message history and client session for each phone; the Agent injects
the bound device into device tools and rejects cross-device tool arguments.
For vision-capable OpenAI models, a user message may contain standard OpenAI
multimodal blocks:
```json
{
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "点击图片中的登录按钮"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
]
}]
}
```
The compact form `{ "role": "user", "text": "...", "image_base64": "..." }`
is also accepted. The model can inspect the supplied image and then call a
phone tool such as `tap` in the same conversation.
Set `AI_PLANNER_MULTIMODAL=true` for a vision model. The Planner sends the
screenshot and omits OCR-only elements and OCR metadata from the structured
scene payload, avoiding duplicate OCR text.
Local mode records LLM responses, reasoning fields, tool calls, tool results,
and final replies in a local SQLite database. View them at
`http://127.0.0.1:8765/conversations`; image bytes are excluded. Set
`HOST_AGENT_CONVERSATION_LOG_PATH` to change the database path.
The authenticated `Devices` page has an on-demand `Get screenshot` button for
each connected device. Screenshots are captured only after the operator clicks
the button; the page does not auto-refresh or capture screenshots as part of
heartbeat synchronization.
In local mode, Appium supervision is enabled by default. Host Agent probes
`/status`, adopts a healthy existing Appium instance, starts Appium when no
listener exists, restarts only processes it started if they crash, and stops
those child processes on shutdown. Override `HOST_AGENT_APPIUM_*` or set
`HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED=false` when an external process
manager owns Appium.
## Project Direction ## Project Direction
The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture
+48 -17
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass, replace
import uvicorn import uvicorn
@@ -12,6 +12,8 @@ from device.manager import DeviceManager
from host_agent.assignment import AssignmentExecutor from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.dependency_supervisor import DependencySupervisor from host_agent.dependency_supervisor import DependencySupervisor
from host_agent.devices import register_local_device from host_agent.devices import register_local_device
from host_agent.enrollment import resolve_host_identity from host_agent.enrollment import resolve_host_identity
@@ -22,6 +24,7 @@ from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceLock from host_agent.instance_lock import InstanceLock
from host_agent.lease import ActiveAssignmentRunner from host_agent.lease import ActiveAssignmentRunner
from host_agent.local_account import LocalAccountStore from host_agent.local_account import LocalAccountStore
from host_agent.local_client import LocalHostAgentClient
from host_agent.mcp_lock import McpBusyTracker from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore from host_agent.mcp_token import McpTokenStore
from host_agent.policy_cache import HostPolicyCacheStore from host_agent.policy_cache import HostPolicyCacheStore
@@ -32,6 +35,8 @@ from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server from host_agent.web.mcp import build_mcp_server
from runtime.executor import default_tool_registry
from runtime.planner_config import load_config as load_planner_config
from storage.artifact_store import ArtifactStore from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore from storage.task_metadata import TaskMetadataStore
@@ -173,25 +178,37 @@ def create_application(
startup_config.identity_path startup_config.identity_path
) )
owned_enrollment_client = enrollment_client is None owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient( bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
startup_config
)
try: try:
resolved_config = resolve_host_identity( if startup_config.mode == "local":
startup_config, resolved_config = replace(
identity_store=resolved_identity_store, startup_config, control_plane_url="", host_id="local-host"
client=bootstrap_client, )
) resolved_manager = manager or _configured_device_manager(
bootstrap_client.config = resolved_config config_store, config=resolved_config, enrollment_client=None
resolved_manager = manager or _configured_device_manager( )
config_store, else:
config=resolved_config, resolved_config = resolve_host_identity(
enrollment_client=bootstrap_client, startup_config,
) identity_store=resolved_identity_store,
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
config=resolved_config,
enrollment_client=bootstrap_client,
)
finally: finally:
if owned_enrollment_client: if owned_enrollment_client:
bootstrap_client.close() bootstrap_client.close()
client = HostAgentClient(resolved_config) if resolved_config.mode == "local":
client = LocalHostAgentClient(
host_id="local-host",
device_ids=lambda: [device.id for device in resolved_manager.list_devices()],
)
else:
client = HostAgentClient(resolved_config)
history_store = ConsoleHistoryStore( history_store = ConsoleHistoryStore(
resolved_config.identity_path.parent / "host_console_history.sqlite3", resolved_config.identity_path.parent / "host_console_history.sqlite3",
@@ -214,12 +231,14 @@ def create_application(
"MCP token generated at %s", mcp_token_path "MCP token generated at %s", mcp_token_path
) )
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0) mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
conversation_log = ConversationLogStore(resolved_config.conversation_log_path) if resolved_config.mode == "local" else None
executor = AssignmentExecutor( executor = AssignmentExecutor(
create_execution_factories( create_execution_factories(
resolved_manager, resolved_manager,
metadata_store=metadata_store, metadata_store=metadata_store,
timeline=timeline, timeline=timeline,
host_agent_config=resolved_config, host_agent_config=resolved_config,
conversation_log=conversation_log,
), ),
mcp_busy_tracker=mcp_busy_tracker, mcp_busy_tracker=mcp_busy_tracker,
) )
@@ -228,6 +247,16 @@ def create_application(
mcp_busy_tracker=mcp_busy_tracker, mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker, status_tracker=status_tracker,
) )
planner_config = load_planner_config()
conversation_agent = (
ConversationAgent(
config=planner_config,
tools=default_tool_registry(manager=resolved_manager),
event_logger=conversation_log.append if conversation_log else None,
)
if resolved_config.ai_planner_transport == "direct"
else None
)
console_app = create_console_app( console_app = create_console_app(
config=resolved_config, config=resolved_config,
manager=resolved_manager, manager=resolved_manager,
@@ -247,6 +276,8 @@ def create_application(
mcp_server=mcp_server, mcp_server=mcp_server,
mcp_token_store=mcp_token_store, mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker, mcp_busy_tracker=mcp_busy_tracker,
conversation_agent=conversation_agent,
conversation_log=conversation_log,
) )
console_server = _EmbeddedConsoleServer( console_server = _EmbeddedConsoleServer(
uvicorn.Config( uvicorn.Config(
@@ -352,7 +383,7 @@ def _configured_device_manager(
config_store: DeviceConfigStore, config_store: DeviceConfigStore,
*, *,
config: HostAgentConfig, config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient, enrollment_client: HostAgentEnrollmentClient | None,
) -> DeviceManager: ) -> DeviceManager:
manager = DeviceManager() manager = DeviceManager()
for device in config_store.list(): for device in config_store.list():
+19
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import argparse import argparse
import getpass import getpass
import os
import sys import sys
from pathlib import Path
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import replace from dataclasses import replace
@@ -18,6 +20,7 @@ class LocalAccountSetupError(RuntimeError):
def main(argv: Sequence[str] | None = None) -> None: def main(argv: Sequence[str] | None = None) -> None:
_load_dotenv()
parser = argparse.ArgumentParser(description="Run the Device Host Agent") parser = argparse.ArgumentParser(description="Run the Device Host Agent")
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("setup", help="Create the local operator account") subparsers.add_parser("setup", help="Create the local operator account")
@@ -96,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore):
if password != confirm: if password != confirm:
raise LocalAccountSetupError("passwords do not match") raise LocalAccountSetupError("passwords do not match")
return store.create(username, password) return store.create(username, password)
def _load_dotenv() -> None:
"""Load a simple repository-root .env without overriding shell values."""
path = Path.cwd() / ".env"
if not path.is_file():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
name = name.strip()
value = value.strip()
if name and name not in os.environ:
os.environ[name] = value
@@ -62,11 +62,13 @@ class CloudProxyToolCallingClient:
screenshot: bytes | None, screenshot: bytes | None,
tools: list[ToolSpec], tools: list[ToolSpec],
timeout: float, timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision: ) -> ToolCallDecision:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"host_id": self.config.host_id, "host_id": self.config.host_id,
"system_prompt": system_prompt, "system_prompt": system_prompt,
"user_prompt": user_prompt, "user_prompt": user_prompt,
"history": history or [],
"screenshot_base64": ( "screenshot_base64": (
base64.b64encode(screenshot).decode("ascii") base64.b64encode(screenshot).decode("ascii")
if screenshot is not None if screenshot is not None
+19 -9
View File
@@ -13,6 +13,7 @@ class HostAgentConfigurationError(ValueError):
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) _LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"}) _AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = ( _REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
"HOST_AGENT_RUNTIME_SUPERVISED", "HOST_AGENT_RUNTIME_SUPERVISED",
"HOST_AGENT_RUNTIME_HOST", "HOST_AGENT_RUNTIME_HOST",
@@ -22,7 +23,8 @@ _REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
@dataclass(frozen=True) @dataclass(frozen=True)
class HostAgentConfig: class HostAgentConfig:
control_plane_url: str control_plane_url: str = ""
mode: str = "cloud"
host_id: str = "" host_id: str = ""
token: str = field(default="", repr=False) token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json") identity_path: Path = Path("tasks/host_identity.json")
@@ -47,6 +49,7 @@ class HostAgentConfig:
dependency_restart_max_attempts: int = 5 dependency_restart_max_attempts: int = 5
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3") task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
task_artifact_dir: Path = Path("host_agent_data/history") task_artifact_dir: Path = Path("host_agent_data/history")
conversation_log_path: Path = Path("host_agent_data/conversations.sqlite3")
task_retention_max_count: int = 50 task_retention_max_count: int = 50
task_retention_max_age_days: int = 7 task_retention_max_age_days: int = 7
skill_sync_interval_seconds: float = 300.0 skill_sync_interval_seconds: float = 300.0
@@ -57,16 +60,19 @@ def load_host_agent_config(
) -> HostAgentConfig: ) -> HostAgentConfig:
values = os.environ if env is None else env values = os.environ if env is None else env
_reject_removed_runtime_supervision_settings(values) _reject_removed_runtime_supervision_settings(values)
mode = values.get("HOST_AGENT_MODE", "cloud").strip().lower()
if mode not in _HOST_AGENT_MODES:
raise HostAgentConfigurationError("HOST_AGENT_MODE must be cloud or local")
control_plane_url = ( control_plane_url = (
values.get( values.get(
"HOST_AGENT_CONTROL_PLANE_URL", "HOST_AGENT_CONTROL_PLANE_URL",
"https://amcp.home.jerryyan.top", "https://amcp.home.jerryyan.top" if mode == "cloud" else "",
) )
.strip() .strip()
.rstrip("/") .rstrip("/")
) )
parsed_url = urlparse(control_plane_url) parsed_url = urlparse(control_plane_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: if mode == "cloud" and (parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc):
raise HostAgentConfigurationError( raise HostAgentConfigurationError(
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL" "HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
) )
@@ -82,9 +88,10 @@ def load_host_agent_config(
config = HostAgentConfig( config = HostAgentConfig(
control_plane_url=control_plane_url, control_plane_url=control_plane_url,
mode=mode,
identity_path=identity_path, identity_path=identity_path,
local_account_path=local_account_path, local_account_path=local_account_path,
enrollment_managed=True, enrollment_managed=mode == "cloud",
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float( heartbeat_interval_seconds=_positive_float(
values, values,
@@ -128,13 +135,15 @@ def load_host_agent_config(
"HOST_AGENT_CONSOLE_HISTORY_LIMIT", "HOST_AGENT_CONSOLE_HISTORY_LIMIT",
200, 200,
), ),
ai_planner_transport=_parse_ai_planner_transport( ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
values.get("AI_PLANNER_TRANSPORT")
),
dependency_supervisor_enabled=_truthy( dependency_supervisor_enabled=_truthy(
values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False values,
"HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED",
mode == "local",
),
appium_supervised=_truthy(
values, "HOST_AGENT_APPIUM_SUPERVISED", mode == "local"
), ),
appium_supervised=_truthy(values, "HOST_AGENT_APPIUM_SUPERVISED", False),
appium_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(), appium_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(),
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723), appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
dependency_restart_max_attempts=_positive_int( dependency_restart_max_attempts=_positive_int(
@@ -151,6 +160,7 @@ def load_host_agent_config(
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history" "HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
).strip() ).strip()
), ),
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").strip()),
task_retention_max_count=_positive_int( task_retention_max_count=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50 values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
), ),
@@ -0,0 +1,291 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
from runtime.planner_config import PlannerConfig
from runtime.tool_specs import ACTION_TOOL_SPECS, ToolSpec
READ_TOOL_SPECS = [
ToolSpec("take_screenshot", "Capture the current device screen.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
ToolSpec("describe_screen", "Inspect the current screen and UI.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
ToolSpec("find_text", "Find visible text on the current screen.", {"type": "object", "required": ["query"], "properties": {"query": {"type": "string"}, "device_id": {"type": "string"}}}),
ToolSpec("get_ui_tree", "Read the current accessibility/UI tree.", {"type": "object", "properties": {"device_id": {"type": "string"}, "include_app_info": {"type": "boolean"}}}),
ToolSpec("list_devices", "List configured devices.", {"type": "object", "properties": {}}),
ToolSpec("device_status", "Read one device status.", {"type": "object", "required": ["device_id"], "properties": {"device_id": {"type": "string"}}}),
]
@dataclass(frozen=True)
class ChatResult:
content: str
tool_calls: int
class ConversationAgent:
"""Small OpenAI-compatible agent loop backed by Host Agent tools."""
def __init__(
self,
*,
config: PlannerConfig,
tools: dict[str, Callable[..., Any]],
max_rounds: int = 8,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.config = config
self.tools = tools
self.max_rounds = max_rounds
self.event_logger = event_logger
self._client: Any | None = None
def chat(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
if self.config.provider == "anthropic":
return self._chat_anthropic(messages, tools=tools)
if self.config.provider not in {"openai", "openai_compatible"}:
raise RuntimeError("unsupported conversation provider")
client = self._get_client()
active_tools = tools or self.tools
history: list[dict[str, Any]] = [
{
"role": "system",
"content": (
"You are a mobile device assistant. Reply naturally when no "
"device action is needed. Call a tool when the user asks you "
"to inspect or operate a device. Never claim an action was "
"completed unless the tool result confirms it."
),
},
*[_normalize_message(message) for message in messages],
]
calls = 0
for _ in range(self.max_rounds):
response = client.chat.completions.create(
model=self.config.resolved_model(),
messages=history,
tools=[_openai_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
tool_choice="auto",
parallel_tool_calls=False,
timeout=self.config.timeout,
)
message = response.choices[0].message
text = getattr(message, "content", None)
thinking = getattr(message, "reasoning_content", None) or getattr(message, "reasoning", None)
tool_calls = getattr(message, "tool_calls", None) or []
self._log({"type": "llm_response", "content": text, "thinking": thinking,
"tool_calls": [{"id": c.id, "name": c.function.name, "arguments": c.function.arguments} for c in tool_calls]})
if not tool_calls:
self._log({"type": "final_reply", "content": str(text or "")})
return ChatResult(content=str(text or ""), tool_calls=calls)
history.append(_message_dict(message))
for tool_call in tool_calls:
name = tool_call.function.name
arguments: dict[str, Any] = {}
try:
decoded_arguments = json.loads(tool_call.function.arguments or "{}")
if not isinstance(decoded_arguments, dict):
raise ValueError("tool arguments must be an object")
arguments = decoded_arguments
arguments.pop("purpose", None)
arguments.pop("expected_outcome", None)
result = active_tools[name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
self._log({"type": "tool_result", "tool_call_id": tool_call.id,
"tool_name": name, "arguments": arguments, "result": result})
history.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=True, default=str),
}
)
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def _chat_anthropic(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
import anthropic
kwargs: dict[str, Any] = {}
if self.config.api_key:
kwargs["api_key"] = self.config.api_key
if self.config.base_url:
kwargs["base_url"] = self.config.base_url
client = anthropic.Anthropic(**kwargs)
history = [_normalize_anthropic_message(message) for message in messages]
active_tools = tools or self.tools
calls = 0
for _ in range(self.max_rounds):
response = client.messages.create(
model=self.config.resolved_model(),
max_tokens=2048,
system="You are a mobile device assistant. Reply naturally, or call one device tool when needed. Never claim an action succeeded unless its tool result confirms it.",
messages=history,
tools=[_anthropic_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
timeout=self.config.timeout,
)
blocks = getattr(response, "content", []) or []
text_parts = [getattr(block, "text", "") for block in blocks if getattr(block, "type", None) == "text"]
thinking_parts = [getattr(block, "thinking", "") for block in blocks if getattr(block, "type", None) == "thinking"]
uses = [block for block in blocks if getattr(block, "type", None) == "tool_use"]
self._log({"type": "llm_response", "content": "\n".join(x for x in text_parts if x), "thinking": "\n".join(x for x in thinking_parts if x), "tool_calls": [{"id": u.id, "name": u.name, "arguments": u.input} for u in uses]})
if not uses:
content = "\n".join(x for x in text_parts if x)
self._log({"type": "final_reply", "content": content})
return ChatResult(content=content, tool_calls=calls)
history.append({"role": "assistant", "content": [_anthropic_block_dict(block) for block in blocks if getattr(block, "type", None) != "thinking"]})
results = []
for use in uses:
arguments = dict(use.input) if isinstance(use.input, dict) else {}
try:
result = active_tools[use.name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
self._log({"type": "tool_result", "tool_call_id": use.id, "tool_name": use.name, "arguments": arguments, "result": result})
results.append({"type": "tool_result", "tool_use_id": use.id, "content": json.dumps(result, ensure_ascii=True, default=str)})
history.append({"role": "user", "content": results})
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def chat_for_device(self, device_id: str, messages: list[dict[str, Any]]) -> ChatResult:
"""Run a conversation bound to exactly one device."""
device_tools = {
name: _bind_device(tool, device_id, name=name)
for name, tool in self.tools.items()
}
return self.chat(messages, tools=device_tools)
def _log(self, event: dict[str, Any]) -> None:
if self.event_logger is not None:
try:
self.event_logger(event)
except Exception:
pass
def _get_client(self) -> Any:
if self._client is None:
from openai import OpenAI
kwargs: dict[str, Any] = {}
if self.config.api_key:
kwargs["api_key"] = self.config.api_key
if self.config.base_url:
kwargs["base_url"] = self.config.base_url
self._client = OpenAI(**kwargs)
return self._client
def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
parameters = dict(spec.parameters)
properties = dict(parameters.get("properties") or {})
properties.setdefault(
"device_id",
{"type": "string", "description": "Target device ID when needed."},
)
parameters["properties"] = properties
return {
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": parameters,
},
}
def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
return {"name": spec.name, "description": spec.description, "input_schema": spec.parameters}
def _anthropic_block_dict(block: Any) -> dict[str, Any]:
block_type = getattr(block, "type", "")
if block_type == "text":
return {"type": "text", "text": getattr(block, "text", "")}
if block_type == "thinking":
return {"type": "thinking", "thinking": getattr(block, "thinking", "")}
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
def _normalize_anthropic_message(message: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize_message(message)
content = normalized["content"]
if isinstance(content, str):
return normalized
blocks = []
for block in content:
if block.get("type") == "text":
blocks.append(block)
elif block.get("type") == "image_url":
url = block.get("image_url", {}).get("url", "")
if isinstance(url, str) and url.startswith("data:"):
header, data = url.split(",", 1)
media_type = header[5:].split(";", 1)[0]
blocks.append({"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}})
return {"role": normalized["role"], "content": blocks}
def _message_dict(message: Any) -> dict[str, Any]:
result: dict[str, Any] = {"role": "assistant"}
content = getattr(message, "content", None)
if content is not None:
result["content"] = content
calls = getattr(message, "tool_calls", None) or []
result["tool_calls"] = [
{
"id": call.id,
"type": "function",
"function": {
"name": call.function.name,
"arguments": call.function.arguments,
},
}
for call in calls
]
return result
def _normalize_message(message: dict[str, Any]) -> dict[str, Any]:
"""Accept OpenAI text/image content blocks and a compact image_base64 form."""
role = message.get("role")
content = message.get("content")
if isinstance(content, str):
return {"role": role, "content": content}
if isinstance(content, list):
blocks: list[dict[str, Any]] = []
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
blocks.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image_url":
image_url = block.get("image_url")
if isinstance(image_url, str):
image_url = {"url": image_url}
if isinstance(image_url, dict) and isinstance(image_url.get("url"), str):
blocks.append({"type": "image_url", "image_url": {"url": image_url["url"]}})
if blocks:
return {"role": role, "content": blocks}
image = message.get("image_base64")
if isinstance(image, str) and image:
mime = str(message.get("mime_type") or "image/png")
text = message.get("text")
blocks = []
if isinstance(text, str) and text:
blocks.append({"type": "text", "text": text})
blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image}"}})
return {"role": role, "content": blocks}
raise ValueError("message content must be text, image blocks, or image_base64")
def _bind_device(tool: Callable[..., Any], device_id: str, *, name: str) -> Callable[..., Any]:
def bound(**arguments: Any) -> Any:
if name == "list_devices":
return tool(**arguments)
requested = arguments.get("device_id")
if requested is not None and requested != device_id:
raise ValueError(f"conversation is bound to device {device_id}")
arguments["device_id"] = device_id
return tool(**arguments)
return bound
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from threading import Lock
from typing import Any
class ConversationLogStore:
"""SQLite-backed local audit log for chat and tool activity."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._lock = Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("CREATE TABLE IF NOT EXISTS conversation_events (id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)")
def append(self, event: dict[str, Any]) -> None:
with self._lock, self._connect() as connection:
connection.execute("INSERT INTO conversation_events (occurred_at, event_type, payload_json) VALUES (?, ?, ?)", (datetime.now(UTC).isoformat(), str(event.get("type") or "event"), json.dumps(_safe(event), ensure_ascii=True, default=str)))
def list_recent(self, *, limit: int = 200) -> list[dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute("SELECT id, occurred_at, event_type, payload_json FROM conversation_events ORDER BY id DESC LIMIT ?", (max(1, min(limit, 1000)),)).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload_json"])
except (TypeError, ValueError):
payload = {}
result.append({"id": row["id"], "occurred_at": row["occurred_at"], "event_type": row["event_type"], **payload})
return result
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(k): _safe(v) for k, v in value.items()}
if isinstance(value, list):
return [_safe(v) for v in value]
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
+10 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import os import os
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from typing import Any
from device.manager import DeviceManager from device.manager import DeviceManager
from host_agent.cloud_planner_client import CloudProxyToolCallingClient from host_agent.cloud_planner_client import CloudProxyToolCallingClient
@@ -35,6 +36,7 @@ def create_execution_factories(
metadata_store: TaskMetadataStore | None = None, metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None, timeline: Timeline | None = None,
host_agent_config: HostAgentConfig | None = None, host_agent_config: HostAgentConfig | None = None,
conversation_log: Any | None = None,
) -> ExecutionFactories: ) -> ExecutionFactories:
shared_workflow_store = workflow_store or WorkflowStore() shared_workflow_store = workflow_store or WorkflowStore()
resolved_host_agent_config = host_agent_config resolved_host_agent_config = host_agent_config
@@ -48,7 +50,10 @@ def create_execution_factories(
), ),
metadata_store=metadata_store, metadata_store=metadata_store,
timeline=timeline, timeline=timeline,
planner=_host_agent_planner(resolved_host_agent_config), planner=_host_agent_planner(
resolved_host_agent_config,
event_logger=conversation_log.append if conversation_log is not None else None,
),
planner_config=_host_agent_planner_config(), planner_config=_host_agent_planner_config(),
device_platform_provider=lambda device_id: _device_platform( device_platform_provider=lambda device_id: _device_platform(
manager, device_id manager, device_id
@@ -86,6 +91,8 @@ def _host_agent_planner_config() -> PlannerConfig:
def _host_agent_planner( def _host_agent_planner(
host_agent_config: HostAgentConfig | None, host_agent_config: HostAgentConfig | None,
*,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> Planner | None: ) -> Planner | None:
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is """Build the `AIPlanner` explicitly when the cloud-proxy transport is
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient` selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
@@ -100,11 +107,12 @@ def _host_agent_planner(
resolved_config = host_agent_config or load_host_agent_config() resolved_config = host_agent_config or load_host_agent_config()
if resolved_config.ai_planner_transport != "cloud": if resolved_config.ai_planner_transport != "cloud":
return None return AIPlanner(config=planner_config, event_logger=event_logger)
return AIPlanner( return AIPlanner(
client=CloudProxyToolCallingClient(resolved_config), client=CloudProxyToolCallingClient(resolved_config),
config=planner_config, config=planner_config,
event_logger=event_logger,
) )
+13 -1
View File
@@ -60,6 +60,8 @@ class HeartbeatSynchronizer:
self.status_tracker.mark_host_policy(self.policy) self.status_tracker.mark_host_policy(self.policy)
async def sync_once(self) -> HeartbeatResponse: async def sync_once(self) -> HeartbeatResponse:
self.probe_connected_devices()
self.connect_devices()
snapshot = build_device_snapshot(self.manager) snapshot = build_device_snapshot(self.manager)
mcp_busy_ids = ( mcp_busy_ids = (
self.mcp_busy_tracker.busy_device_ids() self.mcp_busy_tracker.busy_device_ids()
@@ -108,9 +110,19 @@ class HeartbeatSynchronizer:
def connect_devices(self) -> None: def connect_devices(self) -> None:
for device in self.manager.list_devices(): for device in self.manager.list_devices():
if device.status != "idle": if device.status not in {"idle", "offline", "error"}:
continue continue
try: try:
self.manager.connect(device.id) self.manager.connect(device.id)
except DeviceRuntimeError: except DeviceRuntimeError:
continue continue
def probe_connected_devices(self) -> None:
active_id: str | None = None
if self.status_tracker is not None:
current = self.status_tracker.snapshot().get("current_assignment")
if isinstance(current, dict) and isinstance(current.get("device_id"), str):
active_id = current["device_id"]
for device in self.manager.list_devices():
if device.id != active_id and device.status == "busy":
self.manager.probe(device.id)
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from typing import Any
class IOSDiscoveryError(RuntimeError):
pass
def discover_connected_ios_devices(*, timeout_seconds: int = 10) -> list[dict[str, str]]:
"""Return paired, currently connected physical iOS devices from CoreDevice."""
with tempfile.TemporaryDirectory(prefix="ios-device-discovery-") as temp_dir:
output_path = Path(temp_dir) / "devices.json"
try:
completed = subprocess.run(
[
"xcrun",
"devicectl",
"list",
"devices",
"--json-output",
str(output_path),
"--timeout",
str(timeout_seconds),
"--quiet",
],
capture_output=True,
text=True,
timeout=timeout_seconds + 2,
check=False,
)
except FileNotFoundError as exc:
raise IOSDiscoveryError("xcrun is unavailable; install Xcode command line tools") from exc
except subprocess.TimeoutExpired as exc:
raise IOSDiscoveryError("iOS device discovery timed out") from exc
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise IOSDiscoveryError(detail or "devicectl failed to discover iOS devices")
try:
payload = json.loads(output_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise IOSDiscoveryError("devicectl returned invalid device data") from exc
raw_devices = payload.get("result", {}).get("devices", [])
devices: list[dict[str, str]] = []
for item in raw_devices if isinstance(raw_devices, list) else []:
if not isinstance(item, dict):
continue
hardware = item.get("hardwareProperties", {})
properties = item.get("deviceProperties", {})
connection = item.get("connectionProperties", {})
if not all(isinstance(value, dict) for value in (hardware, properties, connection)):
continue
udid = hardware.get("udid")
if (
hardware.get("platform") != "iOS"
or hardware.get("reality") != "physical"
or connection.get("pairingState") != "paired"
or connection.get("tunnelState") != "connected"
or not isinstance(udid, str)
or not udid
):
continue
devices.append(
{
"udid": udid,
"name": str(properties.get("name") or hardware.get("marketingName") or "iPhone"),
"model": str(hardware.get("marketingName") or hardware.get("productType") or "iPhone"),
"os_version": str(properties.get("osVersionNumber") or ""),
"transport": str(connection.get("transportType") or "unknown"),
}
)
return sorted(devices, key=lambda device: (device["name"], device["udid"]))
@@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from cloud.internal_api.models import (
ClaimResponse,
HeartbeatResponse,
HostTaskCancellationResponse,
HostTaskSubmissionResponse,
LeaseRenewalResponse,
TerminalResultResponse,
)
from host_agent.progress import TaskProgressSnapshot
class LocalHostAgentClient:
"""In-process task broker used when the Host Agent runs without Cloud."""
def __init__(self, *, host_id: str, device_ids: Callable[[], list[str]]) -> None:
self.host_id = host_id
self._device_ids = device_ids
self._queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue()
self._cancelled: set[str] = set()
async def submit_self_task(self, *, goal: str, device_id: str | None = None) -> HostTaskSubmissionResponse:
task_id = f"local-{uuid.uuid4().hex}"
await self._queue.put((task_id, goal, device_id))
return HostTaskSubmissionResponse(task_id=task_id)
async def claim(self):
task_id, goal, requested_device = await self._queue.get()
devices = self._device_ids()
device_id = requested_device or (devices[0] if devices else "")
if not device_id:
return None
from cloud.internal_api.models import AssignmentModel
return AssignmentModel(
task_id=task_id,
attempt=1,
lease_id=f"local-lease-{uuid.uuid4().hex}",
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
host_id=self.host_id,
device_id=device_id,
goal=goal,
)
async def heartbeat(self, *args, **kwargs) -> HeartbeatResponse:
return HeartbeatResponse(
host_id=self.host_id,
accepted_devices=len(self._device_ids()),
received_at=datetime.now(UTC),
)
async def renew(self, assignment, *, progress: TaskProgressSnapshot | None = None) -> LeaseRenewalResponse:
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
)
async def report_result(self, assignment, *, status: str, failure_reason: str | None = None, result: dict | None = None) -> TerminalResultResponse:
return TerminalResultResponse(status="recorded")
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
self._cancelled.add(task_id)
return HostTaskCancellationResponse(task_id=task_id, status="cancel_requested")
async def aclose(self) -> None:
return None
@@ -11,7 +11,9 @@ import jinja2
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from core.errors import DeviceNotFoundError, DeviceOfflineError, DeviceRuntimeError
from device.manager import DeviceManager from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor from host_agent.assignment import AssignmentExecutor
from host_agent.client import ( from host_agent.client import (
HostAgentClient, HostAgentClient,
@@ -20,9 +22,12 @@ from host_agent.client import (
HostTaskSubmissionUnknownError, HostTaskSubmissionUnknownError,
) )
from host_agent.config import HostAgentConfig from host_agent.config import HostAgentConfig
from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.devices import register_local_device, unregister_local_device from host_agent.devices import register_local_device, unregister_local_device
from host_agent.history import ConsoleHistoryStore from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore from host_agent.identity import HostIdentityStore
from host_agent.ios_discovery import IOSDiscoveryError, discover_connected_ios_devices
from host_agent.local_account import LocalAccountStore from host_agent.local_account import LocalAccountStore
from host_agent.mcp_lock import McpBusyTracker from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore from host_agent.mcp_token import McpTokenStore
@@ -232,6 +237,8 @@ def create_console_app(
mcp_server: FastMCP | None = None, mcp_server: FastMCP | None = None,
mcp_token_store: McpTokenStore | None = None, mcp_token_store: McpTokenStore | None = None,
mcp_busy_tracker: McpBusyTracker | None = None, mcp_busy_tracker: McpBusyTracker | None = None,
conversation_agent: ConversationAgent | None = None,
conversation_log: ConversationLogStore | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="Host Agent Console") app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
@@ -415,6 +422,51 @@ def create_console_app(
} }
) )
@app.post("/api/chat")
async def api_chat(
request: Request,
session: SessionState = Depends(require_csrf),
) -> JSONResponse:
if conversation_agent is None:
raise HTTPException(status_code=503, detail="chat agent is not configured")
payload = await request.json()
device_id = payload.get("device_id") if isinstance(payload, dict) else None
if not isinstance(device_id, str) or not device_id.strip():
raise HTTPException(status_code=400, detail="device_id is required")
if device_id not in {device.id for device in manager.list_devices()}:
raise HTTPException(status_code=404, detail="unknown device")
raw_messages = payload.get("messages") if isinstance(payload, dict) else None
if not isinstance(raw_messages, list) or not raw_messages:
raise HTTPException(status_code=400, detail="messages must be a non-empty list")
messages = [
{key: value for key, value in item.items() if key in {"role", "content", "image_base64", "mime_type", "text"}}
for item in raw_messages
if isinstance(item, dict)
and item.get("role") in {"user", "assistant"}
and (isinstance(item.get("content"), (str, list)) or isinstance(item.get("image_base64"), str))
]
if not messages:
raise HTTPException(status_code=400, detail="messages are invalid")
if conversation_log is not None:
await asyncio.to_thread(
conversation_log.append,
{"type": "user_request", "device_id": device_id.strip(), "messages": messages},
)
try:
result = await asyncio.to_thread(
conversation_agent.chat_for_device, device_id.strip(), messages
)
except Exception as exc:
if conversation_log is not None:
await asyncio.to_thread(
conversation_log.append,
{"type": "agent_error", "device_id": device_id.strip(), "error": str(exc)},
)
raise HTTPException(status_code=502, detail=str(exc)) from exc
return JSONResponse(
{"content": result.content, "tool_calls": result.tool_calls}
)
@app.get("/devices", response_class=HTMLResponse) @app.get("/devices", response_class=HTMLResponse)
async def devices_page( async def devices_page(
request: Request, request: Request,
@@ -439,6 +491,124 @@ def create_console_app(
error=None, error=None,
) )
@app.post("/api/devices/{device_id}/screenshot")
async def api_device_screenshot(
device_id: str,
session: SessionState = Depends(require_csrf),
) -> Response:
"""Capture one on-demand screenshot for a connected local device."""
try:
screenshot = await asyncio.to_thread(
lambda: manager.active_driver(device_id).screenshot()
)
except DeviceNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except DeviceOfflineError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except DeviceRuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=str(exc) or "failed to capture device screenshot",
) from exc
if not isinstance(screenshot, bytes) or not screenshot:
raise HTTPException(
status_code=502,
detail="device returned an empty screenshot",
)
return Response(
content=screenshot,
media_type="image/png",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"X-Content-Type-Options": "nosniff",
},
)
@app.get("/api/devices/discover-ios")
async def api_discover_ios_devices(
session: SessionState = Depends(require_session),
) -> JSONResponse:
try:
discovered = await asyncio.to_thread(discover_connected_ios_devices)
except IOSDiscoveryError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
configured = await asyncio.to_thread(config_store.list)
configured_udids = {
str(record["connection_info"].get("udid"))
for record in configured
if record["driver_type"] == "wda"
}
used_wda_ports = {
record["connection_info"].get("wda_local_port") for record in configured
}
used_mjpeg_ports = {
record["connection_info"].get("mjpegServerPort") for record in configured
}
next_wda_port = 8100
next_mjpeg_port = 9100
result = []
for device in discovered:
while next_wda_port in used_wda_ports:
next_wda_port += 1
while next_mjpeg_port in used_mjpeg_ports:
next_mjpeg_port += 1
result.append(
{
**device,
"configured": device["udid"] in configured_udids,
"suggested_wda_port": next_wda_port,
"suggested_mjpeg_port": next_mjpeg_port,
}
)
used_wda_ports.add(next_wda_port)
used_mjpeg_ports.add(next_mjpeg_port)
next_wda_port += 1
next_mjpeg_port += 1
return JSONResponse({"devices": result})
@app.post("/api/devices/test-connection")
async def api_device_test_connection(
request: Request,
session: SessionState = Depends(require_csrf),
) -> JSONResponse:
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Request must be an object.")
driver_type = str(payload.get("driver_type", "")).strip()
connection_info = payload.get("connection_info")
if not driver_type or not isinstance(connection_info, dict):
raise HTTPException(
status_code=400,
detail="Driver type and connection info are required.",
)
driver = None
connected = False
try:
driver = build_driver_factory(driver_type, connection_info)()
await asyncio.to_thread(driver.connect)
connected = True
await asyncio.to_thread(driver.health_check)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=str(exc) or "device connection test failed",
) from exc
finally:
if connected and driver is not None:
try:
await asyncio.to_thread(driver.disconnect)
except Exception:
pass
return JSONResponse({"ok": True, "driver_type": driver_type})
@app.post("/devices/save") @app.post("/devices/save")
async def devices_save( async def devices_save(
request: Request, request: Request,
@@ -465,6 +635,26 @@ def create_console_app(
else: else:
connection_info = parsed connection_info = parsed
if error is None:
# The console exposes the common Appium settings as regular form
# fields. Advanced JSON remains available for uncommon capabilities.
field_map = {
"server_url": "server_url",
"udid": "udid",
"device_name": "device_name",
}
for form_key, config_key in field_map.items():
value = str(form.get(form_key, "")).strip()
if value:
connection_info[config_key] = value
port_key = "wda_local_port" if driver_type == "wda" else "system_port"
port_value = str(form.get(port_key, "")).strip()
if port_value:
try:
connection_info[port_key] = int(port_value)
except ValueError:
error = f"{port_key} must be an integer."
if error is None: if error is None:
try: try:
await asyncio.to_thread( await asyncio.to_thread(
@@ -578,6 +768,15 @@ def create_console_app(
entries=entries, entries=entries,
) )
@app.get("/conversations", response_class=HTMLResponse)
async def conversations_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
events = await asyncio.to_thread(
conversation_log.list_recent if conversation_log is not None else (lambda: [])
)
return _render("conversations.html", title="Conversations", session=session, events=events)
def _tasks_list_context( def _tasks_list_context(
session: SessionState, session: SessionState,
*, *,
@@ -26,6 +26,7 @@ form.inline { display: inline; margin: 0; }
<a href="/tasks">Tasks</a> <a href="/tasks">Tasks</a>
<a href="/account">Account</a> <a href="/account">Account</a>
<a href="/history">History</a> <a href="/history">History</a>
<a href="/conversations">Conversations</a>
<form class="inline" method="post" action="/logout"> <form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
<button type="submit">Logout</button> <button type="submit">Logout</button>
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<h1>Conversations</h1>
{% if not events %}<p>No conversation activity recorded yet.</p>{% endif %}
{% for event in events %}
<article>
<h2>{{ event["event_type"] }} <small>{{ event["occurred_at"] }}</small></h2>
{% if event.get("content") %}<pre>{{ event["content"] }}</pre>{% endif %}
{% if event.get("thinking") %}<details><summary>LLM reasoning</summary><pre>{{ event["thinking"] }}</pre></details>{% endif %}
{% if event.get("tool_calls") %}<details open><summary>Tool calls</summary><pre>{{ event["tool_calls"] | tojson(indent=2) }}</pre></details>{% endif %}
{% if event.get("tool_name") %}<p><strong>{{ event["tool_name"] }}</strong></p><pre>{{ event.get("arguments") | tojson(indent=2) }}</pre><pre>{{ event.get("result") | tojson(indent=2) }}</pre>{% endif %}
</article>
{% endfor %}
{% endblock %}
@@ -5,13 +5,20 @@
<p class="error">{{ error }}</p> <p class="error">{{ error }}</p>
{% endif %} {% endif %}
<table> <table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th>Screenshot</th><th></th></tr></thead>
<tbody>{% for device in devices %} <tbody>{% for device in devices %}
<tr> <tr>
<td>{{ device["device_id"] }}</td> <td>{{ device["device_id"] }}</td>
<td>{{ device["name"] or "" }}</td> <td>{{ device["name"] or "" }}</td>
<td>{{ device["driver_type"] }}</td> <td>{{ device["driver_type"] }}</td>
<td>{{ device["cloud_device_id"] or "" }}</td> <td>{{ device["cloud_device_id"] or "" }}</td>
<td>
<button type="button" class="screenshot-button" data-device-id="{{ device["device_id"] }}">Get screenshot</button>
<div class="screenshot-preview" data-screenshot-preview hidden>
<p class="screenshot-status" data-screenshot-status></p>
<img alt="Current screen for {{ device["device_id"] }}" data-screenshot-image hidden>
</div>
</td>
<td> <td>
<a href="/devices?edit={{ device["device_id"] }}">Edit</a> <a href="/devices?edit={{ device["device_id"] }}">Edit</a>
<form class="inline" method="post" action="/devices/remove"> <form class="inline" method="post" action="/devices/remove">
@@ -24,14 +31,173 @@
{% endfor %}</tbody> {% endfor %}</tbody>
</table> </table>
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2> <h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
<button type="button" id="discover-ios">Scan connected iPhones</button>
<p id="discovery-status" class="screenshot-status"></p>
<div id="discovered-devices"></div>
<form method="post" action="/devices/save"> <form method="post" action="/devices/save">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br> <label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br> <label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
<label>Driver type <input type="text" name="driver_type" value="{{ edit_record["driver_type"] if edit_record else "wda" }}" required></label><br> <label>Platform and protocol
<label>Connection info (JSON)<br> <select name="driver_type" id="driver-type" required>
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea> <option value="wda"{% if not edit_record or edit_record["driver_type"] == "wda" %} selected{% endif %}>iOS - Appium / XCUITest (WDA)</option>
<option value="uiautomator2"{% if edit_record and edit_record["driver_type"] == "uiautomator2" %} selected{% endif %}>Android - Appium / UiAutomator2</option>
</select>
</label><br> </label><br>
<label>Appium server URL <input type="url" name="server_url" value="{{ edit_record["connection_info"].get("server_url", "http://127.0.0.1:4723") if edit_record else "http://127.0.0.1:4723" }}" required></label><br>
<label>Device UDID <input type="text" name="udid" value="{{ edit_record["connection_info"].get("udid", "") if edit_record else "" }}" required></label><br>
<label>Device name <input type="text" name="device_name" value="{{ edit_record["connection_info"].get("device_name", "") if edit_record else "" }}" placeholder="iPhone"></label><br>
<label class="ios-setting">WDA local port <input type="number" min="1" max="65535" name="wda_local_port" value="{{ edit_record["connection_info"].get("wda_local_port", "") if edit_record else "" }}" placeholder="8100"></label>
<label class="android-setting">UiAutomator2 system port <input type="number" min="1" max="65535" name="system_port" value="{{ edit_record["connection_info"].get("system_port", "") if edit_record else "" }}" placeholder="8200"></label><br>
<details>
<summary>Advanced connection capabilities (JSON)</summary>
<textarea name="connection_info" rows="4" cols="60">{{ connection_info_json }}</textarea>
</details>
<p id="connection-test-status" class="screenshot-status"></p>
<button type="button" id="test-connection">Test connection</button>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
<style>
.screenshot-preview { margin-top: 0.5rem; max-width: 260px; }
.screenshot-preview img { display: block; width: 100%; height: auto; border: 1px solid #c8d0d6; }
.screenshot-status { margin: 0 0 0.35rem; color: #5e6b73; }
.screenshot-status.error { color: #b00020; }
</style>
<script>
(() => {
const driverType = document.getElementById("driver-type");
const syncPlatformFields = () => {
const ios = driverType.value === "wda";
document.querySelectorAll(".ios-setting").forEach((el) => { el.hidden = !ios; });
document.querySelectorAll(".android-setting").forEach((el) => { el.hidden = ios; });
};
driverType.addEventListener("change", syncPlatformFields);
syncPlatformFields();
const csrfInput = document.querySelector('input[name="csrf_token"]');
const csrfToken = csrfInput ? csrfInput.value : "";
const form = document.querySelector('form[action="/devices/save"]');
const discoverButton = document.getElementById("discover-ios");
const discoveryStatus = document.getElementById("discovery-status");
const discoveredDevices = document.getElementById("discovered-devices");
discoverButton.addEventListener("click", async () => {
discoverButton.disabled = true;
discoveryStatus.classList.remove("error");
discoveryStatus.textContent = "Scanning...";
discoveredDevices.replaceChildren();
try {
const response = await fetch("/api/devices/discover-ios");
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "Discovery failed.");
discoveryStatus.textContent = payload.devices.length
? `Found ${payload.devices.length} connected iPhone(s).`
: "No connected, paired iPhones found.";
payload.devices.forEach((device, index) => {
const row = document.createElement("p");
const description = document.createElement("span");
description.textContent = `${device.name} - ${device.model} - iOS ${device.os_version} (${device.transport}) `;
const select = document.createElement("button");
select.type = "button";
select.textContent = device.configured ? "Already added" : "Use this iPhone";
select.disabled = device.configured;
select.addEventListener("click", () => {
form.elements.driver_type.value = "wda";
form.elements.udid.value = device.udid;
form.elements.device_name.value = device.name;
form.elements.wda_local_port.value = device.suggested_wda_port;
form.elements.device_id.value ||= `ios-phone-${index + 1}`;
form.elements.name.value ||= device.name;
const advanced = JSON.parse(form.elements.connection_info.value || "{}");
advanced.mjpegServerPort = device.suggested_mjpeg_port;
advanced.derivedDataPath = `/tmp/wda-${device.udid}`;
form.elements.connection_info.value = JSON.stringify(advanced, null, 2);
syncPlatformFields();
form.scrollIntoView({ behavior: "smooth", block: "start" });
});
row.append(description, select);
discoveredDevices.append(row);
});
} catch (error) {
discoveryStatus.classList.add("error");
discoveryStatus.textContent = error.message || "Discovery failed.";
} finally {
discoverButton.disabled = false;
}
});
const testButton = document.getElementById("test-connection");
const testStatus = document.getElementById("connection-test-status");
const connectionInfo = () => {
const data = new FormData(form);
let info = {};
const advanced = String(data.get("connection_info") || "{}");
info = JSON.parse(advanced);
["server_url", "udid", "device_name"].forEach((key) => {
const value = String(data.get(key) || "").trim();
if (value) info[key] = value;
});
const portKey = data.get("driver_type") === "wda" ? "wda_local_port" : "system_port";
const port = String(data.get(portKey) || "").trim();
if (port) info[portKey] = Number(port);
return { driver_type: data.get("driver_type"), connection_info: info };
};
testButton.addEventListener("click", async () => {
testButton.disabled = true;
testStatus.classList.remove("error");
testStatus.textContent = "Testing connection...";
try {
const response = await fetch("/api/devices/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify(connectionInfo()),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "Connection test failed.");
testStatus.textContent = "Connection successful.";
} catch (error) {
testStatus.classList.add("error");
testStatus.textContent = error.message || "Connection test failed.";
} finally {
testButton.disabled = false;
}
});
document.querySelectorAll(".screenshot-button").forEach((button) => {
button.addEventListener("click", async () => {
const deviceId = button.dataset.deviceId;
const preview = button.parentElement.querySelector("[data-screenshot-preview]");
const status = preview.querySelector("[data-screenshot-status]");
const image = preview.querySelector("[data-screenshot-image]");
const previousUrl = image.dataset.objectUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
button.disabled = true;
preview.hidden = false;
image.hidden = true;
status.classList.remove("error");
status.textContent = "Capturing...";
try {
const response = await fetch(
"/api/devices/" + encodeURIComponent(deviceId) + "/screenshot",
{ method: "POST", headers: { "X-CSRF-Token": csrfToken } }
);
if (!response.ok) {
let detail = "Screenshot failed.";
try {
const payload = await response.json();
if (payload.detail) detail = payload.detail;
} catch (_) {}
throw new Error(detail);
}
const objectUrl = URL.createObjectURL(await response.blob());
image.src = objectUrl;
image.dataset.objectUrl = objectUrl;
image.hidden = false;
status.textContent = "Captured.";
} catch (error) {
status.classList.add("error");
status.textContent = error.message || "Screenshot failed.";
} finally {
button.disabled = false;
}
});
});
})();
</script>
{% endblock %} {% endblock %}
@@ -47,6 +47,7 @@ def test_devices_renders(env, sample_session) -> None:
**make_devices_context(sample_session) **make_devices_context(sample_session)
) )
assert '<form method="post" action="/devices/save">' in html assert '<form method="post" action="/devices/save">' in html
assert 'class="screenshot-button"' in html
def test_account_renders(env, sample_session) -> None: def test_account_renders(env, sample_session) -> None:
@@ -96,6 +96,36 @@ def test_decide_base64_encodes_screenshot() -> None:
assert body["screenshot_base64"] == "aGVsbG8=" assert body["screenshot_base64"] == "aGVsbG8="
def test_decide_forwards_planner_history() -> 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)
history = [
{
"user_prompt": "first screen",
"tool_name": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "Open it.",
"tool_result": {"success": True},
}
]
client.decide(
system_prompt="sp",
user_prompt="next screen",
screenshot=None,
tools=_TOOLS,
timeout=10.0,
history=history,
)
assert json.loads(seen_requests[0].content)["history"] == history
def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None: def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None:
seen_requests: list[httpx.Request] = [] seen_requests: list[httpx.Request] = []
@@ -27,6 +27,17 @@ def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> No
assert config.ai_planner_transport == "direct" assert config.ai_planner_transport == "direct"
def test_load_host_agent_config_supports_local_mode() -> None:
config = load_host_agent_config({"HOST_AGENT_MODE": "local"})
assert config.mode == "local"
assert config.control_plane_url == ""
assert config.enrollment_managed is False
assert config.ai_planner_transport == "direct"
assert config.dependency_supervisor_enabled is True
assert config.appium_supervised is True
def test_load_host_agent_config_parses_poll_and_retry_values() -> None: def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
config = load_host_agent_config( config = load_host_agent_config(
{ {
@@ -17,6 +17,9 @@ class ConnectableDriver:
def connect(self) -> None: def connect(self) -> None:
return None return None
def screenshot(self) -> bytes:
return b"ok"
def _config() -> HostAgentConfig: def _config() -> HostAgentConfig:
return HostAgentConfig( return HostAgentConfig(
@@ -89,6 +92,33 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
assert manager.status("device-a") == "busy" assert manager.status("device-a") == "busy"
def test_offline_device_is_retried_on_next_heartbeat_cycle() -> None:
class FlakyDriver(ConnectableDriver):
def __init__(self, fail: bool) -> None:
self.fail = fail
def screenshot(self) -> bytes:
if self.fail:
raise RuntimeError("WDA disconnected")
return b"ok"
instances: list[FlakyDriver] = []
def factory() -> FlakyDriver:
driver = FlakyDriver(not instances)
instances.append(driver)
return driver
manager = DeviceManager()
manager.register_device("device-a", factory) # type: ignore[arg-type]
manager.connect("device-a")
sync = HeartbeatSynchronizer(manager, object(), _config()) # type: ignore[arg-type]
sync.probe_connected_devices()
assert manager.status("device-a") == "offline"
sync.connect_devices()
assert manager.status("device-a") == "busy"
def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None: def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None:
manager = DeviceManager() manager = DeviceManager()
manager.register_device( manager.register_device(
@@ -242,6 +242,147 @@ def test_add_device_appears_in_devices_page_and_manager(tmp_path) -> None:
assert [device.id for device in context["manager"].list_devices()] == ["device-a"] assert [device.id for device in context["manager"].list_devices()] == ["device-a"]
def test_devices_page_captures_screenshot_only_when_button_endpoint_is_called(
tmp_path,
) -> None:
class ScreenshotDriver:
def __init__(self) -> None:
self.capture_count = 0
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def screenshot(self) -> bytes:
self.capture_count += 1
return b"fake-png"
driver = ScreenshotDriver()
client, context = _build_client(tmp_path)
context["config_store"].add(
device_id="device-a",
name="Lab iPhone",
driver_type="wda",
connection_info={},
)
context["manager"].register_device("device-a", lambda: driver)
context["manager"].connect("device-a")
csrf_token = _login(client)
page = client.get("/devices")
assert page.status_code == 200
assert 'class="screenshot-button"' in page.text
assert 'data-device-id="device-a"' in page.text
assert driver.capture_count == 0
response = client.post(
"/api/devices/device-a/screenshot",
headers={"X-CSRF-Token": csrf_token},
)
assert response.status_code == 200
assert response.content == b"fake-png"
assert response.headers["content-type"] == "image/png"
assert response.headers["cache-control"] == "no-store"
assert driver.capture_count == 1
def test_device_screenshot_requires_csrf_and_connected_device(tmp_path) -> None:
class ScreenshotDriver:
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def screenshot(self) -> bytes:
return b"fake-png"
client, context = _build_client(tmp_path)
context["manager"].register_device("device-a", ScreenshotDriver)
csrf_token = _login(client)
missing_csrf = client.post("/api/devices/device-a/screenshot")
assert missing_csrf.status_code == 403
offline = client.post(
"/api/devices/device-a/screenshot",
headers={"X-CSRF-Token": csrf_token},
)
assert offline.status_code == 503
def test_device_connection_test_connects_checks_health_and_disconnects(
tmp_path, monkeypatch
) -> None:
events: list[str] = []
class ProbeDriver:
def connect(self) -> None:
events.append("connect")
def health_check(self) -> None:
events.append("health")
def disconnect(self) -> None:
events.append("disconnect")
def factory(driver_type, connection_info):
assert driver_type == "wda"
assert connection_info["udid"] == "ios-udid"
return ProbeDriver
monkeypatch.setattr("host_agent.web.app.build_driver_factory", factory)
client, context = _build_client(tmp_path)
csrf_token = _login(client)
response = client.post(
"/api/devices/test-connection",
json={"driver_type": "wda", "connection_info": {"udid": "ios-udid"}},
headers={"X-CSRF-Token": csrf_token},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "driver_type": "wda"}
assert events == ["connect", "health", "disconnect"]
assert context["config_store"].list() == []
def test_ios_discovery_returns_connected_devices_and_unique_ports(
tmp_path, monkeypatch
) -> None:
monkeypatch.setattr(
"host_agent.web.app.discover_connected_ios_devices",
lambda: [
{
"udid": "ios-new",
"name": "New iPhone",
"model": "iPhone 15",
"os_version": "18.0",
"transport": "wired",
}
],
)
client, context = _build_client(tmp_path)
context["config_store"].add(
device_id="existing",
driver_type="wda",
connection_info={"udid": "ios-old", "wda_local_port": 8100, "mjpegServerPort": 9100},
)
_login(client)
response = client.get("/api/devices/discover-ios")
assert response.status_code == 200
device = response.json()["devices"][0]
assert device["udid"] == "ios-new"
assert device["configured"] is False
assert device["suggested_wda_port"] == 8101
assert device["suggested_mjpeg_port"] == 9101
def test_remove_device_unregisters_from_manager(tmp_path) -> None: def test_remove_device_unregisters_from_manager(tmp_path) -> None:
client, context = _build_client(tmp_path) client, context = _build_client(tmp_path)
csrf_token = _login(client) csrf_token = _login(client)
+20
View File
@@ -122,6 +122,26 @@ class DeviceManager:
self._drivers.pop(device_id, None) self._drivers.pop(device_id, None)
self._set_status(device_id, "offline" if offline else "error") self._set_status(device_id, "offline" if offline else "error")
def probe(self, device_id: str) -> bool:
"""Check whether an established driver is still reachable."""
with self._lock:
self._device(device_id)
driver = self._drivers.get(device_id)
if driver is None:
return False
try:
health_check = getattr(driver, "health_check", None)
if callable(health_check):
health_check()
else:
# Compatibility for drivers implemented before health_check
# existed. Built-in drivers use the non-screen health check.
driver.screenshot()
except Exception:
self.mark_error(device_id, offline=True)
return False
return True
def active_driver(self, device_id: str | None = None) -> Driver: def active_driver(self, device_id: str | None = None) -> Driver:
with self._lock: with self._lock:
if device_id is None: if device_id is None:
+7
View File
@@ -81,6 +81,13 @@ class AndroidDriver(Driver):
except Exception as exc: except Exception as exc:
raise DriverError("screenshot failed") from exc raise DriverError("screenshot failed") from exc
def health_check(self) -> None:
client = self._require_client()
try:
client.get_status()
except Exception as exc:
raise DriverError("health check failed") from exc
def tap(self, x: float, y: float) -> None: def tap(self, x: float, y: float) -> None:
client = self._require_client() client = self._require_client()
try: try:
+9
View File
@@ -25,6 +25,15 @@ class Driver(ABC):
def screenshot(self) -> bytes: def screenshot(self) -> bytes:
"""Return the current screen as image bytes.""" """Return the current screen as image bytes."""
def health_check(self) -> None:
"""Verify the live session without reading the device screen.
Drivers with a transport-level status endpoint should override this
method. The default is a no-op for legacy drivers that do not expose
a separate health check.
"""
return None
@abstractmethod @abstractmethod
def tap(self, x: float, y: float) -> None: def tap(self, x: float, y: float) -> None:
"""Tap the screen at the given coordinates.""" """Tap the screen at the given coordinates."""
+7
View File
@@ -71,6 +71,13 @@ class WDADriver(Driver):
except Exception as exc: except Exception as exc:
raise DriverError("screenshot failed") from exc raise DriverError("screenshot failed") from exc
def health_check(self) -> None:
client = self._require_client()
try:
client.get_status()
except Exception as exc:
raise DriverError("health check failed") from exc
def tap(self, x: float, y: float) -> None: def tap(self, x: float, y: float) -> None:
client = self._require_client() client = self._require_client()
try: try:
@@ -6,6 +6,7 @@ import json
import logging import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import timedelta from datetime import timedelta
from inspect import Parameter, signature
from time import monotonic from time import monotonic
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import uuid4 from uuid import uuid4
@@ -505,13 +506,21 @@ def create_internal_router(
started_at = monotonic() started_at = monotonic()
try: try:
decision = client.decide( decision_kwargs = {
system_prompt=payload.system_prompt, "system_prompt": payload.system_prompt,
user_prompt=payload.user_prompt, "user_prompt": payload.user_prompt,
screenshot=screenshot, "screenshot": screenshot,
tools=tools, "tools": tools,
timeout=planner_timeout, "timeout": planner_timeout,
) }
parameters = signature(client.decide).parameters.values()
if any(
parameter.name == "history"
or parameter.kind == Parameter.VAR_KEYWORD
for parameter in parameters
):
decision_kwargs["history"] = payload.history
decision = client.decide(**decision_kwargs)
except ToolCallUnavailable as exc: except ToolCallUnavailable as exc:
logger.info( logger.info(
"planner-decision request failed", "planner-decision request failed",
@@ -143,6 +143,7 @@ class PlannerDecisionRequest(BaseModel):
host_id: str = Field(min_length=1) host_id: str = Field(min_length=1)
system_prompt: str system_prompt: str
user_prompt: str user_prompt: str
history: list[dict[str, Any]] = Field(default_factory=list)
screenshot_base64: str | None = None screenshot_base64: str | None = None
tools: list[PlannerToolSpecModel] = Field(default_factory=list) tools: list[PlannerToolSpecModel] = Field(default_factory=list)
timeout_seconds: float = Field(default=30.0, gt=0, le=120) timeout_seconds: float = Field(default=30.0, gt=0, le=120)
+87 -24
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from inspect import Parameter, signature
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from core.errors import TaskFailedError from core.errors import TaskFailedError
@@ -23,9 +25,11 @@ class AIPlanner(Planner):
*, *,
client: ToolCallingClient | None = None, client: ToolCallingClient | None = None,
config: PlannerConfig | None = None, config: PlannerConfig | None = None,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> None: ) -> None:
self.config = config or load_config() self.config = config or load_config()
self.client = client or build_client(self.config) self.client = client or build_client(self.config)
self.event_logger = event_logger
def plan( def plan(
self, self,
@@ -36,25 +40,58 @@ class AIPlanner(Planner):
world: "WorldState | None" = None, world: "WorldState | None" = None,
screenshot: bytes | None = None, screenshot: bytes | None = None,
) -> list[PlannedStep]: ) -> list[PlannedStep]:
_sync_tool_results(context)
scene_json = _without_ocr(scene.to_dict()) if self.config.multimodal else scene.to_dict()
user_prompt = planner_user_prompt( user_prompt = planner_user_prompt(
goal=goal, goal=goal,
scene_json=scene.to_dict(), scene_json=scene_json,
history_summary=_history_summary(world),
device_platform=context.device_platform, device_platform=context.device_platform,
) )
decision = self.client.decide( if context.step_results and not context.step_results[-1].success:
system_prompt=PLANNER_SYSTEM_PROMPT, user_prompt += (
user_prompt=user_prompt, "\n\nThe previous tool call failed. Diagnose the failure and choose a "
screenshot=screenshot, "corrected action or finish the task if it cannot proceed.\n"
tools=ALL_TOOL_SPECS, f"Previous failure: {context.step_results[-1].error or 'unknown error'}"
timeout=self.config.timeout, )
) self._log({"type": "llm_request", "task_id": context.task_id, "goal": goal,
"system_prompt": PLANNER_SYSTEM_PROMPT, "user_prompt": user_prompt,
"has_screenshot": screenshot is not None})
try:
kwargs = {
"system_prompt": PLANNER_SYSTEM_PROMPT,
"user_prompt": user_prompt,
"screenshot": screenshot,
"tools": ALL_TOOL_SPECS,
"timeout": self.config.timeout,
}
if _accepts_history(self.client.decide):
kwargs["history"] = context.planner_history[
-self.config.history_max_turns :
]
decision = self.client.decide(**kwargs)
except Exception as exc:
self._log({"type": "agent_error", "task_id": context.task_id, "error": str(exc)})
raise
self._log({"type": "llm_response", "task_id": context.task_id,
"content": decision.text_output, "thinking": decision.thinking,
"tool_name": decision.tool_name, "arguments": decision.arguments,
"purpose": decision.purpose, "expected_outcome": decision.expected_outcome})
if decision.tool_name == FINISH_TASK_TOOL: if decision.tool_name == FINISH_TASK_TOOL:
if decision.arguments.get("success"): if decision.arguments.get("success"):
return [] return []
raise TaskFailedError(decision.arguments.get("reason") or "task failed") raise TaskFailedError(decision.arguments.get("reason") or "task failed")
context.planner_history.append(
{
"user_prompt": user_prompt,
"tool_name": decision.tool_name,
"arguments": _conversation_arguments(decision),
"rationale": decision.text_output,
"tool_result": None,
}
)
return [ return [
PlannedStep( PlannedStep(
action=decision.tool_name, action=decision.tool_name,
@@ -78,19 +115,45 @@ class AIPlanner(Planner):
# (mapped to an empty plan above), never via this hook. # (mapped to an empty plan above), never via this hook.
return False return False
def _log(self, event: dict[str, Any]) -> None:
if self.event_logger is not None:
try:
self.event_logger(event)
except Exception:
pass
def _history_summary(world: "WorldState | None") -> list[dict[str, Any]]:
if world is None: def _without_ocr(scene_json: dict[str, Any]) -> dict[str, Any]:
return [] cleaned = dict(scene_json)
return [ elements = cleaned.get("elements")
{ if isinstance(elements, list):
"page": event.page, cleaned["elements"] = [
"action": event.action, {key: value for key, value in element.items() if key not in {"source", "confidence", "foreground_color", "background_color"}}
"arguments": dict(event.arguments), for element in elements
"rationale": event.rationale, if isinstance(element, dict) and element.get("source") != "ocr"
"purpose": event.purpose, ]
"expected_outcome": event.expected_outcome, cleaned.pop("ocr_elements", None)
"success": event.success, return cleaned
}
for event in world.history
] def _sync_tool_results(context: TaskContext) -> None:
for turn, result in zip(context.planner_history, context.step_results, strict=False):
if turn.get("tool_result") is None:
turn["tool_result"] = result.to_dict()
def _conversation_arguments(decision: Any) -> dict[str, Any]:
arguments = dict(decision.arguments)
if decision.purpose is not None:
arguments["purpose"] = decision.purpose
if decision.expected_outcome is not None:
arguments["expected_outcome"] = decision.expected_outcome
return arguments
def _accepts_history(method: Any) -> bool:
parameters = signature(method).parameters.values()
return any(
parameter.name == "history" or parameter.kind == Parameter.VAR_KEYWORD
for parameter in parameters
)
+2 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from core.models import Scene from core.models import Scene
@@ -18,6 +18,7 @@ class TaskContext:
scenes: list[Scene] = field(default_factory=list) scenes: list[Scene] = field(default_factory=list)
step_results: list["StepResult"] = field(default_factory=list) step_results: list["StepResult"] = field(default_factory=list)
world: "WorldState | None" = None world: "WorldState | None" = None
planner_history: list[dict[str, Any]] = field(default_factory=list)
def add_scene(self, scene: Scene) -> None: def add_scene(self, scene: Scene) -> None:
self.scenes.append(scene) self.scenes.append(scene)
+5 -1
View File
@@ -124,7 +124,7 @@ def default_tool_registry(
from tools.tap import tap from tools.tap import tap
from tools.ui_tree import get_ui_tree from tools.ui_tree import get_ui_tree
return { registry = {
"take_screenshot": _bind_manager(take_screenshot, manager), "take_screenshot": _bind_manager(take_screenshot, manager),
"screenshot": _bind_manager(take_screenshot, manager), "screenshot": _bind_manager(take_screenshot, manager),
"tap": _bind_manager(tap, manager), "tap": _bind_manager(tap, manager),
@@ -143,6 +143,10 @@ def default_tool_registry(
"find_icon": find_icon, "find_icon": find_icon,
"find_icon_on_screen": _bind_manager(find_icon_on_screen, manager), "find_icon_on_screen": _bind_manager(find_icon_on_screen, manager),
} }
if manager is not None:
registry["list_devices"] = lambda: [device.to_dict() for device in manager.list_devices()]
registry["device_status"] = lambda device_id: {"device_id": device_id, "status": manager.status(device_id)}
return registry
def _bind_manager(func: ToolCallable, manager: DeviceManager | None) -> ToolCallable: def _bind_manager(func: ToolCallable, manager: DeviceManager | None) -> ToolCallable:
+38
View File
@@ -8,14 +8,20 @@ DEFAULT_PROVIDER = "anthropic"
DEFAULT_MODEL_BY_PROVIDER = { DEFAULT_MODEL_BY_PROVIDER = {
"anthropic": "claude-sonnet-5", "anthropic": "claude-sonnet-5",
"openai": "gpt-5.6", "openai": "gpt-5.6",
"openai_compatible": "local-model",
} }
DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_HISTORY_MAX_TURNS = 20
ENABLED_ENV = "AI_PLANNER_ENABLED" ENABLED_ENV = "AI_PLANNER_ENABLED"
PROVIDER_ENV = "AI_PLANNER_PROVIDER" PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL" MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS" TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS" THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS"
API_KEY_ENV = "AI_PLANNER_API_KEY"
BASE_URL_ENV = "AI_PLANNER_BASE_URL"
MULTIMODAL_ENV = "AI_PLANNER_MULTIMODAL"
HISTORY_MAX_TURNS_ENV = "AI_PLANNER_HISTORY_MAX_TURNS"
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER) SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@@ -27,6 +33,10 @@ class PlannerConfig:
model: str = "" model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS timeout: float = DEFAULT_TIMEOUT_SECONDS
thinking_budget_tokens: int | None = None thinking_budget_tokens: int | None = None
api_key: str | None = None
base_url: str | None = None
multimodal: bool = False
history_max_turns: int = DEFAULT_HISTORY_MAX_TURNS
def resolved_model(self) -> str: def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider] return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
@@ -40,6 +50,13 @@ def load_config(env: Mapping[str, str] | None = None) -> PlannerConfig:
model=values.get(MODEL_ENV) or "", model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)), timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_ENV)), thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_ENV)),
api_key=values.get(API_KEY_ENV) or _provider_key(values),
base_url=values.get(BASE_URL_ENV) or None,
multimodal=_parse_bool(values.get(MULTIMODAL_ENV), default=False),
history_max_turns=_parse_positive_int(
values.get(HISTORY_MAX_TURNS_ENV),
default=DEFAULT_HISTORY_MAX_TURNS,
),
) )
@@ -53,6 +70,8 @@ def _parse_provider(value: str | None) -> str:
if value is None: if value is None:
return DEFAULT_PROVIDER return DEFAULT_PROVIDER
provider = value.strip().lower() provider = value.strip().lower()
if provider in {"openai-compatible", "openai_compatible", "local"}:
return "openai_compatible"
return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER
@@ -74,3 +93,22 @@ def _parse_thinking_budget(value: str | None) -> int | None:
except ValueError: except ValueError:
return None return None
return budget if budget > 0 else None return budget if budget > 0 else None
def _parse_positive_int(value: str | None, *, default: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError:
return default
return parsed if parsed > 0 else default
def _provider_key(values: Mapping[str, str]) -> str | None:
provider = (values.get(PROVIDER_ENV) or DEFAULT_PROVIDER).strip().lower()
if provider == "openai":
return values.get("OPENAI_API_KEY") or None
if provider == "anthropic":
return values.get("ANTHROPIC_API_KEY") or None
return None
-3
View File
@@ -66,7 +66,6 @@ def planner_user_prompt(
*, *,
goal: str, goal: str,
scene_json: dict[str, Any], scene_json: dict[str, Any],
history_summary: list[dict[str, Any]],
device_platform: str | None = None, device_platform: str | None = None,
now: datetime | None = None, now: datetime | None = None,
) -> str: ) -> str:
@@ -83,8 +82,6 @@ def planner_user_prompt(
f"{goal}\n\n" f"{goal}\n\n"
"Current Scene (JSON):\n" "Current Scene (JSON):\n"
f"{json.dumps(scene_json, ensure_ascii=False, sort_keys=True)}\n\n" f"{json.dumps(scene_json, ensure_ascii=False, sort_keys=True)}\n\n"
"Recent history, oldest first (JSON):\n"
f"{json.dumps(history_summary, ensure_ascii=False, sort_keys=True)}\n\n"
"Call exactly one tool for this turn." "Call exactly one tool for this turn."
) )
+12 -7
View File
@@ -187,13 +187,18 @@ class TaskRunner:
"failed", "failed",
result.error or "step failed", result.error or "step failed",
) )
self._update_task( # AI planners can use the structured failure feedback to
task, # correct malformed arguments or choose another action.
status="failed", # Deterministic planners retain their fail-fast behavior.
completed=True, if self.planner.__class__.__name__ != "AIPlanner":
failure_reason=result.error or "step failed", self._update_task(
) task,
return task status="failed",
completed=True,
failure_reason=result.error or "step failed",
)
return task
break
self._emit_step_progress( self._emit_step_progress(
len(context.step_results), len(context.step_results),
+93 -6
View File
@@ -51,6 +51,7 @@ class ToolCallingClient(Protocol):
screenshot: bytes | None, screenshot: bytes | None,
tools: list[ToolSpec], tools: list[ToolSpec],
timeout: float, timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision: ... ) -> ToolCallDecision: ...
@@ -80,6 +81,7 @@ class AnthropicToolCallingClient:
screenshot: bytes | None, screenshot: bytes | None,
tools: list[ToolSpec], tools: list[ToolSpec],
timeout: float, timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision: ) -> ToolCallDecision:
try: try:
response = self._create_message( response = self._create_message(
@@ -87,6 +89,7 @@ class AnthropicToolCallingClient:
user_prompt, user_prompt,
screenshot, screenshot,
tools, tools,
history=history,
timeout=timeout, timeout=timeout,
forced=False, forced=False,
) )
@@ -103,6 +106,7 @@ class AnthropicToolCallingClient:
user_prompt, user_prompt,
screenshot, screenshot,
tools, tools,
history=history,
timeout=timeout, timeout=timeout,
forced=True, forced=True,
) )
@@ -123,6 +127,7 @@ class AnthropicToolCallingClient:
screenshot: bytes | None, screenshot: bytes | None,
tools: list[ToolSpec], tools: list[ToolSpec],
*, *,
history: list[dict[str, Any]] | None,
timeout: float, timeout: float,
forced: bool, forced: bool,
) -> Any: ) -> Any:
@@ -146,10 +151,8 @@ class AnthropicToolCallingClient:
} }
], ],
"messages": [ "messages": [
{ *_anthropic_history(history or []),
"role": "user", {"role": "user", "content": _anthropic_content(user_prompt, screenshot)},
"content": _anthropic_content(user_prompt, screenshot),
}
], ],
"tools": [_anthropic_tool(spec) for spec in tools], "tools": [_anthropic_tool(spec) for spec in tools],
"tool_choice": { "tool_choice": {
@@ -207,6 +210,7 @@ class OpenAIToolCallingClient:
screenshot: bytes | None, screenshot: bytes | None,
tools: list[ToolSpec], tools: list[ToolSpec],
timeout: float, timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision: ) -> ToolCallDecision:
try: try:
response = self._create_completion( response = self._create_completion(
@@ -214,6 +218,7 @@ class OpenAIToolCallingClient:
user_prompt, user_prompt,
screenshot, screenshot,
tools, tools,
history=history,
timeout=timeout, timeout=timeout,
forced=False, forced=False,
) )
@@ -227,6 +232,7 @@ class OpenAIToolCallingClient:
user_prompt, user_prompt,
screenshot, screenshot,
tools, tools,
history=history,
timeout=timeout, timeout=timeout,
forced=True, forced=True,
) )
@@ -247,6 +253,7 @@ class OpenAIToolCallingClient:
screenshot: bytes | None, screenshot: bytes | None,
tools: list[ToolSpec], tools: list[ToolSpec],
*, *,
history: list[dict[str, Any]] | None,
timeout: float, timeout: float,
forced: bool, forced: bool,
) -> Any: ) -> Any:
@@ -257,6 +264,7 @@ class OpenAIToolCallingClient:
"timeout": timeout, "timeout": timeout,
"messages": [ "messages": [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
*_openai_history(history or []),
{"role": "user", "content": _openai_content(user_prompt, screenshot)}, {"role": "user", "content": _openai_content(user_prompt, screenshot)},
], ],
"tools": [_openai_tool(spec) for spec in tools], "tools": [_openai_tool(spec) for spec in tools],
@@ -288,10 +296,14 @@ class OpenAIToolCallingClient:
def build_client(config: PlannerConfig) -> ToolCallingClient: def build_client(config: PlannerConfig) -> ToolCallingClient:
model = config.resolved_model() model = config.resolved_model()
if config.provider == "openai": if config.provider in {"openai", "openai_compatible"}:
return OpenAIToolCallingClient(model=model) return OpenAIToolCallingClient(
model=model, api_key=config.api_key, base_url=config.base_url
)
return AnthropicToolCallingClient( return AnthropicToolCallingClient(
model=model, model=model,
api_key=config.api_key,
base_url=config.base_url,
thinking_budget_tokens=config.thinking_budget_tokens, thinking_budget_tokens=config.thinking_budget_tokens,
) )
@@ -316,6 +328,44 @@ def _anthropic_content(
return content return content
def _anthropic_history(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
for index, turn in enumerate(history):
result = turn.get("tool_result")
if result is None:
continue
tool_use_id = f"planner-turn-{index}"
assistant_content: list[dict[str, Any]] = []
rationale = turn.get("rationale")
if isinstance(rationale, str) and rationale:
assistant_content.append({"type": "text", "text": rationale})
assistant_content.append(
{
"type": "tool_use",
"id": tool_use_id,
"name": turn["tool_name"],
"input": dict(turn.get("arguments") or {}),
}
)
messages.extend(
[
{"role": "user", "content": turn["user_prompt"]},
{"role": "assistant", "content": assistant_content},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": json.dumps(result, ensure_ascii=False, default=str),
}
],
},
]
)
return messages
def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]: def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
return { return {
"name": spec.name, "name": spec.name,
@@ -389,6 +439,43 @@ def _openai_content(
] ]
def _openai_history(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
for index, turn in enumerate(history):
result = turn.get("tool_result")
if result is None:
continue
tool_call_id = f"planner-turn-{index}"
messages.extend(
[
{"role": "user", "content": turn["user_prompt"]},
{
"role": "assistant",
"content": turn.get("rationale"),
"tool_calls": [
{
"id": tool_call_id,
"type": "function",
"function": {
"name": turn["tool_name"],
"arguments": json.dumps(
turn.get("arguments") or {},
ensure_ascii=False,
),
},
}
],
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": json.dumps(result, ensure_ascii=False, default=str),
},
]
)
return messages
def _openai_tool(spec: ToolSpec) -> dict[str, Any]: def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
return { return {
"type": "function", "type": "function",
+20
View File
@@ -133,4 +133,24 @@ class TaskMetadataStore:
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path) connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
# A previous interrupted startup can leave a zero-byte SQLite file
# behind before ``__init__`` reaches ``_ensure_schema``. Ensure the
# table exists on every connection so the console can recover without
# manual deletion or database repair.
connection.execute(
"""
create table if not exists tasks (
id text primary key,
goal text not null,
device_id text not null,
status text not null,
created_at text not null,
updated_at text not null,
completed_at text,
failure_reason text,
source_task_id text,
source_attempt integer
)
"""
)
return connection return connection
+57 -53
View File
@@ -8,6 +8,7 @@ from core.errors import TaskFailedError
from core.models import Bounds, Scene, SceneElement from core.models import Bounds, Scene, SceneElement
from runtime.ai_planner import AIPlanner from runtime.ai_planner import AIPlanner
from runtime.context import TaskContext from runtime.context import TaskContext
from runtime.executor import StepResult
from runtime.planner_config import PlannerConfig from runtime.planner_config import PlannerConfig
from runtime.tool_calling_client import ToolCallDecision from runtime.tool_calling_client import ToolCallDecision
from runtime.tool_specs import ALL_TOOL_SPECS from runtime.tool_specs import ALL_TOOL_SPECS
@@ -26,6 +27,7 @@ class FakeToolCallingClient:
screenshot: bytes | None, screenshot: bytes | None,
tools: list[Any], tools: list[Any],
timeout: float, timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision: ) -> ToolCallDecision:
self.calls.append( self.calls.append(
{ {
@@ -34,6 +36,7 @@ class FakeToolCallingClient:
"screenshot": screenshot, "screenshot": screenshot,
"tools": tools, "tools": tools,
"timeout": timeout, "timeout": timeout,
"history": list(history) if history is not None else None,
} }
) )
return self.decision return self.decision
@@ -242,63 +245,64 @@ def test_ai_planner_propagates_rationale_and_thinking_to_planned_step() -> None:
assert steps[0].expected_outcome == "The account settings page is visible." assert steps[0].expected_outcome == "The account settings page is visible."
def test_history_summary_returns_compact_format() -> None: def test_ai_planner_carries_completed_turn_into_the_next_llm_call() -> None:
from collections import deque client = FakeToolCallingClient(
from runtime.ai_planner import _history_summary ToolCallDecision(
from world.models import WorldEvent, WorldState tool_name="tap",
arguments={"x": 1, "y": 2},
state = WorldState( text_output="Opening the send control.",
history=deque( purpose="Open the send control.",
[ expected_outcome="The composer is focused.",
WorldEvent(
action="tap",
success=True,
rationale="Opened settings.",
arguments={"x": 1, "y": 2},
purpose="Open settings.",
expected_outcome="Settings is visible.",
page="Home",
),
WorldEvent(
action="swipe",
success=False,
rationale=None,
arguments={"start_y": 700, "end_y": 200},
page="Settings",
),
]
) )
) )
planner = AIPlanner(client=client)
context = _context()
summary = _history_summary(state) first_step = planner.plan(goal=context.goal, scene=_scene(), context=context)[0]
context.add_step_result(
StepResult(
step=first_step,
success=True,
attempts=1,
result={"ok": True},
)
)
planner.plan(goal=context.goal, scene=_scene(), context=context)
assert summary == [ assert client.calls[0]["history"] == []
history = client.calls[1]["history"]
assert history is not None
assert history[0]["tool_name"] == "tap"
assert history[0]["arguments"] == {
"x": 1,
"y": 2,
"purpose": "Open the send control.",
"expected_outcome": "The composer is focused.",
}
assert history[0]["tool_result"]["success"] is True
assert history[0]["tool_result"]["result"] == {"ok": True}
def test_ai_planner_limits_history_sent_to_the_llm() -> None:
client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
planner = AIPlanner(client=client, config=PlannerConfig(history_max_turns=2))
context = _context()
context.planner_history.extend(
{ {
"page": "Home", "user_prompt": f"turn-{index}",
"action": "tap", "tool_name": "tap",
"arguments": {"x": 1, "y": 2}, "arguments": {},
"rationale": "Opened settings.",
"purpose": "Open settings.",
"expected_outcome": "Settings is visible.",
"success": True,
},
{
"page": "Settings",
"action": "swipe",
"arguments": {"start_y": 700, "end_y": 200},
"rationale": None, "rationale": None,
"purpose": None, "tool_result": {"success": True},
"expected_outcome": None, }
"success": False, for index in range(3)
}, )
planner.plan(goal=context.goal, scene=_scene(), context=context)
assert [turn["user_prompt"] for turn in client.calls[0]["history"]] == [
"turn-1",
"turn-2",
] ]
# Must not contain scene element data
for entry in summary:
assert "scene_summary" not in entry
assert "elements" not in entry
def test_history_summary_returns_empty_for_none_world() -> None:
from runtime.ai_planner import _history_summary
assert _history_summary(None) == []
+42
View File
@@ -30,3 +30,45 @@ def test_device_manager_marks_unreachable_device_offline() -> None:
manager.connect("iphone-1", max_retries=2, retry_backoff_seconds=0) manager.connect("iphone-1", max_retries=2, retry_backoff_seconds=0)
assert manager.status("iphone-1") == "offline" assert manager.status("iphone-1") == "offline"
def test_probe_marks_connected_device_offline_when_driver_is_unreachable() -> None:
class BrokenDriver:
def connect(self) -> None:
return None
def screenshot(self) -> bytes:
raise RuntimeError("WDA disconnected")
manager = DeviceManager()
manager.register_device("iphone-1", lambda: BrokenDriver()) # type: ignore[arg-type]
manager.connect("iphone-1")
assert manager.probe("iphone-1") is False
assert manager.status("iphone-1") == "offline"
def test_probe_uses_health_check_without_capturing_screen() -> None:
class HealthCheckedDriver:
def __init__(self) -> None:
self.health_checks = 0
self.screenshots = 0
def connect(self) -> None:
return None
def screenshot(self) -> bytes:
self.screenshots += 1
return b"screen"
def health_check(self) -> None:
self.health_checks += 1
driver = HealthCheckedDriver()
manager = DeviceManager()
manager.register_device("iphone-1", lambda: driver) # type: ignore[arg-type]
manager.connect("iphone-1")
assert manager.probe("iphone-1") is True
assert driver.health_checks == 1
assert driver.screenshots == 0
+9 -2
View File
@@ -9,7 +9,6 @@ def test_planner_user_prompt_includes_time_zone_and_configured_device_type() ->
prompt = planner_user_prompt( prompt = planner_user_prompt(
goal="open settings", goal="open settings",
scene_json={"screen": {"width": 1, "height": 1}, "elements": []}, scene_json={"screen": {"width": 1, "height": 1}, "elements": []},
history_summary=[],
device_platform="ios", device_platform="ios",
now=datetime( now=datetime(
2026, 2026,
@@ -35,8 +34,16 @@ def test_planner_user_prompt_uses_scene_platform_when_context_is_unavailable() -
"elements": [], "elements": [],
"app": {"platform": "android"}, "app": {"platform": "android"},
}, },
history_summary=[],
now=datetime(2026, 7, 16, tzinfo=timezone.utc), now=datetime(2026, 7, 16, tzinfo=timezone.utc),
) )
assert "Device type: android" in prompt assert "Device type: android" in prompt
def test_planner_user_prompt_does_not_duplicate_conversation_history() -> None:
prompt = planner_user_prompt(
goal="open settings",
scene_json={"screen": {"width": 1, "height": 1}, "elements": []},
)
assert "Recent history" not in prompt
+10
View File
@@ -0,0 +1,10 @@
from storage.task_metadata import TaskMetadataStore
def test_store_recovers_from_preexisting_empty_database(tmp_path) -> None:
path = tmp_path / "task_progress.sqlite3"
path.touch()
store = TaskMetadataStore(path)
assert store.list_tasks() == []