minitap-mobile-use 3.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minitap/mobile_use/__init__.py +0 -0
- minitap/mobile_use/agents/contextor/contextor.md +55 -0
- minitap/mobile_use/agents/contextor/contextor.py +175 -0
- minitap/mobile_use/agents/contextor/types.py +36 -0
- minitap/mobile_use/agents/cortex/cortex.md +135 -0
- minitap/mobile_use/agents/cortex/cortex.py +152 -0
- minitap/mobile_use/agents/cortex/types.py +15 -0
- minitap/mobile_use/agents/executor/executor.md +42 -0
- minitap/mobile_use/agents/executor/executor.py +87 -0
- minitap/mobile_use/agents/executor/tool_node.py +152 -0
- minitap/mobile_use/agents/hopper/hopper.md +15 -0
- minitap/mobile_use/agents/hopper/hopper.py +44 -0
- minitap/mobile_use/agents/orchestrator/human.md +12 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
- minitap/mobile_use/agents/orchestrator/types.py +11 -0
- minitap/mobile_use/agents/outputter/human.md +25 -0
- minitap/mobile_use/agents/outputter/outputter.py +85 -0
- minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
- minitap/mobile_use/agents/planner/human.md +14 -0
- minitap/mobile_use/agents/planner/planner.md +126 -0
- minitap/mobile_use/agents/planner/planner.py +101 -0
- minitap/mobile_use/agents/planner/types.py +51 -0
- minitap/mobile_use/agents/planner/utils.py +70 -0
- minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
- minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
- minitap/mobile_use/agents/video_analyzer/human.md +5 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
- minitap/mobile_use/clients/browserstack_client.py +477 -0
- minitap/mobile_use/clients/idb_client.py +429 -0
- minitap/mobile_use/clients/ios_client.py +332 -0
- minitap/mobile_use/clients/ios_client_config.py +141 -0
- minitap/mobile_use/clients/ui_automator_client.py +330 -0
- minitap/mobile_use/clients/wda_client.py +526 -0
- minitap/mobile_use/clients/wda_lifecycle.py +367 -0
- minitap/mobile_use/config.py +413 -0
- minitap/mobile_use/constants.py +3 -0
- minitap/mobile_use/context.py +106 -0
- minitap/mobile_use/controllers/__init__.py +0 -0
- minitap/mobile_use/controllers/android_controller.py +524 -0
- minitap/mobile_use/controllers/controller_factory.py +46 -0
- minitap/mobile_use/controllers/device_controller.py +182 -0
- minitap/mobile_use/controllers/ios_controller.py +436 -0
- minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
- minitap/mobile_use/controllers/types.py +106 -0
- minitap/mobile_use/controllers/unified_controller.py +193 -0
- minitap/mobile_use/graph/graph.py +160 -0
- minitap/mobile_use/graph/state.py +115 -0
- minitap/mobile_use/main.py +309 -0
- minitap/mobile_use/sdk/__init__.py +12 -0
- minitap/mobile_use/sdk/agent.py +1294 -0
- minitap/mobile_use/sdk/builders/__init__.py +10 -0
- minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
- minitap/mobile_use/sdk/builders/index.py +15 -0
- minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
- minitap/mobile_use/sdk/constants.py +1 -0
- minitap/mobile_use/sdk/examples/README.md +83 -0
- minitap/mobile_use/sdk/examples/__init__.py +1 -0
- minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
- minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
- minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
- minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
- minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
- minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
- minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
- minitap/mobile_use/sdk/services/platform.py +434 -0
- minitap/mobile_use/sdk/types/__init__.py +51 -0
- minitap/mobile_use/sdk/types/agent.py +84 -0
- minitap/mobile_use/sdk/types/exceptions.py +138 -0
- minitap/mobile_use/sdk/types/platform.py +183 -0
- minitap/mobile_use/sdk/types/task.py +269 -0
- minitap/mobile_use/sdk/utils.py +29 -0
- minitap/mobile_use/services/accessibility.py +100 -0
- minitap/mobile_use/services/llm.py +247 -0
- minitap/mobile_use/services/telemetry.py +421 -0
- minitap/mobile_use/tools/index.py +67 -0
- minitap/mobile_use/tools/mobile/back.py +52 -0
- minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
- minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
- minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
- minitap/mobile_use/tools/mobile/launch_app.py +86 -0
- minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
- minitap/mobile_use/tools/mobile/open_link.py +62 -0
- minitap/mobile_use/tools/mobile/press_key.py +83 -0
- minitap/mobile_use/tools/mobile/stop_app.py +62 -0
- minitap/mobile_use/tools/mobile/swipe.py +156 -0
- minitap/mobile_use/tools/mobile/tap.py +154 -0
- minitap/mobile_use/tools/mobile/video_recording.py +177 -0
- minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
- minitap/mobile_use/tools/scratchpad.py +147 -0
- minitap/mobile_use/tools/test_utils.py +413 -0
- minitap/mobile_use/tools/tool_wrapper.py +16 -0
- minitap/mobile_use/tools/types.py +35 -0
- minitap/mobile_use/tools/utils.py +336 -0
- minitap/mobile_use/utils/app_launch_utils.py +173 -0
- minitap/mobile_use/utils/cli_helpers.py +37 -0
- minitap/mobile_use/utils/cli_selection.py +143 -0
- minitap/mobile_use/utils/conversations.py +31 -0
- minitap/mobile_use/utils/decorators.py +124 -0
- minitap/mobile_use/utils/errors.py +6 -0
- minitap/mobile_use/utils/file.py +13 -0
- minitap/mobile_use/utils/logger.py +183 -0
- minitap/mobile_use/utils/media.py +186 -0
- minitap/mobile_use/utils/recorder.py +52 -0
- minitap/mobile_use/utils/requests_utils.py +37 -0
- minitap/mobile_use/utils/shell_utils.py +20 -0
- minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
- minitap/mobile_use/utils/time.py +6 -0
- minitap/mobile_use/utils/ui_hierarchy.py +132 -0
- minitap/mobile_use/utils/video.py +281 -0
- minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
- minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
- minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
- minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""Service for managing cloud device (virtual mobile) task execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from PIL import Image
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from minitap.mobile_use.config import settings
|
|
16
|
+
from minitap.mobile_use.sdk.types.exceptions import PlatformServiceError
|
|
17
|
+
from minitap.mobile_use.sdk.types.platform import TaskRunStatus
|
|
18
|
+
from minitap.mobile_use.sdk.types.task import PlatformTaskRequest
|
|
19
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunTaskRequest(BaseModel):
|
|
25
|
+
"""Request to run a task on a virtual mobile."""
|
|
26
|
+
|
|
27
|
+
task_request: dict[str, Any] = Field(..., alias="taskRequest", description="Task request")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RunTaskResponse(BaseModel):
|
|
31
|
+
"""Response from running a task on a virtual mobile."""
|
|
32
|
+
|
|
33
|
+
task_run_id: str = Field(..., alias="taskRunId", description="ID of the task run")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubgoalTimelineItemResponse(BaseModel):
|
|
37
|
+
"""A subgoal timeline item."""
|
|
38
|
+
|
|
39
|
+
name: str = Field(..., alias="name", description="Name of the subgoal")
|
|
40
|
+
state: str = Field(..., alias="state", description="State of the subgoal")
|
|
41
|
+
started_at: datetime | None = Field(
|
|
42
|
+
None,
|
|
43
|
+
alias="startedAt",
|
|
44
|
+
description="Start time of the subgoal",
|
|
45
|
+
)
|
|
46
|
+
ended_at: datetime | None = Field(
|
|
47
|
+
None,
|
|
48
|
+
alias="endedAt",
|
|
49
|
+
description="End time of the subgoal",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AgentThoughtTimelineItemResponse(BaseModel):
|
|
54
|
+
"""An agent thought timeline item."""
|
|
55
|
+
|
|
56
|
+
agent: str = Field(..., alias="agent", description="Agent who thought")
|
|
57
|
+
content: str = Field(..., alias="content", description="Content of the thought")
|
|
58
|
+
timestamp: datetime = Field(..., alias="timestamp", description="Timestamp of the thought")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TaskRunInfo(BaseModel):
|
|
62
|
+
"""Information about a task run."""
|
|
63
|
+
|
|
64
|
+
id: str
|
|
65
|
+
status: TaskRunStatus
|
|
66
|
+
status_message: str | None = None
|
|
67
|
+
output: str | dict[str, Any] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TimelineItem(BaseModel):
|
|
71
|
+
"""A timeline item (subgoal or agent thought)."""
|
|
72
|
+
|
|
73
|
+
timestamp: datetime
|
|
74
|
+
content: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
VMState = Literal["Stopped", "Starting", "Ready", "Error", "Stopping", "Unknown"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class VirtualMobileInfo(BaseModel):
|
|
81
|
+
"""Information about a virtual mobile."""
|
|
82
|
+
|
|
83
|
+
id: str
|
|
84
|
+
reference_name: str | None = None
|
|
85
|
+
state: VMState
|
|
86
|
+
message: str | None = None
|
|
87
|
+
platform: Literal["android", "ios"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CloudMobileService:
|
|
91
|
+
"""
|
|
92
|
+
Service for executing tasks on cloud mobiles.
|
|
93
|
+
|
|
94
|
+
This service handles:
|
|
95
|
+
- Starting and waiting for cloud mobiles to be ready
|
|
96
|
+
- Triggering task execution via the Platform
|
|
97
|
+
- Polling Platform API for task status and logs
|
|
98
|
+
- Handling task cancellation
|
|
99
|
+
- Timeout management for stalled tasks
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, api_key: str | None = None, http_timeout_seconds: int = 120):
|
|
103
|
+
self._platform_base_url = settings.MINITAP_BASE_URL
|
|
104
|
+
|
|
105
|
+
if api_key:
|
|
106
|
+
self._api_key = api_key
|
|
107
|
+
elif settings.MINITAP_API_KEY:
|
|
108
|
+
self._api_key = settings.MINITAP_API_KEY.get_secret_value()
|
|
109
|
+
else:
|
|
110
|
+
raise PlatformServiceError(
|
|
111
|
+
message="Please provide an API key or set MINITAP_API_KEY environment variable.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self._timeout = httpx.Timeout(timeout=http_timeout_seconds)
|
|
115
|
+
self._client = httpx.AsyncClient(
|
|
116
|
+
base_url=f"{self._platform_base_url}/api",
|
|
117
|
+
timeout=self._timeout,
|
|
118
|
+
headers={
|
|
119
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def start_and_wait_for_ready(
|
|
125
|
+
self,
|
|
126
|
+
cloud_mobile_id: str,
|
|
127
|
+
poll_interval_seconds: float = 5.0,
|
|
128
|
+
timeout_seconds: float = 300.0,
|
|
129
|
+
) -> VirtualMobileInfo:
|
|
130
|
+
"""
|
|
131
|
+
Start a cloud mobile by keeping it alive and wait for it to become ready.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
cloud_mobile_id: ID of the cloud mobile to start
|
|
135
|
+
poll_interval_seconds: Seconds between status polls (default: 5.0)
|
|
136
|
+
timeout_seconds: Maximum time to wait for ready state (default: 300.0)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
VirtualMobileInfo with the final state
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
PlatformServiceError: If the cloud mobile fails to start or times out
|
|
143
|
+
"""
|
|
144
|
+
logger.info(f"Starting cloud mobile '{cloud_mobile_id}'")
|
|
145
|
+
start_time = datetime.now(UTC)
|
|
146
|
+
|
|
147
|
+
while True:
|
|
148
|
+
# Check timeout
|
|
149
|
+
elapsed = (datetime.now(UTC) - start_time).total_seconds()
|
|
150
|
+
if elapsed > timeout_seconds:
|
|
151
|
+
raise PlatformServiceError(
|
|
152
|
+
message=f"Timeout waiting for cloud mobile to be ready after {timeout_seconds}s"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Trigger keep-alive to start the VM
|
|
156
|
+
await self._keep_alive(cloud_mobile_id)
|
|
157
|
+
# Get current status
|
|
158
|
+
vm_info = await self._get_virtual_mobile_status(cloud_mobile_id)
|
|
159
|
+
|
|
160
|
+
logger.info(
|
|
161
|
+
f"Cloud mobile '{cloud_mobile_id}' status: {vm_info.state} - {vm_info.message}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Check if ready
|
|
165
|
+
if vm_info.state == "Ready":
|
|
166
|
+
logger.success(f"Cloud mobile '{cloud_mobile_id}' is ready")
|
|
167
|
+
return vm_info
|
|
168
|
+
|
|
169
|
+
# Check for error state
|
|
170
|
+
if vm_info.state == "Error":
|
|
171
|
+
raise PlatformServiceError(
|
|
172
|
+
message=f"Cloud mobile entered error state: {vm_info.message}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Wait before next poll
|
|
176
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
177
|
+
|
|
178
|
+
async def _keep_alive(self, cloud_mobile_id: str) -> None:
|
|
179
|
+
"""Keep a cloud mobile alive to prevent idle shutdown."""
|
|
180
|
+
try:
|
|
181
|
+
response = await self._client.post(f"daas/virtual-mobiles/{cloud_mobile_id}/keep-alive")
|
|
182
|
+
response.raise_for_status()
|
|
183
|
+
logger.info(f"Keep-alive sent to cloud mobile '{cloud_mobile_id}'")
|
|
184
|
+
except httpx.HTTPStatusError as e:
|
|
185
|
+
raise PlatformServiceError(
|
|
186
|
+
message="Failed to keep cloud mobile alive: "
|
|
187
|
+
f"{e.response.status_code} - {e.response.text}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def _get_virtual_mobile_status(self, cloud_mobile_id: str) -> VirtualMobileInfo:
|
|
191
|
+
"""Get the current status of a cloud mobile."""
|
|
192
|
+
try:
|
|
193
|
+
response = await self._client.get(f"daas/virtual-mobiles/{cloud_mobile_id}")
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
data = response.json()
|
|
196
|
+
|
|
197
|
+
return VirtualMobileInfo(
|
|
198
|
+
id=data["id"],
|
|
199
|
+
reference_name=data.get("referenceName"),
|
|
200
|
+
state=data["state"].get("current", "Unknown"),
|
|
201
|
+
message=data["state"].get("message", "Unknown"),
|
|
202
|
+
platform=data["platform"],
|
|
203
|
+
)
|
|
204
|
+
except httpx.HTTPStatusError as e:
|
|
205
|
+
raise PlatformServiceError(
|
|
206
|
+
message="Failed to get cloud mobile status: "
|
|
207
|
+
f"{e.response.status_code} - {e.response.text}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def resolve_cloud_mobile_id(self, cloud_mobile_id_or_ref: str) -> str:
|
|
211
|
+
"""
|
|
212
|
+
Resolve a cloud mobile identifier (ID or reference name) to a cloud mobile UUID.
|
|
213
|
+
|
|
214
|
+
Uses the GetVirtualMobile endpoint which supports both UUID and reference name lookup.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
cloud_mobile_id_or_ref: Either a cloud mobile UUID or reference name
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The cloud mobile UUID
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
PlatformServiceError: If the cloud mobile is not found or resolution fails
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
response = await self._client.get(f"daas/virtual-mobiles/{cloud_mobile_id_or_ref}")
|
|
227
|
+
response.raise_for_status()
|
|
228
|
+
data = response.json()
|
|
229
|
+
|
|
230
|
+
resolved_id = data["id"]
|
|
231
|
+
logger.info(f"Resolved '{cloud_mobile_id_or_ref}' to UUID '{resolved_id}'")
|
|
232
|
+
return resolved_id
|
|
233
|
+
except httpx.HTTPStatusError as e:
|
|
234
|
+
raise PlatformServiceError(
|
|
235
|
+
message=f"Failed to resolve cloud mobile identifier '{cloud_mobile_id_or_ref}': "
|
|
236
|
+
f"{e.response.status_code} - {e.response.text}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def run_task_on_cloud_mobile(
|
|
240
|
+
self,
|
|
241
|
+
cloud_mobile_id: str,
|
|
242
|
+
request: PlatformTaskRequest,
|
|
243
|
+
on_status_update: Callable[[TaskRunStatus, str | None], None] | None = None,
|
|
244
|
+
on_log: Callable[[str], None] | None = None,
|
|
245
|
+
poll_interval_seconds: float = 2.0,
|
|
246
|
+
stall_timeout_seconds: float = 300.0,
|
|
247
|
+
locked_app_package: str | None = None,
|
|
248
|
+
enable_video_tools: bool = False,
|
|
249
|
+
) -> tuple[TaskRunStatus, str | None, Any | None]:
|
|
250
|
+
"""
|
|
251
|
+
Run a task on a cloud mobile and wait for completion.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
cloud_mobile_id: ID of the cloud mobile to run the task on
|
|
255
|
+
request: Platform task request to execute
|
|
256
|
+
on_status_update: Optional callback for status updates
|
|
257
|
+
on_log: Optional callback for log messages
|
|
258
|
+
poll_interval_seconds: Seconds between status polls (default: 2.0)
|
|
259
|
+
stall_timeout_seconds: Timeout if no new timeline activity (default: 300.0)
|
|
260
|
+
locked_app_package: Optional app package to lock for the task run
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Tuple of (final_status, error_message, output)
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
PlatformServiceError: If the task execution fails
|
|
267
|
+
"""
|
|
268
|
+
task_run_id: str | None = None
|
|
269
|
+
try:
|
|
270
|
+
# Step 1: Trigger the task run
|
|
271
|
+
logger.info(f"Starting task on cloud mobile '{cloud_mobile_id}'")
|
|
272
|
+
|
|
273
|
+
task_run_id = await self._trigger_task_run(
|
|
274
|
+
cloud_mobile_id=cloud_mobile_id,
|
|
275
|
+
request=request,
|
|
276
|
+
locked_app_package=locked_app_package,
|
|
277
|
+
enable_video_tools=enable_video_tools,
|
|
278
|
+
)
|
|
279
|
+
logger.info(f"Task run started: {task_run_id}")
|
|
280
|
+
|
|
281
|
+
# Step 2: Poll for completion
|
|
282
|
+
final_status, error, output = await self._poll_task_until_completion(
|
|
283
|
+
cloud_mobile_id=cloud_mobile_id,
|
|
284
|
+
task_run_id=task_run_id,
|
|
285
|
+
on_status_update=on_status_update,
|
|
286
|
+
on_log=on_log,
|
|
287
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
288
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return final_status, error, output
|
|
292
|
+
|
|
293
|
+
except asyncio.CancelledError:
|
|
294
|
+
# Task was cancelled locally - propagate to the Platform
|
|
295
|
+
logger.info("Task cancelled locally, propagating to the Platform")
|
|
296
|
+
if task_run_id is not None:
|
|
297
|
+
try:
|
|
298
|
+
await self.cancel_task_runs(cloud_mobile_id)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.warning(f"Failed to propagate cancellation: {e}")
|
|
301
|
+
raise
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"Failed to run task on cloud device: {str(e)}")
|
|
304
|
+
raise PlatformServiceError(message=f"Failed to run task on cloud device: {e}")
|
|
305
|
+
|
|
306
|
+
async def _trigger_task_run(
|
|
307
|
+
self,
|
|
308
|
+
cloud_mobile_id: str,
|
|
309
|
+
request: PlatformTaskRequest,
|
|
310
|
+
locked_app_package: str | None = None,
|
|
311
|
+
enable_video_tools: bool = False,
|
|
312
|
+
) -> str:
|
|
313
|
+
"""Trigger a task run on the Platform and return the task run ID."""
|
|
314
|
+
try:
|
|
315
|
+
# Build the task request payload
|
|
316
|
+
payload = RunTaskRequest(
|
|
317
|
+
taskRequest={
|
|
318
|
+
"profile": request.profile,
|
|
319
|
+
"task": (
|
|
320
|
+
request.task if isinstance(request.task, str) else request.task.model_dump()
|
|
321
|
+
),
|
|
322
|
+
"executionOrigin": request.execution_origin,
|
|
323
|
+
"lockedAppPackage": locked_app_package,
|
|
324
|
+
"maxSteps": request.max_steps,
|
|
325
|
+
"enableVideoTools": enable_video_tools,
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
response = await self._client.post(
|
|
330
|
+
f"daas/virtual-mobiles/{cloud_mobile_id}/run-task",
|
|
331
|
+
json=payload.model_dump(by_alias=True),
|
|
332
|
+
)
|
|
333
|
+
response.raise_for_status()
|
|
334
|
+
|
|
335
|
+
result = RunTaskResponse.model_validate(response.json())
|
|
336
|
+
return result.task_run_id
|
|
337
|
+
|
|
338
|
+
except httpx.HTTPStatusError as e:
|
|
339
|
+
raise PlatformServiceError(message=f"Failed to trigger task run on the Platform: {e}")
|
|
340
|
+
|
|
341
|
+
async def _poll_task_until_completion(
|
|
342
|
+
self,
|
|
343
|
+
cloud_mobile_id: str,
|
|
344
|
+
task_run_id: str,
|
|
345
|
+
on_status_update: Callable[[TaskRunStatus, str | None], None] | None,
|
|
346
|
+
on_log: Callable[[str], None] | None,
|
|
347
|
+
poll_interval_seconds: float,
|
|
348
|
+
stall_timeout_seconds: float,
|
|
349
|
+
) -> tuple[TaskRunStatus, str | None, Any | None]:
|
|
350
|
+
"""
|
|
351
|
+
Poll task run status until it completes, fails, or times out.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Tuple of (final_status, error_message, output)
|
|
355
|
+
"""
|
|
356
|
+
last_poll_time: datetime | None = None
|
|
357
|
+
last_activity_time = datetime.now(UTC)
|
|
358
|
+
current_status: TaskRunStatus = "pending"
|
|
359
|
+
previous_status: TaskRunStatus | None = None
|
|
360
|
+
|
|
361
|
+
while True:
|
|
362
|
+
# Check for stall timeout
|
|
363
|
+
now = datetime.now(UTC)
|
|
364
|
+
time_since_last_activity = (now - last_activity_time).total_seconds()
|
|
365
|
+
if time_since_last_activity > stall_timeout_seconds:
|
|
366
|
+
error_msg = (
|
|
367
|
+
f"Task stalled: No activity for {stall_timeout_seconds} seconds. "
|
|
368
|
+
"The task is considered failed."
|
|
369
|
+
)
|
|
370
|
+
logger.error(f"{error_msg} (task_run_id: {task_run_id})")
|
|
371
|
+
await self.cancel_task_runs(cloud_mobile_id)
|
|
372
|
+
return "cancelled", error_msg, None
|
|
373
|
+
|
|
374
|
+
# Fetch current task run status
|
|
375
|
+
task_info = await self._get_task_run_status(task_run_id)
|
|
376
|
+
current_status = task_info.status
|
|
377
|
+
|
|
378
|
+
# Notify status update
|
|
379
|
+
if previous_status != current_status and on_status_update:
|
|
380
|
+
previous_status = current_status
|
|
381
|
+
last_activity_time = now
|
|
382
|
+
try:
|
|
383
|
+
on_status_update(
|
|
384
|
+
task_info.status,
|
|
385
|
+
task_info.status_message,
|
|
386
|
+
)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
logger.warning(f"Status update callback failed: {e}")
|
|
389
|
+
|
|
390
|
+
# Check for subgoal updates
|
|
391
|
+
subgoal_updates = await self._get_subgoal_updates(
|
|
392
|
+
task_run_id=task_run_id,
|
|
393
|
+
after_timestamp=last_poll_time,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Check for new agent thoughts
|
|
397
|
+
new_thoughts = await self._get_new_agent_thoughts(
|
|
398
|
+
task_run_id=task_run_id,
|
|
399
|
+
after_timestamp=last_poll_time,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
updates = sorted(
|
|
403
|
+
subgoal_updates + new_thoughts,
|
|
404
|
+
key=lambda item: item.timestamp,
|
|
405
|
+
)
|
|
406
|
+
if updates:
|
|
407
|
+
last_activity_time = now
|
|
408
|
+
for update in updates:
|
|
409
|
+
if on_log:
|
|
410
|
+
try:
|
|
411
|
+
on_log(f"[{update.timestamp}] {update.content}")
|
|
412
|
+
except Exception as e:
|
|
413
|
+
logger.warning(f"Log callback failed: {e}")
|
|
414
|
+
|
|
415
|
+
# Check if task is in terminal state
|
|
416
|
+
if current_status in ["completed", "failed", "cancelled"]:
|
|
417
|
+
logger.info(f"Task '{task_run_id}' reached terminal state: {current_status}")
|
|
418
|
+
error = (
|
|
419
|
+
task_info.status_message if current_status in ["failed", "cancelled"] else None
|
|
420
|
+
)
|
|
421
|
+
return current_status, error, task_info.output
|
|
422
|
+
|
|
423
|
+
# Wait before next poll
|
|
424
|
+
last_poll_time = now
|
|
425
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
426
|
+
|
|
427
|
+
async def _get_task_run_status(self, task_run_id: str) -> TaskRunInfo:
|
|
428
|
+
"""Get the current status of a task run."""
|
|
429
|
+
try:
|
|
430
|
+
response = await self._client.get(f"v1/task-runs/{task_run_id}")
|
|
431
|
+
response.raise_for_status()
|
|
432
|
+
data = response.json()
|
|
433
|
+
|
|
434
|
+
output: str | dict[str, Any] | None = None
|
|
435
|
+
raw_output = data.get("output")
|
|
436
|
+
try:
|
|
437
|
+
if raw_output is not None:
|
|
438
|
+
output = json.loads(raw_output)
|
|
439
|
+
except json.JSONDecodeError:
|
|
440
|
+
output = raw_output
|
|
441
|
+
|
|
442
|
+
return TaskRunInfo(
|
|
443
|
+
id=data["id"],
|
|
444
|
+
status=data["status"],
|
|
445
|
+
status_message=data.get("statusMessage"),
|
|
446
|
+
output=output,
|
|
447
|
+
)
|
|
448
|
+
except httpx.HTTPStatusError as e:
|
|
449
|
+
raise PlatformServiceError(message=f"Failed to get task run status: {e}")
|
|
450
|
+
|
|
451
|
+
async def _get_subgoal_updates(
|
|
452
|
+
self,
|
|
453
|
+
task_run_id: str,
|
|
454
|
+
after_timestamp: datetime | None,
|
|
455
|
+
) -> list[TimelineItem]:
|
|
456
|
+
"""Get new subgoals from the timeline after a specific timestamp."""
|
|
457
|
+
try:
|
|
458
|
+
started_subgoals = await self._get_filtered_subgoals(
|
|
459
|
+
task_run_id=task_run_id,
|
|
460
|
+
sort_by="started_at",
|
|
461
|
+
sort_order="asc",
|
|
462
|
+
after_timestamp=after_timestamp,
|
|
463
|
+
)
|
|
464
|
+
ended_subgoals = await self._get_filtered_subgoals(
|
|
465
|
+
task_run_id=task_run_id,
|
|
466
|
+
sort_by="ended_at",
|
|
467
|
+
sort_order="asc",
|
|
468
|
+
after_timestamp=after_timestamp,
|
|
469
|
+
)
|
|
470
|
+
items: list[TimelineItem] = []
|
|
471
|
+
for subgoal in started_subgoals:
|
|
472
|
+
if subgoal.started_at is None:
|
|
473
|
+
continue
|
|
474
|
+
items.append(
|
|
475
|
+
TimelineItem(
|
|
476
|
+
timestamp=subgoal.started_at,
|
|
477
|
+
content=f"[START][{subgoal.name}] {subgoal.state}",
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
for subgoal in ended_subgoals:
|
|
481
|
+
if subgoal.ended_at is None:
|
|
482
|
+
continue
|
|
483
|
+
items.append(
|
|
484
|
+
TimelineItem(
|
|
485
|
+
timestamp=subgoal.ended_at,
|
|
486
|
+
content=f"[END][{subgoal.name}] {subgoal.state}",
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
return items
|
|
490
|
+
except httpx.HTTPStatusError as e:
|
|
491
|
+
logger.warning(f"Failed to get subgoals timeline: {e}")
|
|
492
|
+
return []
|
|
493
|
+
|
|
494
|
+
async def _get_filtered_subgoals(
|
|
495
|
+
self,
|
|
496
|
+
task_run_id: str,
|
|
497
|
+
sort_by: str,
|
|
498
|
+
sort_order: str,
|
|
499
|
+
after_timestamp: datetime | None,
|
|
500
|
+
) -> list[SubgoalTimelineItemResponse]:
|
|
501
|
+
params: dict[str, Any] = {
|
|
502
|
+
"page": 1,
|
|
503
|
+
"pageSize": 50,
|
|
504
|
+
"sortBy": sort_by,
|
|
505
|
+
"sortOrder": sort_order,
|
|
506
|
+
}
|
|
507
|
+
if after_timestamp:
|
|
508
|
+
params["after"] = after_timestamp.isoformat()
|
|
509
|
+
|
|
510
|
+
response = await self._client.get(
|
|
511
|
+
f"v1/task-runs/{task_run_id}/subgoals/timeline",
|
|
512
|
+
params=params,
|
|
513
|
+
)
|
|
514
|
+
response.raise_for_status()
|
|
515
|
+
data = response.json()
|
|
516
|
+
subgoals = [
|
|
517
|
+
SubgoalTimelineItemResponse.model_validate(item) for item in data.get("subgoals", [])
|
|
518
|
+
]
|
|
519
|
+
return subgoals
|
|
520
|
+
|
|
521
|
+
async def _get_new_agent_thoughts(
|
|
522
|
+
self, task_run_id: str, after_timestamp: datetime | None
|
|
523
|
+
) -> list[TimelineItem]:
|
|
524
|
+
"""Get new agent thoughts from the timeline after a specific timestamp."""
|
|
525
|
+
try:
|
|
526
|
+
params: dict[str, Any] = {"page": 1, "pageSize": 50}
|
|
527
|
+
if after_timestamp:
|
|
528
|
+
params["after"] = after_timestamp.isoformat()
|
|
529
|
+
|
|
530
|
+
response = await self._client.get(
|
|
531
|
+
f"v1/task-runs/{task_run_id}/agent-thoughts/timeline",
|
|
532
|
+
params=params,
|
|
533
|
+
)
|
|
534
|
+
response.raise_for_status()
|
|
535
|
+
data = response.json()
|
|
536
|
+
agent_thoughts = [
|
|
537
|
+
AgentThoughtTimelineItemResponse.model_validate(item)
|
|
538
|
+
for item in data.get("agentThoughts", [])
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
items: list[TimelineItem] = []
|
|
542
|
+
for thought in agent_thoughts:
|
|
543
|
+
items.append(
|
|
544
|
+
TimelineItem(
|
|
545
|
+
timestamp=thought.timestamp,
|
|
546
|
+
content=f"[{thought.agent}] {thought.content}",
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
return items
|
|
550
|
+
except httpx.HTTPStatusError as e:
|
|
551
|
+
logger.warning(f"Failed to get agent thoughts timeline: {e}")
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
async def cancel_task_runs(self, cloud_mobile_id: str) -> None:
|
|
555
|
+
"""Cancel all task runs on the Platform for a specific cloud mobile."""
|
|
556
|
+
try:
|
|
557
|
+
# Only one task can run on a cloud mobile at a time.
|
|
558
|
+
# Therefore, cancelling all tasks running on it implies cancelling the task run.
|
|
559
|
+
response = await self._client.post(
|
|
560
|
+
f"v1/task-runs/virtual-mobile/{cloud_mobile_id}/cancel"
|
|
561
|
+
)
|
|
562
|
+
response.raise_for_status()
|
|
563
|
+
logger.info(f"Task runs cancelled on cloud mobile '{cloud_mobile_id}'")
|
|
564
|
+
except httpx.HTTPStatusError as e:
|
|
565
|
+
raise PlatformServiceError(message=f"Failed to cancel task run: {e}")
|
|
566
|
+
|
|
567
|
+
async def get_screenshot(self, cloud_mobile_id: str) -> Image.Image:
|
|
568
|
+
"""
|
|
569
|
+
Get a screenshot from a cloud mobile.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
cloud_mobile_id: ID of the cloud mobile to capture screenshot from
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Screenshot as PIL Image
|
|
576
|
+
|
|
577
|
+
Raises:
|
|
578
|
+
PlatformServiceError: If the screenshot capture fails
|
|
579
|
+
"""
|
|
580
|
+
try:
|
|
581
|
+
logger.info(f"Capturing screenshot from cloud mobile '{cloud_mobile_id}'")
|
|
582
|
+
response = await self._client.get(f"daas/virtual-mobiles/{cloud_mobile_id}/screenshot")
|
|
583
|
+
response.raise_for_status()
|
|
584
|
+
|
|
585
|
+
# Convert bytes to PIL Image
|
|
586
|
+
image = Image.open(BytesIO(response.content))
|
|
587
|
+
|
|
588
|
+
size_bytes = len(response.content)
|
|
589
|
+
logger.info(
|
|
590
|
+
f"Screenshot captured from cloud mobile '{cloud_mobile_id}' ({size_bytes} bytes)"
|
|
591
|
+
)
|
|
592
|
+
return image
|
|
593
|
+
except httpx.HTTPStatusError as e:
|
|
594
|
+
raise PlatformServiceError(
|
|
595
|
+
message=f"Failed to get screenshot from cloud mobile: "
|
|
596
|
+
f"{e.response.status_code} - {e.response.text}"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
async def install_apk(self, cloud_mobile_id: str, apk_path: Path) -> None:
|
|
600
|
+
"""
|
|
601
|
+
Upload and install an APK on a cloud mobile device.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
cloud_mobile_id: ID of the cloud mobile to install the APK on
|
|
605
|
+
apk_path: Path to the local APK file to install
|
|
606
|
+
|
|
607
|
+
Raises:
|
|
608
|
+
FileNotFoundError: If APK file doesn't exist
|
|
609
|
+
PlatformServiceError: If upload or installation fails
|
|
610
|
+
"""
|
|
611
|
+
if not apk_path.exists():
|
|
612
|
+
raise FileNotFoundError(f"APK file not found: {apk_path}")
|
|
613
|
+
|
|
614
|
+
filename = apk_path.name
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
# Step 1: Get signed upload URL from storage API
|
|
618
|
+
logger.info(f"Getting signed upload URL for APK '{filename}'")
|
|
619
|
+
response = await self._client.get(
|
|
620
|
+
"v1/storage/signed-upload",
|
|
621
|
+
params={"filenames": filename},
|
|
622
|
+
)
|
|
623
|
+
response.raise_for_status()
|
|
624
|
+
upload_data = response.json()
|
|
625
|
+
|
|
626
|
+
signed_urls = upload_data.get("signed_urls", {})
|
|
627
|
+
if filename not in signed_urls:
|
|
628
|
+
raise PlatformServiceError(message=f"No signed URL returned for {filename}")
|
|
629
|
+
|
|
630
|
+
signed_url = signed_urls[filename]
|
|
631
|
+
|
|
632
|
+
# Step 2: Upload APK to signed URL
|
|
633
|
+
logger.info("Uploading APK to cloud storage")
|
|
634
|
+
async with httpx.AsyncClient(timeout=300.0) as upload_client:
|
|
635
|
+
with open(apk_path, "rb") as f:
|
|
636
|
+
upload_response = await upload_client.put(
|
|
637
|
+
signed_url,
|
|
638
|
+
content=f.read(),
|
|
639
|
+
headers={"Content-Type": "application/vnd.android.package-archive"},
|
|
640
|
+
)
|
|
641
|
+
upload_response.raise_for_status()
|
|
642
|
+
|
|
643
|
+
# Step 3: Install APK on cloud mobile
|
|
644
|
+
logger.info(f"Installing APK on cloud mobile '{cloud_mobile_id}'")
|
|
645
|
+
install_response = await self._client.post(
|
|
646
|
+
f"daas/virtual-mobiles/{cloud_mobile_id}/install-apk",
|
|
647
|
+
json={"filename": filename},
|
|
648
|
+
)
|
|
649
|
+
install_response.raise_for_status()
|
|
650
|
+
logger.info(f"APK installed successfully on cloud mobile '{cloud_mobile_id}'")
|
|
651
|
+
|
|
652
|
+
except httpx.HTTPStatusError as e:
|
|
653
|
+
raise PlatformServiceError(
|
|
654
|
+
message=f"Failed to install APK on cloud mobile: "
|
|
655
|
+
f"{e.response.status_code} - {e.response.text}"
|
|
656
|
+
)
|