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.
- minitap/mobile_use/agents/cortex/cortex.py +2 -6
- minitap/mobile_use/agents/executor/executor.py +2 -6
- minitap/mobile_use/agents/executor/tool_node.py +31 -6
- minitap/mobile_use/agents/hopper/hopper.py +2 -6
- minitap/mobile_use/agents/orchestrator/orchestrator.py +2 -6
- minitap/mobile_use/agents/outputter/outputter.py +2 -4
- minitap/mobile_use/agents/planner/planner.py +2 -2
- minitap/mobile_use/agents/screen_analyzer/screen_analyzer.py +2 -6
- minitap/mobile_use/config.py +1 -1
- minitap/mobile_use/graph/graph.py +6 -2
- minitap/mobile_use/main.py +2 -2
- minitap/mobile_use/sdk/agent.py +212 -12
- minitap/mobile_use/sdk/builders/agent_config_builder.py +43 -9
- minitap/mobile_use/sdk/examples/README.md +1 -1
- minitap/mobile_use/sdk/examples/platform_manual_task_example.py +2 -2
- minitap/mobile_use/sdk/examples/platform_minimal_example.py +2 -3
- minitap/mobile_use/sdk/examples/simple_photo_organizer.py +2 -2
- minitap/mobile_use/sdk/examples/smart_notification_assistant.py +2 -2
- minitap/mobile_use/sdk/services/cloud_mobile.py +582 -0
- minitap/mobile_use/sdk/types/agent.py +3 -0
- minitap/mobile_use/sdk/types/exceptions.py +7 -0
- minitap/mobile_use/sdk/types/task.py +0 -3
- minitap/mobile_use/services/llm.py +0 -2
- {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/METADATA +1 -1
- {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/RECORD +27 -26
- {minitap_mobile_use-2.7.1.dist-info → minitap_mobile_use-2.8.0.dist-info}/WHEEL +0 -0
- {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.
|
|
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
|