fleet-python 0.2.13__py3-none-any.whl → 0.2.16__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 fleet-python might be problematic. Click here for more details.

Files changed (41) hide show
  1. examples/diff_example.py +161 -0
  2. examples/dsl_example.py +50 -1
  3. examples/example_action_log.py +28 -0
  4. examples/example_mcp_anthropic.py +77 -0
  5. examples/example_mcp_openai.py +27 -0
  6. examples/example_task.py +199 -0
  7. examples/example_verifier.py +71 -0
  8. examples/query_builder_example.py +117 -0
  9. fleet/__init__.py +51 -40
  10. fleet/_async/base.py +14 -1
  11. fleet/_async/client.py +155 -19
  12. fleet/_async/env/client.py +4 -4
  13. fleet/_async/instance/__init__.py +1 -2
  14. fleet/_async/instance/client.py +3 -2
  15. fleet/_async/playwright.py +2 -2
  16. fleet/_async/resources/sqlite.py +654 -0
  17. fleet/_async/tasks.py +44 -0
  18. fleet/_async/verifiers/__init__.py +17 -0
  19. fleet/_async/verifiers/bundler.py +699 -0
  20. fleet/_async/verifiers/verifier.py +301 -0
  21. fleet/base.py +14 -1
  22. fleet/client.py +664 -12
  23. fleet/config.py +1 -1
  24. fleet/instance/__init__.py +1 -2
  25. fleet/instance/client.py +15 -5
  26. fleet/models.py +171 -4
  27. fleet/resources/browser.py +7 -8
  28. fleet/resources/mcp.py +60 -0
  29. fleet/resources/sqlite.py +654 -0
  30. fleet/tasks.py +44 -0
  31. fleet/types.py +18 -0
  32. fleet/verifiers/__init__.py +11 -5
  33. fleet/verifiers/bundler.py +699 -0
  34. fleet/verifiers/decorator.py +103 -0
  35. fleet/verifiers/verifier.py +301 -0
  36. {fleet_python-0.2.13.dist-info → fleet_python-0.2.16.dist-info}/METADATA +3 -42
  37. fleet_python-0.2.16.dist-info/RECORD +69 -0
  38. fleet_python-0.2.13.dist-info/RECORD +0 -52
  39. {fleet_python-0.2.13.dist-info → fleet_python-0.2.16.dist-info}/WHEEL +0 -0
  40. {fleet_python-0.2.13.dist-info → fleet_python-0.2.16.dist-info}/licenses/LICENSE +0 -0
  41. {fleet_python-0.2.13.dist-info → fleet_python-0.2.16.dist-info}/top_level.txt +0 -0
fleet/client.py CHANGED
@@ -14,49 +14,177 @@
14
14
 
15
15
  """Fleet API Client for making HTTP requests to Fleet services."""
16
16
 
17
- import os
17
+ import base64
18
+ import cloudpickle
18
19
  import httpx
19
20
  import logging
20
- from typing import Optional, List
21
+ import os
22
+ from typing import List, Optional
23
+ import json
24
+ import time
25
+ from datetime import datetime
26
+ from typing import Dict, List, Any, Tuple
21
27
 
22
28
  from .base import EnvironmentBase, SyncWrapper
23
- from .models import InstanceRequest, InstanceRecord, Environment as EnvironmentModel
29
+ from .models import (
30
+ InstanceRequest,
31
+ InstanceRecord,
32
+ Environment as EnvironmentModel,
33
+ VerifiersCheckResponse,
34
+ VerificationResponse,
35
+ VerifiersExecuteResponse,
36
+ ToolLogEntry,
37
+ ActionLogEntry,
38
+ EnvironmentSnapshot,
39
+ SnapshotValidation,
40
+ ToolLogResponse,
41
+ ToolSessionStartRequest,
42
+ ToolSessionStartResponse,
43
+ ToolLogQueryRequest,
44
+ )
24
45
 
