minitap-mobile-use 2.7.1__py3-none-any.whl → 2.8.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.

Potentially problematic release.


This version of minitap-mobile-use might be problematic. Click here for more details.

Files changed (27) hide show
  1. minitap/mobile_use/agents/cortex/cortex.py +2 -6
  2. minitap/mobile_use/agents/executor/executor.py +2 -6
  3. minitap/mobile_use/agents/executor/tool_node.py +31 -6
  4. minitap/mobile_use/agents/hopper/hopper.py +2 -6
  5. minitap/mobile_use/agents/orchestrator/orchestrator.py +2 -6
  6. minitap/mobile_use/agents/outputter/outputter.py +2 -4
  7. minitap/mobile_use/agents/planner/planner.py +2 -2
  8. minitap/mobile_use/agents/screen_analyzer/screen_analyzer.py +2 -6
  9. minitap/mobile_use/config.py +1 -1
  10. minitap/mobile_use/graph/graph.py +6 -2
  11. minitap/mobile_use/main.py +2 -2
  12. minitap/mobile_use/sdk/agent.py +212 -12
  13. minitap/mobile_use/sdk/builders/agent_config_builder.py +43 -9
  14. minitap/mobile_use/sdk/examples/README.md +1 -1
  15. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +2 -2
  16. minitap/mobile_use/sdk/examples/platform_minimal_example.py +2 -3
  17. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +2 -2
  18. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +2 -2
  19. minitap/mobile_use/sdk/services/cloud_mobile.py +582 -0
  20. minitap/mobile_use/sdk/types/agent.py +3 -0
  21. minitap/mobile_use/sdk/types/exceptions.py +7 -0
  22. minitap/mobile_use/sdk/types/task.py +0 -3
  23. minitap/mobile_use/services/llm.py +0 -2
  24. {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/METADATA +1 -1
  25. {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/RECORD +27 -26
  26. {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/WHEEL +0 -0
  27. {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/entry_points.txt +0 -0
@@ -33,7 +33,7 @@ async def main() -> None:
33
33
 
34
34
  try:
35
35
  # Initialize agent (finds a device, starts required servers)
36
- agent.init()
36
+ await agent.init()
37
37
 
38
38
  # Calculate yesterday's date for the example
39
39
  yesterday = date.today() - timedelta(days=1)
@@ -69,7 +69,7 @@ async def main() -> None:
69
69
  print(f"Error: {e}")
70
70
  finally:
71
71
  # Always clean up resources
72
- agent.clean()
72
+ await agent.clean()
73
73
 
74
74
 
75
75
  if __name__ == "__main__":
@@ -167,7 +167,7 @@ async def main():
167
167
 
168
168
  try:
169
169
  # Initialize agent (finds a device, starts required servers)
170
- agent.init()
170
+ await agent.init()
171
171
 
172
172
  print("Checking for notifications...")
173
173
 
@@ -217,7 +217,7 @@ async def main():
217
217
 
218
218
  finally:
219
219
  # Clean up
220
- agent.clean()
220
+ await agent.clean()
221
221
  print(f"\nTraces saved to: {traces_dir}")
222
222
 
223
223
 
@@ -0,0 +1,582 @@
1
+ """Service for managing cloud device (virtual mobile) task execution."""
2
+
3
+ import asyncio
4
+ from datetime import UTC, datetime
5
+ from io import BytesIO
6
+ import json
7
+ from typing import Any, Literal
8
+ from collections.abc import Callable
9
+
10
+ import httpx
11
+ from PIL import Image
12
+ from pydantic import BaseModel, Field
13
+
14
+ from minitap.mobile_use.config import settings
15
+ from minitap.mobile_use.sdk.types.exceptions import PlatformServiceError
16
+ from minitap.mobile_use.sdk.types.platform import TaskRunStatus
17
+ from minitap.mobile_use.sdk.types.task import PlatformTaskRequest
18
+ from minitap.mobile_use.utils.logger import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ class RunTaskRequest(BaseModel):
24
+ """Request to run a task on a virtual mobile."""
25
+
26
+ task_request: dict[str, Any] = Field(..., alias="taskRequest", description="Task request")
27
+
28
+
29
+ class RunTaskResponse(BaseModel):
30
+ """Response from running a task on a virtual mobile."""
31
+
32
+ task_run_id: str = Field(..., alias="taskRunId", description="ID of the task run")
33
+
34
+
35
+ class SubgoalTimelineItemResponse(BaseModel):
36
+ """A subgoal timeline item."""
37
+
38
+ name: str = Field(..., alias="name", description="Name of the subgoal")
39
+ state: str = Field(..., alias="state", description="State of the subgoal")
40
+ started_at: datetime | None = Field(
41
+ None,
42
+ alias="startedAt",
43
+ description="Start time of the subgoal",
44
+ )
45
+ ended_at: datetime | None = Field(
46
+ None,
47
+ alias="endedAt",
48
+ description="End time of the subgoal",
49
+ )
50
+
51
+
52
+ class AgentThoughtTimelineItemResponse(BaseModel):
53
+ """An agent thought timeline item."""
54
+
55
+ agent: str = Field(..., alias="agent", description="Agent who thought")
56
+ content: str = Field(..., alias="content", description="Content of the thought")
57
+ timestamp: datetime = Field(..., alias="timestamp", description="Timestamp of the thought")
58
+
59
+
60
+ class TaskRunInfo(BaseModel):
61
+ """Information about a task run."""
62
+
63
+ id: str
64
+ status: TaskRunStatus
65
+ status_message: str | None = None
66
+ output: str | dict[str, Any] | None = None
67
+
68
+
69
+ class TimelineItem(BaseModel):
70
+ """A timeline item (subgoal or agent thought)."""
71
+
72
+ timestamp: datetime
73
+ content: str
74
+
75
+
76
+ VMState = Literal["Stopped", "Starting", "Ready", "Error", "Stopping", "Unknown"]
77
+
78
+
79
+ class VirtualMobileInfo(BaseModel):
80
+ """Information about a virtual mobile."""
81
+
82
+ id: str
83
+ reference_name: str | None = None
84
+ state: VMState
85
+ message: str | None = None
86
+
87
+
88
+ class CloudMobileService:
89
+ """
90
+ Service for executing tasks on cloud mobiles.
91
+
92
+ This service handles:
93
+ - Starting and waiting for cloud mobiles to be ready
94
+ - Triggering task execution via the Platform
95
+ - Polling Platform API for task status and logs
96
+ - Handling task cancellation
97
+ - Timeout management for stalled tasks
98
+ """
99
+
100
+ def __init__(self, api_key: str | None = None, http_timeout_seconds: int = 120):
101
+ self._platform_base_url = settings.MINITAP_BASE_URL
102
+
103
+ if api_key:
104
+ self._api_key = api_key
105
+ elif settings.MINITAP_API_KEY:
106
+ self._api_key = settings.MINITAP_API_KEY.get_secret_value()
107
+ else:
108
+ raise PlatformServiceError(
109
+ message="Please provide an API key or set MINITAP_API_KEY environment variable.",
110
+ )
111
+
112
+ self._timeout = httpx.Timeout(timeout=http_timeout_seconds)
113
+ self._client = httpx.AsyncClient(
114
+ base_url=f"{self._platform_base_url}/api",
115
+ timeout=self._timeout,
116
+ headers={
117
+ "Authorization": f"Bearer {self._api_key}",
118
+ "Content-Type": "application/json",
119
+ },
120
+ )
121
+
122
+ async def start_and_wait_for_ready(
123
+ self,
124
+ cloud_mobile_id: str,
125
+ poll_interval_seconds: float = 5.0,
126
+ timeout_seconds: float = 300.0,
127
+ ) -> VirtualMobileInfo:
128
+ """
129
+ Start a cloud mobile by keeping it alive and wait for it to become ready.
130
+
131
+ Args:
132
+ cloud_mobile_id: ID of the cloud mobile to start
133
+ poll_interval_seconds: Seconds between status polls (default: 5.0)
134
+ timeout_seconds: Maximum time to wait for ready state (default: 300.0)
135
+
136
+ Returns:
137
+ VirtualMobileInfo with the final state
138
+
139
+ Raises:
140
+ PlatformServiceError: If the cloud mobile fails to start or times out
141
+ """
142
+ logger.info(f"Starting cloud mobile '{cloud_mobile_id}'")
143
+
144
+ # Step 1: Trigger keep-alive to start the VM
145
+ await self._keep_alive(cloud_mobile_id)
146
+
147
+ # Step 2: Poll until ready or timeout
148
+ start_time = datetime.now(UTC)
149
+
150
+ while True:
151
+ # Check timeout
152
+ elapsed = (datetime.now(UTC) - start_time).total_seconds()
153
+ if elapsed > timeout_seconds:
154
+ raise PlatformServiceError(
155
+ message=f"Timeout waiting for cloud mobile to be ready after {timeout_seconds}s"
156
+ )
157
+
158
+ # Get current status
159
+ vm_info = await self._get_virtual_mobile_status(cloud_mobile_id)
160
+
161
+ logger.info(
162
+ f"Cloud mobile '{cloud_mobile_id}' status: {vm_info.state} - {vm_info.message}"
163
+ )
164
+
165
+ # Check if ready
166
+ if vm_info.state == "Ready":
167
+ logger.success(f"Cloud mobile '{cloud_mobile_id}' is ready")
168
+ return vm_info
169
+
170
+ # Check for error state
171
+ if vm_info.state == "Error":
172
+ raise PlatformServiceError(
173
+ message=f"Cloud mobile entered error state: {vm_info.message}"
174
+ )
175
+
176
+ # Wait before next poll
177
+ await asyncio.sleep(poll_interval_seconds)
178
+
179
+ async def _keep_alive(self, cloud_mobile_id: str) -> None:
180
+ """Keep a cloud mobile alive to prevent idle shutdown."""
181
+ try:
182
+ response = await self._client.post(f"daas/virtual-mobiles/{cloud_mobile_id}/keep-alive")
183
+ response.raise_for_status()
184
+ logger.info(f"Keep-alive sent to cloud mobile '{cloud_mobile_id}'")
185
+ except httpx.HTTPStatusError as e:
186
+ raise PlatformServiceError(
187
+ message="Failed to keep cloud mobile alive: "
188
+ f"{e.response.status_code} - {e.response.text}"
189
+ )
190
+
191
+ async def _get_virtual_mobile_status(self, cloud_mobile_id: str) -> VirtualMobileInfo:
192
+ """Get the current status of a cloud mobile."""
193
+ try:
194
+ response = await self._client.get(f"daas/virtual-mobiles/{cloud_mobile_id}")
195
+ response.raise_for_status()
196
+ data = response.json()
197
+
198
+ return VirtualMobileInfo(
199
+ id=data["id"],
200
+ reference_name=data.get("referenceName"),
201
+ state=data["state"].get("current", "Unknown"),
202
+ message=data["state"].get("message", "Unknown"),
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
+ ) -> tuple[TaskRunStatus, str | None, Any | None]:
248
+ """
249
+ Run a task on a cloud mobile and wait for completion.
250
+
251
+ Args:
252
+ cloud_mobile_id: ID of the cloud mobile to run the task on
253
+ request: Platform task request to execute
254
+ on_status_update: Optional callback for status updates
255
+ on_log: Optional callback for log messages
256
+ poll_interval_seconds: Seconds between status polls (default: 2.0)
257
+ stall_timeout_seconds: Timeout if no new timeline activity (default: 300.0)
258
+
259
+ Returns:
260
+ Tuple of (final_status, error_message, output)
261
+
262
+ Raises:
263
+ PlatformServiceError: If the task execution fails
264
+ """
265
+ task_run_id: str | None = None
266
+ try:
267
+ # Step 1: Trigger the task run
268
+ logger.info(f"Starting task on cloud mobile '{cloud_mobile_id}'")
269
+
270
+ task_run_id = await self._trigger_task_run(
271
+ cloud_mobile_id=cloud_mobile_id,
272
+ request=request,
273
+ )
274
+ logger.info(f"Task run started: {task_run_id}")
275
+
276
+ # Step 2: Poll for completion
277
+ final_status, error, output = await self._poll_task_until_completion(
278
+ cloud_mobile_id=cloud_mobile_id,
279
+ task_run_id=task_run_id,
280
+ on_status_update=on_status_update,
281
+ on_log=on_log,
282
+ poll_interval_seconds=poll_interval_seconds,
283
+ stall_timeout_seconds=stall_timeout_seconds,
284
+ )
285
+
286
+ return final_status, error, output
287
+
288
+ except asyncio.CancelledError:
289
+ # Task was cancelled locally - propagate to the Platform
290
+ logger.info("Task cancelled locally, propagating to the Platform")
291
+ if task_run_id is not None:
292
+ try:
293
+ await self.cancel_task_runs(cloud_mobile_id)
294
+ except Exception as e:
295
+ logger.warning(f"Failed to propagate cancellation: {e}")
296
+ raise
297
+ except Exception as e:
298
+ logger.error(f"Failed to run task on cloud device: {str(e)}")
299
+ raise PlatformServiceError(message=f"Failed to run task on cloud device: {e}")
300
+
301
+ async def _trigger_task_run(self, cloud_mobile_id: str, request: PlatformTaskRequest) -> str:
302
+ """Trigger a task run on the Platform and return the task run ID."""
303
+ try:
304
+ # Build the task request payload
305
+ payload = RunTaskRequest(
306
+ taskRequest={
307
+ "profile": request.profile,
308
+ "task": (
309
+ request.task if isinstance(request.task, str) else request.task.model_dump()
310
+ ),
311
+ }
312
+ )
313
+
314
+ response = await self._client.post(
315
+ f"daas/virtual-mobiles/{cloud_mobile_id}/run-task",
316
+ json=payload.model_dump(by_alias=True),
317
+ )
318
+ response.raise_for_status()
319
+
320
+ result = RunTaskResponse.model_validate(response.json())
321
+ return result.task_run_id
322
+
323
+ except httpx.HTTPStatusError as e:
324
+ raise PlatformServiceError(message=f"Failed to trigger task run on the Platform: {e}")
325
+
326
+ async def _poll_task_until_completion(
327
+ self,
328
+ cloud_mobile_id: str,
329
+ task_run_id: str,
330
+ on_status_update: Callable[[TaskRunStatus, str | None], None] | None,
331
+ on_log: Callable[[str], None] | None,
332
+ poll_interval_seconds: float,
333
+ stall_timeout_seconds: float,
334
+ ) -> tuple[TaskRunStatus, str | None, Any | None]:
335
+ """
336
+ Poll task run status until it completes, fails, or times out.
337
+
338
+ Returns:
339
+ Tuple of (final_status, error_message, output)
340
+ """
341
+ last_poll_time: datetime | None = None
342
+ last_activity_time = datetime.now(UTC)
343
+ current_status: TaskRunStatus = "pending"
344
+ previous_status: TaskRunStatus | None = None
345
+
346
+ while True:
347
+ # Check for stall timeout
348
+ now = datetime.now(UTC)
349
+ time_since_last_activity = (now - last_activity_time).total_seconds()
350
+ if time_since_last_activity > stall_timeout_seconds:
351
+ error_msg = (
352
+ f"Task stalled: No activity for {stall_timeout_seconds} seconds. "
353
+ "The task is considered failed."
354
+ )
355
+ logger.error(f"{error_msg} (task_run_id: {task_run_id})")
356
+ await self.cancel_task_runs(cloud_mobile_id)
357
+ return "cancelled", error_msg, None
358
+
359
+ # Fetch current task run status
360
+ task_info = await self._get_task_run_status(task_run_id)
361
+ current_status = task_info.status
362
+
363
+ # Notify status update
364
+ if previous_status != current_status and on_status_update:
365
+ previous_status = current_status
366
+ last_activity_time = now
367
+ try:
368
+ on_status_update(
369
+ task_info.status,
370
+ task_info.status_message,
371
+ )
372
+ except Exception as e:
373
+ logger.warning(f"Status update callback failed: {e}")
374
+
375
+ # Check for subgoal updates
376
+ subgoal_updates = await self._get_subgoal_updates(
377
+ task_run_id=task_run_id,
378
+ after_timestamp=last_poll_time,
379
+ )
380
+
381
+ # Check for new agent thoughts
382
+ new_thoughts = await self._get_new_agent_thoughts(
383
+ task_run_id=task_run_id,
384
+ after_timestamp=last_poll_time,
385
+ )
386
+
387
+ updates = sorted(
388
+ subgoal_updates + new_thoughts,
389
+ key=lambda item: item.timestamp,
390
+ )
391
+ if updates:
392
+ last_activity_time = now
393
+ for update in updates:
394
+ if on_log:
395
+ try:
396
+ on_log(f"[{update.timestamp}] {update.content}")
397
+ except Exception as e:
398
+ logger.warning(f"Log callback failed: {e}")
399
+
400
+ # Check if task is in terminal state
401
+ if current_status in ["completed", "failed", "cancelled"]:
402
+ logger.info(f"Task '{task_run_id}' reached terminal state: {current_status}")
403
+ error = (
404
+ task_info.status_message if current_status in ["failed", "cancelled"] else None
405
+ )
406
+ return current_status, error, task_info.output
407
+
408
+ # Wait before next poll
409
+ last_poll_time = now
410
+ await asyncio.sleep(poll_interval_seconds)
411
+
412
+ async def _get_task_run_status(self, task_run_id: str) -> TaskRunInfo:
413
+ """Get the current status of a task run."""
414
+ try:
415
+ response = await self._client.get(f"v1/task-runs/{task_run_id}")
416
+ response.raise_for_status()
417
+ data = response.json()
418
+
419
+ output: str | dict[str, Any] | None = None
420
+ raw_output = data.get("output")
421
+ try:
422
+ if raw_output is not None:
423
+ output = json.loads(raw_output)
424
+ except json.JSONDecodeError:
425
+ output = raw_output
426
+
427
+ return TaskRunInfo(
428
+ id=data["id"],
429
+ status=data["status"],
430
+ status_message=data.get("statusMessage"),
431
+ output=output,
432
+ )
433
+ except httpx.HTTPStatusError as e:
434
+ raise PlatformServiceError(message=f"Failed to get task run status: {e}")
435
+
436
+ async def _get_subgoal_updates(
437
+ self,
438
+ task_run_id: str,
439
+ after_timestamp: datetime | None,
440
+ ) -> list[TimelineItem]:
441
+ """Get new subgoals from the timeline after a specific timestamp."""
442
+ try:
443
+ started_subgoals = await self._get_filtered_subgoals(
444
+ task_run_id=task_run_id,
445
+ sort_by="started_at",
446
+ sort_order="asc",
447
+ after_timestamp=after_timestamp,
448
+ )
449
+ ended_subgoals = await self._get_filtered_subgoals(
450
+ task_run_id=task_run_id,
451
+ sort_by="ended_at",
452
+ sort_order="asc",
453
+ after_timestamp=after_timestamp,
454
+ )
455
+ items: list[TimelineItem] = []
456
+ for subgoal in started_subgoals:
457
+ if subgoal.started_at is None:
458
+ continue
459
+ items.append(
460
+ TimelineItem(
461
+ timestamp=subgoal.started_at,
462
+ content=f"[START][{subgoal.name}] {subgoal.state}",
463
+ )
464
+ )
465
+ for subgoal in ended_subgoals:
466
+ if subgoal.ended_at is None:
467
+ continue
468
+ items.append(
469
+ TimelineItem(
470
+ timestamp=subgoal.ended_at,
471
+ content=f"[END][{subgoal.name}] {subgoal.state}",
472
+ )
473
+ )
474
+ return items
475
+ except httpx.HTTPStatusError as e:
476
+ logger.warning(f"Failed to get subgoals timeline: {e}")
477
+ return []
478
+
479
+ async def _get_filtered_subgoals(
480
+ self,
481
+ task_run_id: str,
482
+ sort_by: str,
483
+ sort_order: str,
484
+ after_timestamp: datetime | None,
485
+ ) -> list[SubgoalTimelineItemResponse]:
486
+ params: dict[str, Any] = {
487
+ "page": 1,
488
+ "pageSize": 50,
489
+ "sortBy": sort_by,
490
+ "sortOrder": sort_order,
491
+ }
492
+ if after_timestamp:
493
+ params["after"] = after_timestamp.isoformat()
494
+
495
+ response = await self._client.get(
496
+ f"v1/task-runs/{task_run_id}/subgoals/timeline",
497
+ params=params,
498
+ )
499
+ response.raise_for_status()
500
+ data = response.json()
501
+ subgoals = [
502
+ SubgoalTimelineItemResponse.model_validate(item) for item in data.get("subgoals", [])
503
+ ]
504
+ return subgoals
505
+
506
+ async def _get_new_agent_thoughts(
507
+ self, task_run_id: str, after_timestamp: datetime | None
508
+ ) -> list[TimelineItem]:
509
+ """Get new agent thoughts from the timeline after a specific timestamp."""
510
+ try:
511
+ params: dict[str, Any] = {"page": 1, "pageSize": 50}
512
+ if after_timestamp:
513
+ params["after"] = after_timestamp.isoformat()
514
+
515
+ response = await self._client.get(
516
+ f"v1/task-runs/{task_run_id}/agent-thoughts/timeline",
517
+ params=params,
518
+ )
519
+ response.raise_for_status()
520
+ data = response.json()
521
+ agent_thoughts = [
522
+ AgentThoughtTimelineItemResponse.model_validate(item)
523
+ for item in data.get("agentThoughts", [])
524
+ ]
525
+
526
+ items: list[TimelineItem] = []
527
+ for thought in agent_thoughts:
528
+ items.append(
529
+ TimelineItem(
530
+ timestamp=thought.timestamp,
531
+ content=f"[{thought.agent}] {thought.content}",
532
+ )
533
+ )
534
+ return items
535
+ except httpx.HTTPStatusError as e:
536
+ logger.warning(f"Failed to get agent thoughts timeline: {e}")
537
+ return []
538
+
539
+ async def cancel_task_runs(self, cloud_mobile_id: str) -> None:
540
+ """Cancel all task runs on the Platform for a specific cloud mobile."""
541
+ try:
542
+ # Only one task can run on a cloud mobile at a time.
543
+ # Therefore, cancelling all tasks running on it implies cancelling the task run.
544
+ response = await self._client.post(
545
+ f"v1/task-runs/virtual-mobile/{cloud_mobile_id}/cancel"
546
+ )
547
+ response.raise_for_status()
548
+ logger.info(f"Task runs cancelled on cloud mobile '{cloud_mobile_id}'")
549
+ except httpx.HTTPStatusError as e:
550
+ raise PlatformServiceError(message=f"Failed to cancel task run: {e}")
551
+
552
+ async def get_screenshot(self, cloud_mobile_id: str) -> Image.Image:
553
+ """
554
+ Get a screenshot from a cloud mobile.
555
+
556
+ Args:
557
+ cloud_mobile_id: ID of the cloud mobile to capture screenshot from
558
+
559
+ Returns:
560
+ Screenshot as PIL Image
561
+
562
+ Raises:
563
+ PlatformServiceError: If the screenshot capture fails
564
+ """
565
+ try:
566
+ logger.info(f"Capturing screenshot from cloud mobile '{cloud_mobile_id}'")
567
+ response = await self._client.get(f"daas/virtual-mobiles/{cloud_mobile_id}/screenshot")
568
+ response.raise_for_status()
569
+
570
+ # Convert bytes to PIL Image
571
+ image = Image.open(BytesIO(response.content))
572
+
573
+ size_bytes = len(response.content)
574
+ logger.info(
575
+ f"Screenshot captured from cloud mobile '{cloud_mobile_id}' ({size_bytes} bytes)"
576
+ )
577
+ return image
578
+ except httpx.HTTPStatusError as e:
579
+ raise PlatformServiceError(
580
+ message=f"Failed to get screenshot from cloud mobile: "
581
+ f"{e.response.status_code} - {e.response.text}"
582
+ )
@@ -65,6 +65,8 @@ class AgentConfig(BaseModel):
65
65
  device_id: Specific device to target (if None, first available is used).
66
66
  device_platform: Platform of the device to target.
67
67
  servers: Custom server configurations.
68
+ cloud_mobile_id_or_ref: ID or reference name of cloud mobile (virtual mobile)
69
+ to use for remote execution.
68
70
  """
69
71
 
70
72
  agent_profiles: dict[str, AgentProfile]
@@ -74,5 +76,6 @@ class AgentConfig(BaseModel):
74
76
  device_platform: DevicePlatform | None = None
75
77
  servers: ServerConfig
76
78
  graph_config_callbacks: Callbacks = None
79
+ cloud_mobile_id_or_ref: str | None = None
77
80
 
78
81
  model_config = {"arbitrary_types_allowed": True}
@@ -124,6 +124,13 @@ class PlatformServiceUninitializedError(MobileUseError):
124
124
  )
125
125
 
126
126
 
127
+ class CloudMobileServiceUninitializedError(MobileUseError):
128
+ """Exception raised when a cloud mobile service call fails."""
129
+
130
+ def __init__(self):
131
+ super().__init__("Cloud mobile service is not initialized!")
132
+
133
+
127
134
  class PlatformServiceError(MobileUseError):
128
135
  """Exception raised when a platform service call fails."""
129
136
 
@@ -127,13 +127,10 @@ class PlatformTaskRequest[TOutput](TaskRequestBase):
127
127
  task: Either a task name to fetch from the platform, or a
128
128
  ManualTaskConfig to create manually
129
129
  profile: Optional profile name specified by the user on the platform
130
- api_key: Optional API key to authenticate with the platform
131
- (overrides MINITAP_API_KEY env variable)
132
130
  """
133
131
 
134
132
  task: str | ManualTaskConfig
135
133
  profile: str | None = None
136
- api_key: str | None = None
137
134
 
138
135
 
139
136
  class CloudDevicePlatformTaskRequest[TOutput](PlatformTaskRequest[TOutput]):
@@ -28,7 +28,6 @@ user_messages_logger = get_logger(__name__)
28
28
 
29
29
  async def invoke_llm_with_timeout_message[T](
30
30
  llm_call: Coroutine[Any, Any, T],
31
- agent_name: str,
32
31
  timeout_seconds: int = 10,
33
32
  ) -> T:
34
33
  """
@@ -36,7 +35,6 @@ async def invoke_llm_with_timeout_message[T](
36
35
 
37
36
  Args:
38
37
  llm_call: The coroutine of the LLM call to execute.
39
- agent_name: The name of the agent making the call (for the message).
40
38
  timeout_seconds: The delay in seconds before displaying the message.
41
39
 
42
40
  Returns:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: minitap-mobile-use
3
- Version: 2.7.1
3
+ Version: 2.8.0
4
4
  Summary: AI-powered multi-agent system that automates real Android and iOS devices through low-level control using LangGraph.
5
5
  Author: Pierre-Louis Favreau, Jean-Pierre Lo, Nicolas Dehandschoewercker
6
6
  License: MIT License