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.
Files changed (115) hide show
  1. minitap/mobile_use/__init__.py +0 -0
  2. minitap/mobile_use/agents/contextor/contextor.md +55 -0
  3. minitap/mobile_use/agents/contextor/contextor.py +175 -0
  4. minitap/mobile_use/agents/contextor/types.py +36 -0
  5. minitap/mobile_use/agents/cortex/cortex.md +135 -0
  6. minitap/mobile_use/agents/cortex/cortex.py +152 -0
  7. minitap/mobile_use/agents/cortex/types.py +15 -0
  8. minitap/mobile_use/agents/executor/executor.md +42 -0
  9. minitap/mobile_use/agents/executor/executor.py +87 -0
  10. minitap/mobile_use/agents/executor/tool_node.py +152 -0
  11. minitap/mobile_use/agents/hopper/hopper.md +15 -0
  12. minitap/mobile_use/agents/hopper/hopper.py +44 -0
  13. minitap/mobile_use/agents/orchestrator/human.md +12 -0
  14. minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
  15. minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
  16. minitap/mobile_use/agents/orchestrator/types.py +11 -0
  17. minitap/mobile_use/agents/outputter/human.md +25 -0
  18. minitap/mobile_use/agents/outputter/outputter.py +85 -0
  19. minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
  20. minitap/mobile_use/agents/planner/human.md +14 -0
  21. minitap/mobile_use/agents/planner/planner.md +126 -0
  22. minitap/mobile_use/agents/planner/planner.py +101 -0
  23. minitap/mobile_use/agents/planner/types.py +51 -0
  24. minitap/mobile_use/agents/planner/utils.py +70 -0
  25. minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
  26. minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
  27. minitap/mobile_use/agents/video_analyzer/human.md +5 -0
  28. minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
  29. minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
  30. minitap/mobile_use/clients/browserstack_client.py +477 -0
  31. minitap/mobile_use/clients/idb_client.py +429 -0
  32. minitap/mobile_use/clients/ios_client.py +332 -0
  33. minitap/mobile_use/clients/ios_client_config.py +141 -0
  34. minitap/mobile_use/clients/ui_automator_client.py +330 -0
  35. minitap/mobile_use/clients/wda_client.py +526 -0
  36. minitap/mobile_use/clients/wda_lifecycle.py +367 -0
  37. minitap/mobile_use/config.py +413 -0
  38. minitap/mobile_use/constants.py +3 -0
  39. minitap/mobile_use/context.py +106 -0
  40. minitap/mobile_use/controllers/__init__.py +0 -0
  41. minitap/mobile_use/controllers/android_controller.py +524 -0
  42. minitap/mobile_use/controllers/controller_factory.py +46 -0
  43. minitap/mobile_use/controllers/device_controller.py +182 -0
  44. minitap/mobile_use/controllers/ios_controller.py +436 -0
  45. minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
  46. minitap/mobile_use/controllers/types.py +106 -0
  47. minitap/mobile_use/controllers/unified_controller.py +193 -0
  48. minitap/mobile_use/graph/graph.py +160 -0
  49. minitap/mobile_use/graph/state.py +115 -0
  50. minitap/mobile_use/main.py +309 -0
  51. minitap/mobile_use/sdk/__init__.py +12 -0
  52. minitap/mobile_use/sdk/agent.py +1294 -0
  53. minitap/mobile_use/sdk/builders/__init__.py +10 -0
  54. minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
  55. minitap/mobile_use/sdk/builders/index.py +15 -0
  56. minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
  57. minitap/mobile_use/sdk/constants.py +1 -0
  58. minitap/mobile_use/sdk/examples/README.md +83 -0
  59. minitap/mobile_use/sdk/examples/__init__.py +1 -0
  60. minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
  61. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
  62. minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
  63. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
  64. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
  65. minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
  66. minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
  67. minitap/mobile_use/sdk/services/platform.py +434 -0
  68. minitap/mobile_use/sdk/types/__init__.py +51 -0
  69. minitap/mobile_use/sdk/types/agent.py +84 -0
  70. minitap/mobile_use/sdk/types/exceptions.py +138 -0
  71. minitap/mobile_use/sdk/types/platform.py +183 -0
  72. minitap/mobile_use/sdk/types/task.py +269 -0
  73. minitap/mobile_use/sdk/utils.py +29 -0
  74. minitap/mobile_use/services/accessibility.py +100 -0
  75. minitap/mobile_use/services/llm.py +247 -0
  76. minitap/mobile_use/services/telemetry.py +421 -0
  77. minitap/mobile_use/tools/index.py +67 -0
  78. minitap/mobile_use/tools/mobile/back.py +52 -0
  79. minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
  80. minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
  81. minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
  82. minitap/mobile_use/tools/mobile/launch_app.py +86 -0
  83. minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
  84. minitap/mobile_use/tools/mobile/open_link.py +62 -0
  85. minitap/mobile_use/tools/mobile/press_key.py +83 -0
  86. minitap/mobile_use/tools/mobile/stop_app.py +62 -0
  87. minitap/mobile_use/tools/mobile/swipe.py +156 -0
  88. minitap/mobile_use/tools/mobile/tap.py +154 -0
  89. minitap/mobile_use/tools/mobile/video_recording.py +177 -0
  90. minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
  91. minitap/mobile_use/tools/scratchpad.py +147 -0
  92. minitap/mobile_use/tools/test_utils.py +413 -0
  93. minitap/mobile_use/tools/tool_wrapper.py +16 -0
  94. minitap/mobile_use/tools/types.py +35 -0
  95. minitap/mobile_use/tools/utils.py +336 -0
  96. minitap/mobile_use/utils/app_launch_utils.py +173 -0
  97. minitap/mobile_use/utils/cli_helpers.py +37 -0
  98. minitap/mobile_use/utils/cli_selection.py +143 -0
  99. minitap/mobile_use/utils/conversations.py +31 -0
  100. minitap/mobile_use/utils/decorators.py +124 -0
  101. minitap/mobile_use/utils/errors.py +6 -0
  102. minitap/mobile_use/utils/file.py +13 -0
  103. minitap/mobile_use/utils/logger.py +183 -0
  104. minitap/mobile_use/utils/media.py +186 -0
  105. minitap/mobile_use/utils/recorder.py +52 -0
  106. minitap/mobile_use/utils/requests_utils.py +37 -0
  107. minitap/mobile_use/utils/shell_utils.py +20 -0
  108. minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
  109. minitap/mobile_use/utils/time.py +6 -0
  110. minitap/mobile_use/utils/ui_hierarchy.py +132 -0
  111. minitap/mobile_use/utils/video.py +281 -0
  112. minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
  113. minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
  114. minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
  115. 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
+ )