25
46
  from .instance import (
26
47
  InstanceClient,
27
48
  ResetRequest,
28
49
  ResetResponse,
29
- ValidatorType,
30
50
  ExecuteFunctionResponse,
31
51
  )
32
52
  from .config import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, REGION_BASE_URL
33
53
  from .instance.base import default_httpx_client
54
+ from .instance.client import ValidatorType
55
+ from .instance.models import (
56
+ Resource as ResourceModel,
57
+ CDPDescribeResponse, # Add this
58
+ )
34
59
  from .resources.base import Resource
35
60
  from .resources.sqlite import SQLiteResource
36
61
  from .resources.browser import BrowserResource
62
+ from .resources.mcp import MCPResource
37
63
 
38
64
  logger = logging.getLogger(__name__)
39
65
 
40
66
 
41
- def _delete_instance(client: SyncWrapper, instance_id: str) -> InstanceRecord:
42
- response = client.request("DELETE", f"/v1/env/instances/{instance_id}")
43
- return InstanceRecord(**response.json())
67
+ class LoggingBrowserResource(BrowserResource):
68
+ """Browser resource wrapper that automatically logs all tool usage."""
69
+
70
+ def __init__(
71
+ self,
72
+ resource: ResourceModel,
73
+ client: "SyncWrapper",
74
+ session_id: Optional[str] = None,
75
+ ):
76
+ super().__init__(resource, client)
77
+ self._session_id = session_id
78
+
79
+ def _log_tool_action(
80
+ self,
81
+ action: str,
82
+ parameters: Dict[str, Any],
83
+ result: Any = None,
84
+ error: str = None,
85
+ ):
86
+ """Log a tool action to the tool_log table."""
87
+ if not self._session_id:
88
+ return
89
+
90
+ start_time = time.time()
91
+ try:
92
+ self.client.request(
93
+ "POST",
94
+ "/log-tool",
95
+ json={
96
+ "tool_name": "browser",
97
+ "action": action,
98
+ "parameters": parameters,
99
+ "result": result if result else {},
100
+ "success": error is None,
101
+ "error": error,
102
+ "duration_ms": int((time.time() - start_time) * 1000),
103
+ "session_id": self._session_id,
104
+ "user_agent": "fleet-sdk",
105
+ },
106
+ )
107
+ except Exception as e:
108
+ logger.warning(f"Failed to log tool action: {e}")
109
+
110
+ def start(self, width: int = 1920, height: int = 1080) -> CDPDescribeResponse:
111
+ """Start browser and log the action."""
112
+ parameters = {
113
+ "width": width,
114
+ "height": height,
115
+ "resolution": f"{width}x{height}",
116
+ }
117
+ try:
118
+ result = super().start(width, height)
119
+ self._log_tool_action("start", parameters, {"success": True})
120
+ return result
121
+ except Exception as e:
122
+ self._log_tool_action("start", parameters, error=str(e))
123
+ raise
124
+
125
+ def screenshot(self) -> Dict[str, Any]:
126
+ """Take screenshot and log the action."""
127
+ parameters = {}
128
+ try:
129
+ # This assumes there's a screenshot method - you may need to implement it
130
+ result = {
131
+ "url": self.devtools_url(),
132
+ "timestamp": datetime.utcnow().isoformat(),
133
+ }
134
+ self._log_tool_action("screenshot", parameters, result)
135
+ return result
136
+ except Exception as e:
137
+ self._log_tool_action("screenshot", parameters, error=str(e))
138
+ raise
44
139
 
45
140
 
46
141
  class Environment(EnvironmentBase):
47
- def __init__(self, client: SyncWrapper, **kwargs):
142
+ def __init__(self, client: Optional[SyncWrapper], **kwargs):
48
143
  super().__init__(**kwargs)
49
144
  self._client = client
50
145
  self._instance: Optional[InstanceClient] = None
146
+ self._apps: Dict[str, InstanceClient] = {}
147
+ self._session_id: Optional[str] = None # ADD THIS
51
148
 
52
149
  @property
