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.
@@ -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", "https://mcpbe.quashbugs.com")
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, "credits": float},
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
- api_key: str,
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 - Step-by-step execution).
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
- data_dict = {
236
- "api_key": api_key,
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
- api_key: str,
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} - Status: {status}")
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
- json={
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
- logger.warning(f"Failed to finalize session: HTTP {response.status_code}")
349
- return {"finalized": False, "error": f"HTTP {response.status_code}"}
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}")
@@ -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
@@ -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 or "complete(" 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
- credits = user_info.get("credits", 0)
240
+ organization_credits = validation_result.get("organization_credits", 0)
257
241
 
258
- if credits <= 0:
242
+ if organization_credits <= 0:
259
243
  return {
260
244
  "status": "error",
261
- "message": f"❌ Insufficient credits. Current balance: ${credits:.2f}",
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: ${credits:.2f}")
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 execution
278
- start_time = time.time()
279
- session_id = f"session_{uuid.uuid4().hex[:12]}"
280
- step_number = 0
281
- chat_history = []
282
- total_tokens = {"prompt": 0, "completion": 0, "total": 0}
283
- total_cost = 0.0
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
- # 1. Capture device state (State A)
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
- # Only include screenshot if vision is enabled
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
- ui_state_dict = {
363
- "a11y_tree": [{"index": 0, "text": "Error capturing UI", "children": []}],
364
- "phone_state": {"package": "unknown"}
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
- api_key=quash_api_key,
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": step_number,
388
- "tokens": total_tokens,
389
- "cost": total_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 usage tracking
394
- step_tokens = step_result.get("tokens_used", {})
395
- step_cost = step_result.get("cost", 0.0)
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
- return {
441
- "status": "failed",
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
- # Store old UI state for comparison
455
- old_ui_state = ui_state_dict.copy()
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
- # Add execution result to chat history
527
- chat_history.append({
528
- "role": "user",
529
- "content": f"Execution Result:\n```\n{feedback}\n```"
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
- # Add error to chat history
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
- else:
543
- # No code to execute
494
+ elif not code:
544
495
  log_progress("⚠️ No action code provided by backend")
545
- chat_history.append({
546
- "role": "user",
547
- "content": "No code was provided. Please provide code to execute."
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 to create execution record
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": step_number,
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": step_number,
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": step_number,
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.12
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=zm_rh7C6yx6Y62YZ32w_nTiff0h8i0CE0OZXLUiqZwE,12794
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=Q0HIZHLBswaD94Qa7AA2VtjY31EZ68l0YClJDmMjOBU,3778
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=2aq7A-CwKRSbXZAob-JdoK6FfGfLJRB77tMYyo-O2Vo,3349
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=8WHiEcVWUcAekh7MGaqpXKYGAzdilQ1HaNankMoAxHI,23506
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.12.dist-info/METADATA,sha256=ymeZQAKXV5hQElUhEGeiM5HEyVG9seGjhl0q-Y87nYU,8424
21
- quash_mcp-0.2.12.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
- quash_mcp-0.2.12.dist-info/entry_points.txt,sha256=9sbDxrx0ApGDVRS-IE3mQgSao3DwKnnV_k-_ipFn9QI,52
23
- quash_mcp-0.2.12.dist-info/RECORD,,
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,,