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