53
150
  def instance(self) -> InstanceClient:
54
151
  if self._instance is None:
55
152
  self._instance = InstanceClient(
56
- self.manager_url, self._client.httpx_client
153
+ self.manager_url,
154
+ self.env_key,
155
+ self._client.httpx_client if self._client else None,
57
156
  )
58
157
  return self._instance
59
158
 
159
+ def app(self, name: str) -> InstanceClient:
160
+ if name not in self._apps:
161
+ # Extract base URL by removing the current app path (e.g., /sentry/api/v1/env)
162
+ # manager_url looks like: https://xxx.fleetai.com/sentry/api/v1/env
163
+ base_url = self.manager_url.split('/api/v1/env')[0]
164
+ # Remove the current app name (e.g., /sentry) to get the root
165
+ if '/' in base_url:
166
+ parts = base_url.rsplit('/', 1)
167
+ if len(parts) == 2:
168
+ base_url = parts[0]
169
+
170
+ self._apps[name] = InstanceClient(
171
+ f"{base_url}/{name}/api/v1/env",
172
+ self.env_key,
173
+ self._client.httpx_client if self._client else None,
174
+ )
175
+ return self._apps[name]
176
+
177
+ @property
178
+ def session_id(self) -> Optional[str]: # ADD THIS PROPERTY
179
+ """Get the current tool logging session ID."""
180
+ return self._session_id
181
+
182
+ @property
183
+ def _load_client(self) -> SyncWrapper:
184
+ if self._client is None:
185
+ raise ValueError("Client not initialized")
186
+ return self._client
187
+
60
188
  def reset(
61
189
  self, seed: Optional[int] = None, timestamp: Optional[int] = None
62
190
  ) -> ResetResponse:
@@ -65,8 +193,17 @@ class Environment(EnvironmentBase):
65
193
  def db(self, name: str = "current") -> SQLiteResource:
66
194
  return self.instance.db(name)
67
195
 
196
+ @property
197
+ def mcp(self) -> MCPResource:
198
+ return self.instance.mcp()
199
+
68
200
  def browser(self, name: str = "cdp") -> BrowserResource:
69
- return self.instance.browser(name)
201
+ """Get browser resource with automatic logging."""
202
+ base_browser = self.instance.browser(name)
203
+ # Wrap it with logging capabilities
204
+ return LoggingBrowserResource(
205
+ base_browser.resource, base_browser.client, self._session_id
206
+ )
70
207
 
71
208
  def state(self, uri: str) -> Resource:
72
209
  return self.instance.state(uri)
@@ -75,7 +212,16 @@ class Environment(EnvironmentBase):
75
212
  return self.instance.resources()
76
213
 
77
214
  def close(self) -> InstanceRecord:
78
- return _delete_instance(self._client, self.instance_id)
215
+ if hasattr(self, "_session_id") and self._session_id:
216
+ try:
217
+ self.instance.client.request(
218
+ "POST", f"/end-tool-session/{self._session_id}"
219
+ )
220
+ logger.info(f"Ended tool logging session: {self._session_id}")
221
+ except Exception as e:
222
+ logger.warning(f"Failed to end tool logging session: {e}")
223
+
224
+ return _delete_instance(self._load_client, self.instance_id)
79
225
 
80
226
  def verify(self, validator: ValidatorType) -> ExecuteFunctionResponse:
81
227
  return self.instance.verify(validator)
@@ -85,6 +231,155 @@ class Environment(EnvironmentBase):
85
231
  ) -> ExecuteFunctionResponse:
86
232
  return self.instance.verify_raw(function_code, function_name)
87
233
 
