quash-mcp 0.2.12__py3-none-any.whl → 0.2.14__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.
- quash_mcp/backend_client.py +17 -97
- quash_mcp/device/adb_tools.py +42 -0
- quash_mcp/device/state_capture.py +5 -0
- quash_mcp/models.py +42 -0
- quash_mcp/tools/execute_v3.py +131 -170
- {quash_mcp-0.2.12.dist-info → quash_mcp-0.2.14.dist-info}/METADATA +1 -1
- {quash_mcp-0.2.12.dist-info → quash_mcp-0.2.14.dist-info}/RECORD +9 -8
- {quash_mcp-0.2.12.dist-info → quash_mcp-0.2.14.dist-info}/WHEEL +0 -0
- {quash_mcp-0.2.12.dist-info → quash_mcp-0.2.14.dist-info}/entry_points.txt +0 -0
quash_mcp/backend_client.py
CHANGED
|
@@ -8,6 +8,10 @@ import httpx
|
|
|
8
8
|
from typing import Dict, Any, Optional
|
|
9
9
|
import logging
|
|
10
10
|
|
|
11
|
+
from .models import SessionDTO
|
|
12
|
+
|
|
13
|
+
from .models import SessionDTO
|
|
14
|
+
|
|
11
15
|
logger = logging.getLogger(__name__)
|
|
12
16
|
|
|
13
17
|
|
|
@@ -16,7 +20,7 @@ class BackendClient:
|
|
|
16
20
|
|
|
17
21
|
def __init__(self):
|
|
18
22
|
# Get backend URL from environment variable, default to production backend
|
|
19
|
-
self.base_url = os.getenv("MAHORAGA_BACKEND_URL", "
|
|
23
|
+
self.base_url = os.getenv("MAHORAGA_BACKEND_URL", "http://localhost:8000")
|
|
20
24
|
self.timeout = 300.0 # 5 minutes for long-running LLM calls
|
|
21
25
|
logger.info(f"🔧 Backend client initialized: URL={self.base_url}")
|
|
22
26
|
|
|
@@ -31,7 +35,8 @@ class BackendClient:
|
|
|
31
35
|
Dict with validation result:
|
|
32
36
|
{
|
|
33
37
|
"valid": bool,
|
|
34
|
-
"user": {"email": str, "name": str
|
|
38
|
+
"user": {"email": str, "name": str},
|
|
39
|
+
"organization_credits": float,
|
|
35
40
|
"openrouter_api_key": str,
|
|
36
41
|
"error": str (if invalid)
|
|
37
42
|
}
|
|
@@ -192,61 +197,18 @@ class BackendClient:
|
|
|
192
197
|
|
|
193
198
|
async def execute_step(
|
|
194
199
|
self,
|
|
195
|
-
|
|
196
|
-
session_id: str,
|
|
197
|
-
step_number: int,
|
|
198
|
-
task: str,
|
|
199
|
-
ui_state: Dict[str, Any],
|
|
200
|
-
chat_history: list,
|
|
201
|
-
config: Dict[str, Any],
|
|
200
|
+
session: "SessionDTO",
|
|
202
201
|
screenshot_bytes: Optional[bytes] = None
|
|
203
202
|
) -> Dict[str, Any]:
|
|
204
203
|
"""
|
|
205
|
-
Execute single agent step (V3 -
|
|
206
|
-
|
|
207
|
-
Sends device state to backend, receives next action to execute.
|
|
208
|
-
|
|
209
|
-
Args:
|
|
210
|
-
api_key: Quash API key
|
|
211
|
-
session_id: Unique session identifier
|
|
212
|
-
step_number: Current step number
|
|
213
|
-
task: Original task description
|
|
214
|
-
ui_state: Device UI state (a11y_tree, phone_state)
|
|
215
|
-
chat_history: Previous conversation messages
|
|
216
|
-
config: Execution configuration
|
|
217
|
-
screenshot_bytes: Optional screenshot (only if vision=True)
|
|
218
|
-
|
|
219
|
-
Returns:
|
|
220
|
-
Dict with action to execute:
|
|
221
|
-
{
|
|
222
|
-
"action": {"type": str, "code": str, "reasoning": str},
|
|
223
|
-
"completed": bool,
|
|
224
|
-
"success": bool (if completed),
|
|
225
|
-
"final_message": str (if completed),
|
|
226
|
-
"assistant_response": str,
|
|
227
|
-
"tokens_used": {"prompt": int, "completion": int, "total": int},
|
|
228
|
-
"cost": float
|
|
229
|
-
}
|
|
204
|
+
Execute single agent step (V3 - DTO-based execution).
|
|
230
205
|
"""
|
|
231
206
|
import json
|
|
232
207
|
|
|
233
208
|
try:
|
|
234
209
|
# Prepare form data (multipart)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
"session_id": session_id,
|
|
238
|
-
"step_number": step_number,
|
|
239
|
-
"task": task,
|
|
240
|
-
"ui_state": ui_state,
|
|
241
|
-
"chat_history": chat_history,
|
|
242
|
-
"config": config
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
# Convert to JSON string
|
|
246
|
-
data_json = json.dumps(data_dict)
|
|
247
|
-
|
|
248
|
-
# Prepare form data (data field as string)
|
|
249
|
-
form_data = {"data": data_json}
|
|
210
|
+
session_json = session.model_dump_json()
|
|
211
|
+
form_data = {"session_data": session_json}
|
|
250
212
|
|
|
251
213
|
# Prepare files dict (only screenshot if provided)
|
|
252
214
|
files = {}
|
|
@@ -254,7 +216,6 @@ class BackendClient:
|
|
|
254
216
|
files["screenshot"] = ("screenshot.png", screenshot_bytes, "image/png")
|
|
255
217
|
|
|
256
218
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
257
|
-
# Send both form data and files (multipart/form-data)
|
|
258
219
|
response = await client.post(
|
|
259
220
|
f"{self.base_url}/api/agent/step",
|
|
260
221
|
data=form_data,
|
|
@@ -283,60 +244,18 @@ class BackendClient:
|
|
|
283
244
|
|
|
284
245
|
async def finalize_session(
|
|
285
246
|
self,
|
|
286
|
-
|
|
287
|
-
session_id: str,
|
|
288
|
-
task: str,
|
|
289
|
-
device_serial: str,
|
|
290
|
-
status: str,
|
|
291
|
-
final_message: Optional[str] = None,
|
|
292
|
-
error: Optional[str] = None,
|
|
293
|
-
duration_seconds: float = 0.0,
|
|
294
|
-
config: Optional[Dict[str, Any]] = None
|
|
247
|
+
session: "SessionDTO",
|
|
295
248
|
) -> Dict[str, Any]:
|
|
296
249
|
"""
|
|
297
250
|
Finalize a session and aggregate execution record.
|
|
298
|
-
|
|
299
|
-
Called when task ends for ANY reason: normal completion, max steps, error, interrupt.
|
|
300
|
-
|
|
301
|
-
Args:
|
|
302
|
-
api_key: Quash API key
|
|
303
|
-
session_id: Session identifier to finalize
|
|
304
|
-
task: Original task description
|
|
305
|
-
device_serial: Device serial number
|
|
306
|
-
status: "success", "failed", "max_steps", "error", "interrupted"
|
|
307
|
-
final_message: Final message from agent
|
|
308
|
-
error: Error message if failed
|
|
309
|
-
duration_seconds: Total execution time
|
|
310
|
-
config: Execution configuration
|
|
311
|
-
|
|
312
|
-
Returns:
|
|
313
|
-
Dict with finalization result:
|
|
314
|
-
{
|
|
315
|
-
"finalized": bool,
|
|
316
|
-
"execution_id": str,
|
|
317
|
-
"total_steps": int,
|
|
318
|
-
"total_tokens": {"prompt": int, "completion": int, "total": int},
|
|
319
|
-
"total_cost": float,
|
|
320
|
-
"error": str (if failed)
|
|
321
|
-
}
|
|
322
251
|
"""
|
|
323
|
-
logger.info(f"🏁 Finalizing session {session_id}
|
|
252
|
+
logger.info(f"🏁 Finalizing session {session.session_id}")
|
|
324
253
|
|
|
325
254
|
try:
|
|
326
255
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
327
256
|
response = await client.post(
|
|
328
257
|
f"{self.base_url}/api/agent/finalize",
|
|
329
|
-
|
|
330
|
-
"api_key": api_key,
|
|
331
|
-
"session_id": session_id,
|
|
332
|
-
"task": task,
|
|
333
|
-
"device_serial": device_serial,
|
|
334
|
-
"status": status,
|
|
335
|
-
"final_message": final_message,
|
|
336
|
-
"error": error,
|
|
337
|
-
"duration_seconds": duration_seconds,
|
|
338
|
-
"config": config or {}
|
|
339
|
-
}
|
|
258
|
+
data={"session_data": session.model_dump_json(exclude={'ui_state', 'chat_history'})}
|
|
340
259
|
)
|
|
341
260
|
|
|
342
261
|
if response.status_code == 200:
|
|
@@ -345,8 +264,9 @@ class BackendClient:
|
|
|
345
264
|
logger.info(f"✅ Session finalized: {result.get('total_steps')} steps, ${result.get('total_cost', 0):.4f}")
|
|
346
265
|
return result
|
|
347
266
|
else:
|
|
348
|
-
|
|
349
|
-
|
|
267
|
+
error_details = response.json() if response.headers.get("content-type") == "application/json" else response.text
|
|
268
|
+
logger.warning(f"Failed to finalize session: HTTP {response.status_code} - Details: {error_details}")
|
|
269
|
+
return {"finalized": False, "error": f"HTTP {response.status_code} - Details: {error_details}"}
|
|
350
270
|
|
|
351
271
|
except Exception as e:
|
|
352
272
|
logger.error(f"Failed to finalize session: {e}")
|
quash_mcp/device/adb_tools.py
CHANGED
|
@@ -102,6 +102,48 @@ class AdbTools:
|
|
|
102
102
|
logger.error(f"Failed to remove TCP port forwarding: {e}")
|
|
103
103
|
return False
|
|
104
104
|
|
|
105
|
+
def get_accessibility_tree(self) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Get the current accessibility tree (UI hierarchy) as an XML string.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
# Use uiautomator dump to get the UI hierarchy
|
|
111
|
+
result = self.device.shell("uiautomator dump --compressed")
|
|
112
|
+
# The dump command writes to /sdcard/window_dump.xml
|
|
113
|
+
# We need to read it back
|
|
114
|
+
xml_content = self.device.pull("/sdcard/window_dump.xml").decode("utf-8")
|
|
115
|
+
# Clean up the dumped file from the device
|
|
116
|
+
self.device.shell("rm /sdcard/window_dump.xml")
|
|
117
|
+
return xml_content
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Failed to get accessibility tree: {e}", exc_info=True)
|
|
120
|
+
return f"<error>Failed to get accessibility tree: {e}</error>"
|
|
121
|
+
|
|
122
|
+
def get_phone_state(self) -> dict[str, any]:
|
|
123
|
+
"""
|
|
124
|
+
Get basic phone state information, like current app package.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
current_app = self.device.current_app()
|
|
128
|
+
return {
|
|
129
|
+
"package": current_app.package,
|
|
130
|
+
"activity": current_app.activity,
|
|
131
|
+
"pid": current_app.pid,
|
|
132
|
+
}
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to get phone state: {e}", exc_info=True)
|
|
135
|
+
return {"package": "unknown", "error": str(e)}
|
|
136
|
+
|
|
137
|
+
def get_screenshot(self) -> bytes:
|
|
138
|
+
"""
|
|
139
|
+
Get a screenshot of the device as PNG bytes.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
return self.device.screenshot()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Failed to get screenshot: {e}", exc_info=True)
|
|
145
|
+
return b""
|
|
146
|
+
|
|
105
147
|
def __del__(self):
|
|
106
148
|
"""Cleanup when the object is destroyed."""
|
|
107
149
|
if hasattr(self, "tcp_forwarded") and self.tcp_forwarded:
|
|
@@ -103,6 +103,11 @@ def get_device_state(serial: str) -> Tuple[Dict[str, Any], Optional[bytes]]:
|
|
|
103
103
|
# Get current package
|
|
104
104
|
current_package = get_current_package(serial)
|
|
105
105
|
|
|
106
|
+
logger.debug("Capturing device state...")
|
|
107
|
+
|
|
108
|
+
# Get current package
|
|
109
|
+
current_package = get_current_package(serial)
|
|
110
|
+
|
|
106
111
|
# Get accessibility tree
|
|
107
112
|
a11y_tree = get_accessibility_tree(serial)
|
|
108
113
|
|
quash_mcp/models.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Optional, List, Dict, Any
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
class TokensInfo(BaseModel):
|
|
6
|
+
prompt: int
|
|
7
|
+
completion: int
|
|
8
|
+
total: int
|
|
9
|
+
|
|
10
|
+
class ConfigInfo(BaseModel):
|
|
11
|
+
model: str
|
|
12
|
+
temperature: float
|
|
13
|
+
vision: bool = False
|
|
14
|
+
reasoning: bool = False
|
|
15
|
+
reflection: bool = False
|
|
16
|
+
debug: bool = False
|
|
17
|
+
|
|
18
|
+
class UIStateInfo(BaseModel):
|
|
19
|
+
a11y_tree: str
|
|
20
|
+
phone_state: Dict[str, Any]
|
|
21
|
+
|
|
22
|
+
class ChatHistoryMessage(BaseModel):
|
|
23
|
+
role: str
|
|
24
|
+
content: str
|
|
25
|
+
|
|
26
|
+
class AgentStepDTO(BaseModel):
|
|
27
|
+
step_number: int
|
|
28
|
+
reasoning: Optional[str] = None
|
|
29
|
+
code: Optional[str] = None
|
|
30
|
+
tokens_used: TokensInfo
|
|
31
|
+
cost: float
|
|
32
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
33
|
+
|
|
34
|
+
class SessionDTO(BaseModel):
|
|
35
|
+
session_id: str
|
|
36
|
+
api_key: str
|
|
37
|
+
task: str
|
|
38
|
+
device_serial: str
|
|
39
|
+
config: ConfigInfo
|
|
40
|
+
chat_history: List[ChatHistoryMessage] = []
|
|
41
|
+
steps: List[AgentStepDTO] = []
|
|
42
|
+
ui_state: Optional[UIStateInfo] = None
|
quash_mcp/tools/execute_v3.py
CHANGED
|
@@ -17,6 +17,7 @@ from ..state import get_state
|
|
|
17
17
|
from ..backend_client import get_backend_client
|
|
18
18
|
from ..device.state_capture import get_device_state
|
|
19
19
|
from ..device.adb_tools import AdbTools
|
|
20
|
+
import logging
|
|
20
21
|
|
|
21
22
|
# Import mahoraga components for tool functions
|
|
22
23
|
try:
|
|
@@ -149,7 +150,7 @@ def wait_for_action_effect(
|
|
|
149
150
|
"""
|
|
150
151
|
# Check if action should change UI
|
|
151
152
|
code_lower = executed_code.lower()
|
|
152
|
-
if "get_state" in code_lower
|
|
153
|
+
if "get_state" in code_lower:
|
|
153
154
|
# Action doesn't change UI - no need to wait
|
|
154
155
|
time.sleep(0.1)
|
|
155
156
|
return get_state_func(device_serial)[0], None, False
|
|
@@ -177,6 +178,8 @@ def wait_for_action_effect(
|
|
|
177
178
|
# MAIN EXECUTION FUNCTION
|
|
178
179
|
# ============================================================
|
|
179
180
|
|
|
181
|
+
from ..models import SessionDTO, UIStateInfo, ChatHistoryMessage, ConfigInfo, AgentStepDTO
|
|
182
|
+
|
|
180
183
|
async def execute_v3(
|
|
181
184
|
task: str,
|
|
182
185
|
max_steps: int = 15,
|
|
@@ -184,25 +187,6 @@ async def execute_v3(
|
|
|
184
187
|
) -> Dict[str, Any]:
|
|
185
188
|
"""
|
|
186
189
|
Execute automation task using step-by-step backend communication.
|
|
187
|
-
|
|
188
|
-
Each step:
|
|
189
|
-
1. Capture device state (State A)
|
|
190
|
-
2. Send to backend for AI decision
|
|
191
|
-
3. Execute returned action locally
|
|
192
|
-
4. POLL until state changes (State B ≠ State A) or timeout
|
|
193
|
-
5. Send State B to backend in next iteration
|
|
194
|
-
6. Repeat until complete
|
|
195
|
-
|
|
196
|
-
This ensures the backend always sees the UPDATED state after each action,
|
|
197
|
-
preventing the agent from making decisions based on stale state.
|
|
198
|
-
|
|
199
|
-
Args:
|
|
200
|
-
task: Natural language task description
|
|
201
|
-
max_steps: Maximum number of steps to execute (default: 15)
|
|
202
|
-
progress_callback: Optional callback for progress updates
|
|
203
|
-
|
|
204
|
-
Returns:
|
|
205
|
-
Dict with execution result and details
|
|
206
190
|
"""
|
|
207
191
|
state = get_state()
|
|
208
192
|
backend = get_backend_client()
|
|
@@ -253,12 +237,12 @@ async def execute_v3(
|
|
|
253
237
|
|
|
254
238
|
# Check credits
|
|
255
239
|
user_info = validation_result.get("user", {})
|
|
256
|
-
|
|
240
|
+
organization_credits = validation_result.get("organization_credits", 0)
|
|
257
241
|
|
|
258
|
-
if
|
|
242
|
+
if organization_credits <= 0:
|
|
259
243
|
return {
|
|
260
244
|
"status": "error",
|
|
261
|
-
"message": f"❌ Insufficient credits. Current balance: ${
|
|
245
|
+
"message": f"❌ Insufficient credits. Current balance: ${organization_credits:.2f}",
|
|
262
246
|
"user": user_info
|
|
263
247
|
}
|
|
264
248
|
|
|
@@ -267,20 +251,23 @@ async def execute_v3(
|
|
|
267
251
|
if progress_callback:
|
|
268
252
|
progress_callback(message)
|
|
269
253
|
|
|
270
|
-
log_progress(f"✅ API Key validated - Credits: ${
|
|
254
|
+
log_progress(f"✅ API Key validated - Credits: ${organization_credits:.2f}")
|
|
271
255
|
log_progress(f"👤 User: {user_info.get('name', 'Unknown')}")
|
|
272
256
|
log_progress(f"🚀 Starting task: {task}")
|
|
273
257
|
log_progress(f"📱 Device: {state.device_serial}")
|
|
274
258
|
log_progress(f"🧠 Model: {config['model']}")
|
|
259
|
+
|
|
275
260
|
log_progress(f"🔢 Max steps: {max_steps}")
|
|
276
261
|
|
|
277
|
-
# Initialize
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
262
|
+
# Initialize Session DTO
|
|
263
|
+
|
|
264
|
+
session = SessionDTO(
|
|
265
|
+
session_id=f"session_{uuid.uuid4().hex[:12]}",
|
|
266
|
+
api_key=quash_api_key,
|
|
267
|
+
task=task,
|
|
268
|
+
device_serial=state.device_serial,
|
|
269
|
+
config=ConfigInfo(**config)
|
|
270
|
+
)
|
|
284
271
|
|
|
285
272
|
# Initialize local ADB tools for code execution
|
|
286
273
|
adb_tools = AdbTools(serial=state.device_serial, use_tcp=True)
|
|
@@ -329,6 +316,15 @@ async def execute_v3(
|
|
|
329
316
|
# Add wrapped function to globals so code can call it directly
|
|
330
317
|
executor_globals[tool_name] = make_printing_wrapper(tool_function)
|
|
331
318
|
|
|
319
|
+
# Override the 'complete' function to be a no-op
|
|
320
|
+
# The backend already handles completion via the 'completed' flag
|
|
321
|
+
def complete_no_op(success=True, reason=""):
|
|
322
|
+
"""No-op wrapper for complete() - completion is handled by backend."""
|
|
323
|
+
print(f"complete() called: success={success}, reason='{reason}'")
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
executor_globals['complete'] = complete_no_op
|
|
327
|
+
|
|
332
328
|
log_progress(f"🔧 Loaded {len(filtered_tools)} tool functions: {list(filtered_tools.keys())}")
|
|
333
329
|
except Exception as e:
|
|
334
330
|
log_progress(f"⚠️ Warning: Could not load tool functions: {e}")
|
|
@@ -337,43 +333,48 @@ async def execute_v3(
|
|
|
337
333
|
|
|
338
334
|
executor_locals = {}
|
|
339
335
|
|
|
336
|
+
start_time = time.time()
|
|
337
|
+
|
|
340
338
|
try:
|
|
341
339
|
# ============================================================
|
|
342
340
|
# STEP-BY-STEP EXECUTION LOOP
|
|
343
341
|
# ============================================================
|
|
344
|
-
while step_number < max_steps: # Use user-provided max_steps
|
|
345
|
-
step_number += 1
|
|
346
|
-
log_progress(f"🧠 Step {step_number}/{max_steps}: Analyzing...")
|
|
347
342
|
|
|
348
|
-
|
|
343
|
+
while len(session.steps) < max_steps:
|
|
344
|
+
|
|
345
|
+
log_progress(f"🧠 Step {len(session.steps) + 1}/{max_steps}: Analyzing...")
|
|
346
|
+
|
|
347
|
+
# 1. Capture device state and update session DTO
|
|
349
348
|
try:
|
|
350
349
|
ui_state_dict, screenshot_bytes = get_device_state(state.device_serial)
|
|
351
350
|
|
|
352
|
-
|
|
351
|
+
session.ui_state = UIStateInfo(**ui_state_dict)
|
|
352
|
+
# Update local tools with new state
|
|
353
|
+
if mahoraga_tools and "a11y_tree" in ui_state_dict and isinstance(ui_state_dict["a11y_tree"], str):
|
|
354
|
+
try:
|
|
355
|
+
import json
|
|
356
|
+
a11y_tree_obj = json.loads(ui_state_dict["a11y_tree"])
|
|
357
|
+
mahoraga_tools.update_state(a11y_tree_obj)
|
|
358
|
+
except (json.JSONDecodeError, TypeError):
|
|
359
|
+
pass # Ignore if not a valid JSON string
|
|
360
|
+
|
|
353
361
|
if not config["vision"]:
|
|
354
362
|
screenshot_bytes = None
|
|
355
363
|
|
|
356
|
-
# Log current state
|
|
357
364
|
current_package = ui_state_dict.get("phone_state", {}).get("package", "unknown")
|
|
358
365
|
log_progress(f"📱 Current app: {current_package}")
|
|
359
366
|
|
|
360
367
|
except Exception as e:
|
|
361
368
|
log_progress(f"⚠️ Warning: Failed to capture device state: {e}")
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
369
|
+
session.ui_state = UIStateInfo(
|
|
370
|
+
a11y_tree="<error>Failed to capture UI</error>",
|
|
371
|
+
phone_state={"package": "unknown"}
|
|
372
|
+
)
|
|
366
373
|
screenshot_bytes = None
|
|
367
374
|
|
|
368
|
-
# 2. Send to backend for AI decision
|
|
375
|
+
# 2. Send session DTO to backend for AI decision
|
|
369
376
|
step_result = await backend.execute_step(
|
|
370
|
-
|
|
371
|
-
session_id=session_id,
|
|
372
|
-
step_number=step_number,
|
|
373
|
-
task=task,
|
|
374
|
-
ui_state=ui_state_dict,
|
|
375
|
-
chat_history=chat_history,
|
|
376
|
-
config=config,
|
|
377
|
+
session=session,
|
|
377
378
|
screenshot_bytes=screenshot_bytes
|
|
378
379
|
)
|
|
379
380
|
|
|
@@ -384,20 +385,20 @@ async def execute_v3(
|
|
|
384
385
|
"status": "error",
|
|
385
386
|
"message": step_result["message"],
|
|
386
387
|
"error": step_result["error"],
|
|
387
|
-
"steps_taken":
|
|
388
|
-
"tokens":
|
|
389
|
-
"cost":
|
|
388
|
+
"steps_taken": len(session.steps),
|
|
389
|
+
"tokens": None,
|
|
390
|
+
"cost": None,
|
|
390
391
|
"duration_seconds": time.time() - start_time
|
|
391
392
|
}
|
|
392
393
|
|
|
393
|
-
# Update
|
|
394
|
-
|
|
395
|
-
|
|
394
|
+
# Update Session DTO with new step and chat history
|
|
395
|
+
new_step_data = step_result.get("new_step")
|
|
396
|
+
if new_step_data:
|
|
397
|
+
new_step = AgentStepDTO(**new_step_data)
|
|
398
|
+
session.steps.append(new_step)
|
|
399
|
+
assistant_response = step_result.get("assistant_response", "")
|
|
400
|
+
session.chat_history.append(ChatHistoryMessage(role="assistant", content=assistant_response))
|
|
396
401
|
|
|
397
|
-
total_tokens["prompt"] += step_tokens.get("prompt", 0)
|
|
398
|
-
total_tokens["completion"] += step_tokens.get("completion", 0)
|
|
399
|
-
total_tokens["total"] += step_tokens.get("total", 0)
|
|
400
|
-
total_cost += step_cost
|
|
401
402
|
|
|
402
403
|
# Get action from backend
|
|
403
404
|
action = step_result.get("action", {})
|
|
@@ -405,78 +406,36 @@ async def execute_v3(
|
|
|
405
406
|
code = action.get("code")
|
|
406
407
|
reasoning = action.get("reasoning")
|
|
407
408
|
|
|
409
|
+
|
|
408
410
|
# Log reasoning
|
|
409
411
|
if reasoning:
|
|
410
412
|
log_progress(f"🤔 Reasoning: {reasoning}")
|
|
411
413
|
|
|
412
|
-
# Update chat history
|
|
413
|
-
assistant_response = step_result.get("assistant_response", "")
|
|
414
|
-
chat_history.append({"role": "assistant", "content": assistant_response})
|
|
415
|
-
|
|
416
|
-
# 3. Check if task is complete
|
|
417
|
-
if step_result.get("completed", False):
|
|
418
|
-
success = step_result.get("success", False)
|
|
419
|
-
final_message = step_result.get("final_message", "Task completed")
|
|
420
|
-
|
|
421
|
-
duration = time.time() - start_time
|
|
422
|
-
|
|
423
|
-
if success:
|
|
424
|
-
log_progress(f"✅ Task completed successfully in {step_number} steps")
|
|
425
|
-
log_progress(f"💰 Usage: {total_tokens['total']} tokens, ${total_cost:.4f}")
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
"status": "success",
|
|
429
|
-
"steps_taken": step_number,
|
|
430
|
-
"final_message": final_message,
|
|
431
|
-
"message": f"✅ Success: {final_message}",
|
|
432
|
-
"tokens": total_tokens,
|
|
433
|
-
"cost": total_cost,
|
|
434
|
-
"duration_seconds": duration
|
|
435
|
-
}
|
|
436
|
-
else:
|
|
437
|
-
log_progress(f"❌ Task failed: {final_message}")
|
|
438
|
-
log_progress(f"💰 Usage: {total_tokens['total']} tokens, ${total_cost:.4f}")
|
|
439
414
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
"steps_taken": step_number,
|
|
443
|
-
"final_message": final_message,
|
|
444
|
-
"message": f"❌ Failed: {final_message}",
|
|
445
|
-
"tokens": total_tokens,
|
|
446
|
-
"cost": total_cost,
|
|
447
|
-
"duration_seconds": duration
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
# 4. Execute action locally
|
|
415
|
+
# 3. Execute action locally FIRST (if provided)
|
|
416
|
+
# NOTE: Backend should have already removed complete() from the code
|
|
451
417
|
if code and action_type == "execute_code":
|
|
452
418
|
log_progress(f"⚡ Executing action...")
|
|
453
419
|
|
|
454
|
-
#
|
|
455
|
-
|
|
420
|
+
log_progress(f"```python\n{code}\n```") # Log the code
|
|
421
|
+
|
|
422
|
+
old_ui_state = session.ui_state.model_dump().copy()
|
|
456
423
|
|
|
457
424
|
try:
|
|
458
425
|
import io
|
|
459
426
|
import contextlib
|
|
460
427
|
|
|
461
|
-
# Capture stdout and stderr to get tool function outputs
|
|
462
428
|
stdout = io.StringIO()
|
|
463
429
|
stderr = io.StringIO()
|
|
464
430
|
|
|
465
431
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
466
|
-
# Execute code in sandbox
|
|
467
432
|
exec(code, executor_globals, executor_locals)
|
|
468
433
|
|
|
469
|
-
# Get captured output
|
|
470
434
|
execution_output = stdout.getvalue()
|
|
471
435
|
error_output = stderr.getvalue()
|
|
472
436
|
|
|
473
|
-
# ============================================================
|
|
474
|
-
# CRITICAL: Wait for state change (polling-based event detection)
|
|
475
|
-
# ============================================================
|
|
476
437
|
log_progress(f"⏳ Waiting for UI state to update...")
|
|
477
|
-
|
|
478
438
|
try:
|
|
479
|
-
# Poll until state changes or timeout
|
|
480
439
|
new_ui_state_dict, _, state_changed = wait_for_action_effect(
|
|
481
440
|
get_device_state,
|
|
482
441
|
state.device_serial,
|
|
@@ -486,7 +445,6 @@ async def execute_v3(
|
|
|
486
445
|
poll_interval=0.5
|
|
487
446
|
)
|
|
488
447
|
|
|
489
|
-
# Log what happened
|
|
490
448
|
if state_changed:
|
|
491
449
|
old_pkg = old_ui_state.get("phone_state", {}).get("package", "")
|
|
492
450
|
new_pkg = new_ui_state_dict.get("phone_state", {}).get("package", "")
|
|
@@ -495,6 +453,7 @@ async def execute_v3(
|
|
|
495
453
|
log_progress(f"✅ State changed: App switched ({old_pkg} → {new_pkg})")
|
|
496
454
|
else:
|
|
497
455
|
log_progress(f"✅ State changed: UI updated")
|
|
456
|
+
|
|
498
457
|
else:
|
|
499
458
|
log_progress(f"⚠️ WARNING: State did NOT change after action (timeout)")
|
|
500
459
|
log_progress(f" This might mean the action had no effect or took too long")
|
|
@@ -502,10 +461,8 @@ async def execute_v3(
|
|
|
502
461
|
except Exception as e:
|
|
503
462
|
log_progress(f"⚠️ Error during state change detection: {e}")
|
|
504
463
|
state_changed = False
|
|
505
|
-
# Fallback: Just wait a bit
|
|
506
464
|
time.sleep(1.5)
|
|
507
465
|
|
|
508
|
-
# Build feedback message
|
|
509
466
|
feedback_parts = []
|
|
510
467
|
|
|
511
468
|
if execution_output:
|
|
@@ -523,56 +480,80 @@ async def execute_v3(
|
|
|
523
480
|
|
|
524
481
|
log_progress(f"✅ {feedback[:200]}")
|
|
525
482
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
})
|
|
483
|
+
session.chat_history.append(ChatHistoryMessage(role="user", content=f"Execution Result:\n```\n{feedback}\n```"))
|
|
484
|
+
|
|
485
|
+
# Introduce a small delay to allow UI effects to settle before checking completion
|
|
486
|
+
time.sleep(1.0) # Added delay
|
|
531
487
|
|
|
532
488
|
except Exception as e:
|
|
533
489
|
error_msg = f"Error during execution: {str(e)}"
|
|
534
490
|
log_progress(f"💥 Action failed: {error_msg}")
|
|
535
491
|
|
|
536
|
-
|
|
537
|
-
chat_history.append({
|
|
538
|
-
"role": "user",
|
|
539
|
-
"content": f"Execution Error:\n```\n{error_msg}\n```"
|
|
540
|
-
})
|
|
492
|
+
session.chat_history.append(ChatHistoryMessage(role="user", content=f"Execution Error:\n```\n{error_msg}\n```"))
|
|
541
493
|
|
|
542
|
-
|
|
543
|
-
# No code to execute
|
|
494
|
+
elif not code:
|
|
544
495
|
log_progress("⚠️ No action code provided by backend")
|
|
545
|
-
chat_history.append(
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
496
|
+
session.chat_history.append(ChatHistoryMessage(role="user", content="No code was provided. Please provide code to execute."))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# 4. Check if task is complete AFTER executing action
|
|
500
|
+
if step_result.get("completed", False):
|
|
501
|
+
success = step_result.get("success", False)
|
|
502
|
+
final_message = step_result.get("final_message", "Task completed")
|
|
503
|
+
|
|
504
|
+
duration = time.time() - start_time
|
|
505
|
+
|
|
506
|
+
if success:
|
|
507
|
+
log_progress(f"✅ Task completed successfully!")
|
|
508
|
+
else:
|
|
509
|
+
log_progress(f"❌ Task marked as failed")
|
|
510
|
+
|
|
511
|
+
# Finalize session on backend
|
|
512
|
+
finalize_result = await backend.finalize_session(session=session)
|
|
513
|
+
|
|
514
|
+
if success:
|
|
515
|
+
log_progress(f"✅ Task completed successfully in {len(session.steps)} steps")
|
|
516
|
+
log_progress(f"💰 Usage: {finalize_result.get('total_tokens', {}).get('total')} tokens, ${finalize_result.get('total_cost', 0):.4f}")
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
"status": "success",
|
|
520
|
+
"steps_taken": len(session.steps),
|
|
521
|
+
"final_message": final_message,
|
|
522
|
+
"message": f"✅ Success: {final_message}",
|
|
523
|
+
"tokens": finalize_result.get("total_tokens"),
|
|
524
|
+
"cost": finalize_result.get("total_cost"),
|
|
525
|
+
"duration_seconds": duration
|
|
526
|
+
}
|
|
527
|
+
else:
|
|
528
|
+
log_progress(f"❌ Task failed: {final_message}")
|
|
529
|
+
log_progress(f"💰 Usage: {finalize_result.get('total_tokens', {}).get('total')} tokens, ${finalize_result.get('total_cost', 0):.4f}")
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
"status": "failed",
|
|
533
|
+
"steps_taken": len(session.steps),
|
|
534
|
+
"final_message": final_message,
|
|
535
|
+
"message": f"❌ Failed: {final_message}",
|
|
536
|
+
"tokens": finalize_result.get("total_tokens"),
|
|
537
|
+
"cost": finalize_result.get("total_cost"),
|
|
538
|
+
"duration_seconds": duration
|
|
539
|
+
}
|
|
540
|
+
|
|
549
541
|
|
|
550
542
|
# Max steps reached
|
|
551
543
|
log_progress(f"⚠️ Reached maximum steps ({max_steps})")
|
|
552
|
-
log_progress(f"💰 Usage: {total_tokens['total']} tokens, ${total_cost:.4f}")
|
|
553
544
|
|
|
554
545
|
duration = time.time() - start_time
|
|
555
546
|
|
|
556
|
-
# Finalize session on backend
|
|
557
|
-
await backend.finalize_session(
|
|
558
|
-
api_key=quash_api_key,
|
|
559
|
-
session_id=session_id,
|
|
560
|
-
task=task,
|
|
561
|
-
device_serial=state.device_serial,
|
|
562
|
-
status="max_steps",
|
|
563
|
-
final_message=f"Reached maximum step limit of {max_steps}",
|
|
564
|
-
error=None,
|
|
565
|
-
duration_seconds=duration,
|
|
566
|
-
config=config
|
|
567
|
-
)
|
|
547
|
+
# Finalize session on backend
|
|
548
|
+
finalize_result = await backend.finalize_session(session=session)
|
|
568
549
|
|
|
569
550
|
return {
|
|
570
551
|
"status": "failed",
|
|
571
|
-
"steps_taken":
|
|
552
|
+
"steps_taken": len(session.steps),
|
|
572
553
|
"final_message": f"Reached maximum step limit of {max_steps}",
|
|
573
554
|
"message": "❌ Failed: Maximum steps reached",
|
|
574
|
-
"tokens": total_tokens,
|
|
575
|
-
"cost": total_cost,
|
|
555
|
+
"tokens": finalize_result.get("total_tokens"),
|
|
556
|
+
"cost": finalize_result.get("total_cost"),
|
|
576
557
|
"duration_seconds": duration
|
|
577
558
|
}
|
|
578
559
|
|
|
@@ -581,24 +562,14 @@ async def execute_v3(
|
|
|
581
562
|
duration = time.time() - start_time
|
|
582
563
|
|
|
583
564
|
# Finalize session on backend
|
|
584
|
-
await backend.finalize_session(
|
|
585
|
-
api_key=quash_api_key,
|
|
586
|
-
session_id=session_id,
|
|
587
|
-
task=task,
|
|
588
|
-
device_serial=state.device_serial,
|
|
589
|
-
status="interrupted",
|
|
590
|
-
final_message="Task interrupted by user",
|
|
591
|
-
error=None,
|
|
592
|
-
duration_seconds=duration,
|
|
593
|
-
config=config
|
|
594
|
-
)
|
|
565
|
+
finalize_result = await backend.finalize_session(session=session)
|
|
595
566
|
|
|
596
567
|
return {
|
|
597
568
|
"status": "interrupted",
|
|
598
569
|
"message": "ℹ️ Task execution interrupted",
|
|
599
|
-
"steps_taken":
|
|
600
|
-
"tokens": total_tokens,
|
|
601
|
-
"cost": total_cost,
|
|
570
|
+
"steps_taken": len(session.steps),
|
|
571
|
+
"tokens": finalize_result.get("total_tokens"),
|
|
572
|
+
"cost": finalize_result.get("total_cost"),
|
|
602
573
|
"duration_seconds": duration
|
|
603
574
|
}
|
|
604
575
|
|
|
@@ -608,25 +579,15 @@ async def execute_v3(
|
|
|
608
579
|
duration = time.time() - start_time
|
|
609
580
|
|
|
610
581
|
# Finalize session on backend
|
|
611
|
-
await backend.finalize_session(
|
|
612
|
-
api_key=quash_api_key,
|
|
613
|
-
session_id=session_id,
|
|
614
|
-
task=task,
|
|
615
|
-
device_serial=state.device_serial,
|
|
616
|
-
status="error",
|
|
617
|
-
final_message=None,
|
|
618
|
-
error=error_msg,
|
|
619
|
-
duration_seconds=duration,
|
|
620
|
-
config=config
|
|
621
|
-
)
|
|
582
|
+
finalize_result = await backend.finalize_session(session=session)
|
|
622
583
|
|
|
623
584
|
return {
|
|
624
585
|
"status": "error",
|
|
625
586
|
"message": f"💥 Execution error: {error_msg}",
|
|
626
587
|
"error": error_msg,
|
|
627
|
-
"steps_taken":
|
|
628
|
-
"tokens": total_tokens,
|
|
629
|
-
"cost": total_cost,
|
|
588
|
+
"steps_taken": len(session.steps),
|
|
589
|
+
"tokens": finalize_result.get("total_tokens"),
|
|
590
|
+
"cost": finalize_result.get("total_cost"),
|
|
630
591
|
"duration_seconds": duration
|
|
631
592
|
}
|
|
632
593
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quash-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.14
|
|
4
4
|
Summary: Model Context Protocol server for Quash - AI-powered mobile automation agent
|
|
5
5
|
Project-URL: Homepage, https://quashbugs.com
|
|
6
6
|
Project-URL: Repository, https://github.com/quash/quash-mcp
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
quash_mcp/__init__.py,sha256=LImiWCRgjAbb5DZXBq2DktUEAbftvnO61Vil4Ayun9A,39
|
|
2
2
|
quash_mcp/__main__.py,sha256=WCg5OlnXhr6i0XJHAUGpbhliMy3qE2SJkFzVD4wO-lw,239
|
|
3
|
-
quash_mcp/backend_client.py,sha256=
|
|
3
|
+
quash_mcp/backend_client.py,sha256=jQ_OFOhdbGlTr42VCZMu5XP3_TJPsWCpTX_2iBppafo,9949
|
|
4
|
+
quash_mcp/models.py,sha256=0S7uCZZRRtQuSwIwKTDKZnX2HRMYOBDoDcl7-b8Tzpk,1001
|
|
4
5
|
quash_mcp/server.py,sha256=scUGnplxjsvyYLK2q6hrjl-5Chkdnat9pODDtLzsQFY,15519
|
|
5
6
|
quash_mcp/state.py,sha256=Tnt795GnZcas-h62Y6KYyIZVopeoWPM0TbRwOeVFYj4,4394
|
|
6
7
|
quash_mcp/device/__init__.py,sha256=6e8CtHolt-vJKPxZUU_Vsd6-QGqos9VrFykaLTT90rk,772
|
|
7
|
-
quash_mcp/device/adb_tools.py,sha256=
|
|
8
|
+
quash_mcp/device/adb_tools.py,sha256=SsYnzGjG3XsfbiAHiC7PpgLdC149kRH-YkoXQZvxvWc,5439
|
|
8
9
|
quash_mcp/device/portal.py,sha256=sDLJOruUwwNNxIDriiXB4vT0BZYILidgzVgdhHCEkDY,5241
|
|
9
|
-
quash_mcp/device/state_capture.py,sha256=
|
|
10
|
+
quash_mcp/device/state_capture.py,sha256=WUNewRlgi_A6L8usWsga-9iqMTroCuSaZ0OdY8EheU0,3473
|
|
10
11
|
quash_mcp/tools/__init__.py,sha256=r4fMAjHDjHUbimRwYW7VYUDkQHs12UVsG_IBmWpeX9s,249
|
|
11
12
|
quash_mcp/tools/build.py,sha256=M6tGXWrQNkdtCYYrK14gUaoufQvyoor_hNN0lBPSVHY,30321
|
|
12
13
|
quash_mcp/tools/build_old.py,sha256=6M9gaqZ_dX4B7UFTxSMD8T1BX0zEwQUL7RJ8ItNfB54,6016
|
|
@@ -14,10 +15,10 @@ quash_mcp/tools/configure.py,sha256=cv4RTolu6qae-XzyACSJUDrALfd0gYC-XE5s66_zfNk,
|
|
|
14
15
|
quash_mcp/tools/connect.py,sha256=Kc7RGRUgtd2sR_bv6U4CB4kWSaLfsDc5kBo9u4FEjzs,4799
|
|
15
16
|
quash_mcp/tools/execute.py,sha256=kR3VzIl31Lek-js4Hgxs-S_ls4YwKnbqkt79KFbvFuM,909
|
|
16
17
|
quash_mcp/tools/execute_v2_backup.py,sha256=waWnaD0dEVcOJgRBbqZo3HnxME1s6YUOn8aRbm4R3X4,6081
|
|
17
|
-
quash_mcp/tools/execute_v3.py,sha256=
|
|
18
|
+
quash_mcp/tools/execute_v3.py,sha256=zlkQqjdohE6bFQKMwWf4MeL59iCAbcfOVW6dCNl_veQ,22661
|
|
18
19
|
quash_mcp/tools/runsuite.py,sha256=gohLk9FpN8v7F0a69fspqOqUexTcslpYf3qU-iIZZ3s,7220
|
|
19
20
|
quash_mcp/tools/usage.py,sha256=g76A6FO36fThoyRFG7q92QmS3Kh1pIKOrhYOzUdIubA,1155
|
|
20
|
-
quash_mcp-0.2.
|
|
21
|
-
quash_mcp-0.2.
|
|
22
|
-
quash_mcp-0.2.
|
|
23
|
-
quash_mcp-0.2.
|
|
21
|
+
quash_mcp-0.2.14.dist-info/METADATA,sha256=r-rZWyw5mSL799jHIeqqSYvt0q_G23VevDXfaPTBr3w,8424
|
|
22
|
+
quash_mcp-0.2.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
23
|
+
quash_mcp-0.2.14.dist-info/entry_points.txt,sha256=9sbDxrx0ApGDVRS-IE3mQgSao3DwKnnV_k-_ipFn9QI,52
|
|
24
|
+
quash_mcp-0.2.14.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|