234
+ def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
235
+ return _check_bundle_exists(self._load_client, bundle_hash)
236
+
237
+ def execute_verifier_remote(
238
+ self,
239
+ bundle_data: bytes,
240
+ bundle_sha: str,
241
+ key: str,
242
+ function_name: str,
243
+ args: tuple,
244
+ kwargs: dict,
245
+ timeout: Optional[int] = 30,
246
+ needs_upload: bool = True,
247
+ ) -> VerifiersExecuteResponse:
248
+ return _execute_verifier_remote(
249
+ self._load_client,
250
+ bundle_data,
251
+ bundle_sha,
252
+ key,
253
+ function_name,
254
+ args,
255
+ kwargs,
256
+ timeout,
257
+ needs_upload,
258
+ )
259
+
260
+ def __getstate__(self):
261
+ state = self.__dict__.copy()
262
+ state.pop("_client", None)
263
+ state.pop("_instance", None)
264
+ return state
265
+
266
+ def __setstate__(self, state):
267
+ self.__dict__.update(state)
268
+
269
+ def get_snapshot(
270
+ self, browser_resource: Optional[BrowserResource] = None
271
+ ) -> EnvironmentSnapshot:
272
+ """
273
+ Get a snapshot of the current environment state including action logs and tool logs.
274
+
275
+ Args:
276
+ browser_resource: Optional browser resource to capture current state from
277
+
278
+ Returns:
279
+ EnvironmentSnapshot containing all logs and state information
280
+ """
281
+ # Get current timestamp
282
+ timestamp = datetime.utcnow().isoformat() + "Z"
283
+
284
+ # Get session ID from current tool logs or generate new one
285
+ session_id = self._get_current_session_id() or f"snapshot-{int(time.time())}"
286
+
287
+ # Query tool logs
288
+ tool_logs_response = self.instance.client.request(
289
+ "POST",
290
+ "/query-tool-logs",
291
+ json={
292
+ "session_id": session_id,
293
+ "limit": None, # Get all logs for this session
294
+ },
295
+ )
296
+ tool_logs_data = tool_logs_response.json()
297
+ tool_logs = [
298
+ ToolLogEntry(**entry) for entry in tool_logs_data.get("entries", [])
299
+ ]
300
+
301
+ # Query action logs
302
+ action_logs_query = """
303
+ SELECT id, timestamp, action_type, payload, sql, args, path
304
+ FROM action_log
305
+ ORDER BY timestamp DESC
306
+ LIMIT 10000
307
+ """
308
+ action_logs_response = self.instance.client.request(
309
+ "POST",
310
+ "/resources/sqlite/action_log/query",
311
+ json={"query": action_logs_query, "read_only": True},
312
+ )
313
+ action_logs_data = action_logs_response.json()
314
+
315
+ action_logs = []
316
+ if action_logs_data.get("success") and action_logs_data.get("rows"):
317
+ columns = action_logs_data["columns"]
318
+ for row in action_logs_data["rows"]:
319
+ entry_dict = dict(zip(columns, row))
320
+ action_logs.append(ActionLogEntry(**entry_dict))
321
+
322
+ # Get current page URL and viewport if browser is available
323
+ page_url = ""
324
+ viewport_size = (1920, 1080) # Default
325
+
326
+ if browser_resource:
327
+ try:
328
+ # Get current page URL via CDP
329
+ cdp_info = browser_resource.describe()
330
+ # You might need to implement a method to get current URL via CDP
331
+ # For now, we'll look for the last navigation in tool logs
332
+ for log in reversed(tool_logs):
333
+ if log.tool_name == "browser" and log.action == "screenshot":
334
+ if log.result and "url" in log.result:
335
+ page_url = log.result["url"]
336
+ break
337
+
338
+ # Get viewport size from last browser start or from logs
339
+ for log in reversed(tool_logs):
340
+ if log.tool_name == "browser" and log.action == "start":
341
+ if log.parameters and "resolution" in log.parameters:
342
+ res = log.parameters["resolution"].split("x")
343
+ viewport_size = (int(res[0]), int(res[1]))
344
+ break
345
+ except Exception as e:
346
+ logger.warning(f"Could not get browser state: {e}")
347
+
348
+ # Create snapshot
349
+ return EnvironmentSnapshot(
350
+ env_key=self.env_key,
351
+ instance_id=self.instance_id,
352
+ timestamp=timestamp,
353
+ session_id=session_id,
354
+ tool_logs=tool_logs,
355
+ action_logs=action_logs,
356
+ page_url=page_url,
357
+ viewport_size=viewport_size,
358
+ metadata={
359
+ "snapshot_version": "1.0",
360
+ "tool_log_count": len(tool_logs),
361
+ "action_log_count": len(action_logs),
362
+ },
363
+ )
364
+
365
+ def _get_current_session_id(self) -> Optional[str]:
366
+ """Get the current session ID from the environment."""
367
+ # First try to use the stored session ID
368
+ if hasattr(self, "_session_id") and self._session_id:
369
+ return self._session_id
370
+
371
+ # Otherwise, try to get the most recent session ID from tool logs
372
+ try:
373
+ response = self.instance.client.request(
374
+ "POST", "/query-tool-logs", json={"limit": 1}
375
+ )
376
+ data = response.json()
377
+ if data.get("entries") and len(data["entries"]) > 0:
378
+ return data["entries"][0].get("session_id")
379
+ except Exception:
380
+ pass
381
+ return None
382
+
88
383
 
89
384
  class Fleet:
90
385
  def __init__(
@@ -115,7 +410,10 @@ class Fleet:
115
410
  return EnvironmentModel(**response.json())
116
411
 
117
412
  def make(
118
- self, env_key: str, region: Optional[str] = None
413
+ self,
414
+ env_key: str,
415
+ region: Optional[str] = None,
416
+ session_id: Optional[str] = None,
119
417
  ) -> Environment:
120
418
  if ":" in env_key:
121
419
  env_key_part, version = env_key.split(":", 1)
@@ -135,6 +433,31 @@ class Fleet:
135
433
  )
136
434
  instance = Environment(client=self.client, **response.json())
137
435
  instance.instance.load()
436
+
437
+ # Start tool logging session automatically
438
+ if session_id is None:
439
+ session_id = f"env-{instance.instance_id}-{int(time.time())}"
440
+
441
+ try:
442
+ instance.instance.client.request(
443
+ "POST",
444
+ "/start-tool-session",
445
+ json={
446
+ "session_id": session_id,
447
+ "metadata": {
448
+ "env_key": env_key,
449
+ "instance_id": instance.instance_id,
450
+ "region": region or "default",
451
+ "started_at": datetime.utcnow().isoformat() + "Z",
452
+ },
453
+ },
454
+ )
455
+ instance._session_id = session_id
456
+ logger.info(f"Started tool logging session: {session_id}")
457
+ except Exception as e:
458
+ logger.warning(f"Failed to start tool logging session: {e}")
459
+ instance._session_id = None
460
+
138
461
  return instance
139
462
 
140
463
  def instances(
@@ -158,5 +481,334 @@ class Fleet:
158
481
  instance.instance.load()
159
482
  return instance
160
483
 
484
+ def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
485
+ return _check_bundle_exists(self.client, bundle_hash)
486
+
487
+ def execute_verifier_remote(
488
+ self, bundle_data: bytes, args: tuple, kwargs: dict, timeout: Optional[int] = 30
489
+ ) -> VerifiersExecuteResponse:
490
+ return _execute_verifier_remote(self.client, bundle_data, args, kwargs, timeout)
491
+
161
492
  def delete(self, instance_id: str) -> InstanceRecord:
162
493
  return _delete_instance(self.client, instance_id)
494
+
495
+ def resume(
496
+ self,
497
+ snapshot: EnvironmentSnapshot,
498
+ validate: bool = True,
499
+ playback_speed: float = 1.0,
500
+ ) -> Tuple[Environment, SnapshotValidation]:
501
+ """
502
+ Resume an environment from a snapshot by recreating the state.
503
+
504
+ Args:
505
+ snapshot: EnvironmentSnapshot to resume from
506
+ validate: Whether to validate the resumed state matches the snapshot
507
+ playback_speed: Speed multiplier for replaying actions (1.0 = normal speed)
508
+
509
+ Returns:
510
+ Tuple of (new Environment instance, validation results)
511
+ """
512
+ # Create new environment instance
513
+ new_env = self.make(snapshot.env_key)
514
+
515
+ # Start a new tool session for tracking
516
+ replay_session_id = f"replay-{snapshot.session_id}-{int(time.time())}"
517
+ new_env.instance.client.request(
518
+ "POST",
519
+ "/start-tool-session",
520
+ json={
521
+ "session_id": replay_session_id,
522
+ "metadata": {
523
+ "type": "snapshot_replay",
524
+ "original_session_id": snapshot.session_id,
525
+ "snapshot_timestamp": snapshot.timestamp,
526
+ },
527
+ },
528
+ )
529
+
530
+ # Update tool logs with environment name before replay
531
+ for log in snapshot.tool_logs:
532
+ log_dict = log.dict()
533
+ log_dict["session_id"] = replay_session_id
534
+ log_dict["metadata"] = {"env_name": snapshot.env_key, "replay": True}
535
+
536
+ # Start browser with same viewport
537
+ browser = new_env.browser()
538
+ browser.start(width=snapshot.viewport_size[0], height=snapshot.viewport_size[1])
539
+
540
+ from fleet.playwright import FleetPlaywrightWrapper
541
+
542
+ playwright_wrapper = FleetPlaywrightWrapper(
543
+ cdp_url=browser.cdp_url(), instance_client=new_env.instance
544
+ )
545
+
546
+ # Replay tool logs in order
547
+ validation_errors = []
548
+ last_timestamp = None
549
+
550
+ for i, tool_log in enumerate(snapshot.tool_logs):
551
+ try:
552
+ # Calculate wait time between actions
553
+ if last_timestamp and playback_speed > 0:
554
+ current_ts = datetime.fromisoformat(tool_log.timestamp.rstrip("Z"))
555
+ last_ts = datetime.fromisoformat(last_timestamp.rstrip("Z"))
556
+ wait_time = (current_ts - last_ts).total_seconds() / playback_speed
557
+ if wait_time > 0:
558
+ time.sleep(min(wait_time, 5)) # Cap at 5 seconds
559
+
560
+ # Replay the tool action
561
+ _replay_tool_action(
562
+ playwright_wrapper,
563
+ tool_log,
564
+ new_env.instance._client,
565
+ replay_session_id,
566
+ )
567
+
568
+ last_timestamp = tool_log.timestamp
569
+
570
+ except Exception as e:
571
+ error_msg = f"Failed to replay action {i}: {tool_log.tool_name}.{tool_log.action} - {e}"
572
+ logger.error(error_msg)
573
+ validation_errors.append(error_msg)
574
+
575
+ # End replay session
576
+ new_env.instance.client.request(
577
+ "POST", f"/end-tool-session/{replay_session_id}"
578
+ )
579
+
580
+ # Validate if requested
581
+ validation = SnapshotValidation(
582
+ success=True,
583
+ page_match=True,
584
+ action_log_match=True,
585
+ discrepancies=validation_errors,
586
+ message="Replay completed",
587
+ )
588
+
589
+ if validate:
590
+ validation = _validate_resumed_state(
591
+ new_env, snapshot, playwright_wrapper, validation_errors
592
+ )
593
+
594
+ return new_env, validation
595
+
596
+
597
+ # Shared
598
+ def _delete_instance(client: SyncWrapper, instance_id: str) -> InstanceRecord:
599
+ response = client.request("DELETE", f"/v1/env/instances/{instance_id}")
600
+ return InstanceRecord(**response.json())
601
+
602
+
603
+ def _check_bundle_exists(
604
+ client: SyncWrapper, bundle_hash: str
605
+ ) -> VerifiersCheckResponse:
606
+ response = client.request("GET", f"/v1/verifiers/check?sha256={bundle_hash}")
607
+ return VerifiersCheckResponse(**response.json())
608
+
609
+
610
+ def _execute_verifier_remote(
611
+ client: SyncWrapper,
612
+ bundle_data: bytes,
613
+ bundle_sha: str,
614
+ key: str,
615
+ function_name: str,
616
+ args: tuple,
617
+ kwargs: dict,
618
+ timeout: Optional[int] = 30,
619
+ needs_upload: bool = True,
620
+ ) -> VerifiersExecuteResponse:
621
+ # Pickle args and kwargs together
622
+ # The first arg should be None as a placeholder for env
623
+ args_with_none = (None,) + args
624
+ args_kwargs_pickled = cloudpickle.dumps({"args": args_with_none, "kwargs": kwargs})
625
+ args_kwargs_b64 = base64.b64encode(args_kwargs_pickled).decode("utf-8")
626
+
627
+ # Build request data
628
+ request_data = {
629
+ "key": key,
630
+ "sha256": bundle_sha,
631
+ "args": args_kwargs_b64,
632
+ "function_name": function_name,
633
+ "timeout": timeout,
634
+ "region": "us-west-1", # TODO: make configurable
635
+ }
636
+
637
+ # Add bundle data only if upload is needed
638
+ if needs_upload:
639
+ bundle_b64 = base64.b64encode(bundle_data).decode("utf-8")
640
+ request_data["bundle"] = bundle_b64
641
+
642
+ # Debug logging
643
+ logger.debug(
644
+ f"Sending verifier execute request: key={key}, sha256={bundle_sha[:8]}..., function_name={function_name}"
645
+ )
646
+ logger.debug(f"Request has bundle: {needs_upload}")
647
+ logger.debug(f"Using client with base_url: {client.base_url}")
648
+ logger.debug(f"Request data keys: {list(request_data.keys())}")
649
+ logger.debug(
650
+ f"Bundle size: {len(request_data.get('bundle', ''))} chars"
651
+ if "bundle" in request_data
652
+ else "No bundle"
653
+ )
654
+
655
+ # Note: This should be called on the instance URL, not the orchestrator
656
+ # The instance has manager URLs for verifier execution
657
+ response = client.request("POST", "/v1/verifiers/execute", json=request_data)
658
+
659
+ # Debug the response
660
+ response_json = response.json()
661
+ logger.debug(f"Verifier execute response: {response_json}")
662
+
663
+ return VerifiersExecuteResponse(**response_json)
664
+
665
+
666
+ def _replay_tool_action(
667
+ playwright_wrapper: "FleetPlaywrightWrapper",
668
+ tool_log: ToolLogEntry,
669
+ client: "SyncWrapper",
670
+ session_id: str,
671
+ ) -> None:
672
+ """Replay a single tool action."""
673
+ start_time = time.time()
674
+
675
+ try:
676
+ if tool_log.tool_name == "browser":
677
+ # Map browser actions to playwright wrapper methods
678
+ action_map = {
679
+ "screenshot": lambda: playwright_wrapper.screenshot(),
680
+ "left_click": lambda: playwright_wrapper.click(
681
+ tool_log.parameters.get("x"), tool_log.parameters.get("y")
682
+ ),
683
+ "type": lambda: playwright_wrapper.type(
684
+ tool_log.parameters.get("text", "")
685
+ ),
686
+ "key": lambda: playwright_wrapper.key(
687
+ tool_log.parameters.get("text", "")
688
+ ),
689
+ "scroll": lambda: playwright_wrapper.scroll(
690
+ direction=tool_log.parameters.get("scroll_direction", "down"),
691
+ amount=tool_log.parameters.get("scroll_amount", 3),
692
+ ),
693
+ "mouse_move": lambda: playwright_wrapper.mouse_move(
694
+ tool_log.parameters.get("x"), tool_log.parameters.get("y")
695
+ ),
696
+ "wait": lambda: time.sleep(tool_log.parameters.get("duration", 1)),
697
+ # Add more action mappings as needed
698
+ }
699
+
700
+ if tool_log.action in action_map:
701
+ result = action_map[tool_log.action]()
702
+ else:
703
+ logger.warning(f"Unknown browser action: {tool_log.action}")
704
+ result = None
705
+
706
+ elif tool_log.tool_name == "complete_task":
707
+ # Don't replay task completion
708
+ logger.info("Skipping complete_task during replay")
709
+ return
710
+
711
+ elif tool_log.tool_name == "report_issue":
712
+ # Log but don't replay issue reports
713
+ logger.info(f"Previous issue reported: {tool_log.parameters}")
714
+ return
715
+
716
+ elif tool_log.tool_name == "give_up":
717
+ # Don't replay give up
718
+ logger.warning(f"Original session gave up: {tool_log.parameters}")
719
+ return
720
+
721
+ # Log the replayed action
722
+ duration_ms = int((time.time() - start_time) * 1000)
723
+
724
+ client.request(
725
+ "POST",
726
+ "/log-tool",
727
+ json={
728
+ "tool_name": tool_log.tool_name,
729
+ "action": tool_log.action,
730
+ "parameters": tool_log.parameters,
731
+ "result": result if result else tool_log.result,
732
+ "success": True,
733
+ "duration_ms": duration_ms,
734
+ "session_id": session_id,
735
+ "user_agent": "snapshot_replay",
736
+ },
737
+ )
738
+
739
+ except Exception as e:
740
+ # Log failure
741
+ duration_ms = int((time.time() - start_time) * 1000)
742
+
743
+ client.request(
744
+ "POST",
745
+ "/log-tool",
746
+ json={
747
+ "tool_name": tool_log.tool_name,
748
+ "action": tool_log.action,
749
+ "parameters": tool_log.parameters,
750
+ "success": False,
751
+ "error": str(e),
752
+ "duration_ms": duration_ms,
753
+ "session_id": session_id,
754
+ "user_agent": "snapshot_replay",
755
+ },
756
+ )
757
+ raise
758
+
759
+
760
+ def _validate_resumed_state(
761
+ new_env: Environment,
762
+ snapshot: EnvironmentSnapshot,
763
+ playwright_wrapper: "FleetPlaywrightWrapper",
764
+ existing_errors: List[str],
765
+ ) -> SnapshotValidation:
766
+ """Validate that the resumed state matches the snapshot."""
767
+ discrepancies = existing_errors.copy()
768
+
769
+ # Check current page URL
770
+ page_match = True
771
+ try:
772
+ current_screenshot = playwright_wrapper.screenshot()
773
+ current_url = current_screenshot.get("url", "")
774
+
775
+ if current_url != snapshot.page_url:
776
+ page_match = False
777
+ discrepancies.append(
778
+ f"Page URL mismatch: expected '{snapshot.page_url}', got '{current_url}'"
779
+ )
780
+ except Exception as e:
781
+ page_match = False
782
+ discrepancies.append(f"Could not verify page URL: {e}")
783
+
784
+ # Compare action logs
785
+ action_log_match = True
786
+ try:
787
+ # Get new action logs
788
+ new_snapshot = new_env.get_snapshot()
789
+
790
+ # Compare counts
791
+ if len(new_snapshot.action_logs) != len(snapshot.action_logs):
792
+ action_log_match = False
793
+ discrepancies.append(
794
+ f"Action log count mismatch: expected {len(snapshot.action_logs)}, "
795
+ f"got {len(new_snapshot.action_logs)}"
796
+ )
797
+
798
+ # Could do more detailed comparison here if needed
799
+
800
+ except Exception as e:
801
+ action_log_match = False
802
+ discrepancies.append(f"Could not verify action logs: {e}")
803
+
804
+ success = page_match and action_log_match and len(discrepancies) == 0
805
+
806
+ return SnapshotValidation(
807
+ success=success,
808
+ page_match=page_match,
809
+ action_log_match=action_log_match,
810
+ discrepancies=discrepancies,
811
+ message="Validation completed"
812
+ if success
813
+ else "Validation failed with discrepancies",
814
+ )