sentienceapi 0.90.12__py3-none-any.whl → 0.92.2__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 sentienceapi might be problematic. Click here for more details.

Files changed (63) hide show
  1. sentience/__init__.py +14 -5
  2. sentience/_extension_loader.py +40 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +408 -25
  5. sentience/agent.py +804 -310
  6. sentience/agent_config.py +3 -0
  7. sentience/async_api.py +101 -0
  8. sentience/base_agent.py +95 -0
  9. sentience/browser.py +594 -25
  10. sentience/browser_evaluator.py +299 -0
  11. sentience/cloud_tracing.py +458 -36
  12. sentience/conversational_agent.py +79 -45
  13. sentience/element_filter.py +136 -0
  14. sentience/expect.py +98 -2
  15. sentience/extension/background.js +56 -185
  16. sentience/extension/content.js +117 -289
  17. sentience/extension/injected_api.js +799 -1374
  18. sentience/extension/manifest.json +1 -1
  19. sentience/extension/pkg/sentience_core.js +190 -396
  20. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  21. sentience/extension/release.json +47 -47
  22. sentience/formatting.py +9 -53
  23. sentience/inspector.py +183 -1
  24. sentience/llm_interaction_handler.py +191 -0
  25. sentience/llm_provider.py +256 -28
  26. sentience/llm_provider_utils.py +120 -0
  27. sentience/llm_response_builder.py +153 -0
  28. sentience/models.py +66 -1
  29. sentience/overlay.py +109 -2
  30. sentience/protocols.py +228 -0
  31. sentience/query.py +1 -1
  32. sentience/read.py +95 -3
  33. sentience/recorder.py +223 -3
  34. sentience/schemas/trace_v1.json +102 -9
  35. sentience/screenshot.py +48 -2
  36. sentience/sentience_methods.py +86 -0
  37. sentience/snapshot.py +309 -64
  38. sentience/snapshot_diff.py +141 -0
  39. sentience/text_search.py +119 -5
  40. sentience/trace_event_builder.py +129 -0
  41. sentience/trace_file_manager.py +197 -0
  42. sentience/trace_indexing/index_schema.py +95 -7
  43. sentience/trace_indexing/indexer.py +117 -14
  44. sentience/tracer_factory.py +119 -6
  45. sentience/tracing.py +172 -8
  46. sentience/utils/__init__.py +40 -0
  47. sentience/utils/browser.py +46 -0
  48. sentience/utils/element.py +257 -0
  49. sentience/utils/formatting.py +59 -0
  50. sentience/utils.py +1 -1
  51. sentience/visual_agent.py +2056 -0
  52. sentience/wait.py +70 -4
  53. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/METADATA +61 -22
  54. sentienceapi-0.92.2.dist-info/RECORD +65 -0
  55. sentienceapi-0.92.2.dist-info/licenses/LICENSE +24 -0
  56. sentienceapi-0.92.2.dist-info/licenses/LICENSE-APACHE +201 -0
  57. sentienceapi-0.92.2.dist-info/licenses/LICENSE-MIT +21 -0
  58. sentience/extension/test-content.js +0 -4
  59. sentienceapi-0.90.12.dist-info/RECORD +0 -46
  60. sentienceapi-0.90.12.dist-info/licenses/LICENSE.md +0 -43
  61. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/WHEEL +0 -0
  62. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/entry_points.txt +0 -0
  63. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/top_level.txt +0 -0
@@ -4,16 +4,20 @@ Cloud trace sink with pre-signed URL upload.
4
4
  Implements "Local Write, Batch Upload" pattern for enterprise cloud tracing.
5
5
  """
6
6
 
7
+ import base64
7
8
  import gzip
8
9
  import json
9
10
  import os
10
11
  import threading
11
12
  from collections.abc import Callable
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
14
  from pathlib import Path
13
- from typing import Any, Protocol
15
+ from typing import Any, Optional, Protocol, Union
14
16
 
15
17
  import requests
16
18
 
19
+ from sentience.models import TraceStats
20
+ from sentience.trace_file_manager import TraceFileManager
17
21
  from sentience.tracing import TraceSink
18
22
 
19
23
 
@@ -95,6 +99,7 @@ class CloudTraceSink(TraceSink):
95
99
  # Use persistent cache directory instead of temp file
96
100
  # This ensures traces survive process crashes
97
101
  cache_dir = Path.home() / ".sentience" / "traces" / "pending"
102
+ # Create directory if it doesn't exist (ensure_directory is for file paths, not dirs)
98
103
  cache_dir.mkdir(parents=True, exist_ok=True)
99
104
 
100
105
  # Persistent file (survives process crash)
@@ -103,9 +108,11 @@ class CloudTraceSink(TraceSink):
103
108
  self._closed = False
104
109
  self._upload_successful = False
105
110
 
106
- # File size tracking (NEW)
111
+ # File size tracking
107
112
  self.trace_file_size_bytes = 0
108
113
  self.screenshot_total_size_bytes = 0
114
+ self.screenshot_count = 0 # Track number of screenshots extracted
115
+ self.index_file_size_bytes = 0 # Track index file size
109
116
 
110
117
  def emit(self, event: dict[str, Any]) -> None:
111
118
  """
@@ -119,9 +126,7 @@ class CloudTraceSink(TraceSink):
119
126
  if self._closed:
120
127
  raise RuntimeError("CloudTraceSink is closed")
121
128
 
122
- json_str = json.dumps(event, ensure_ascii=False)
123
- self._trace_file.write(json_str + "\n")
124
- self._trace_file.flush() # Ensure written to disk
129
+ TraceFileManager.write_event(self._trace_file, event)
125
130
 
126
131
  def close(
127
132
  self,
@@ -142,9 +147,25 @@ class CloudTraceSink(TraceSink):
142
147
 
143
148
  self._closed = True
144
149
 
145
- # Close file first
150
+ # Flush and sync file to disk before closing to ensure all data is written
151
+ # This is critical on CI systems where file system operations may be slower
152
+ self._trace_file.flush()
153
+ try:
154
+ # Force OS to write buffered data to disk
155
+ os.fsync(self._trace_file.fileno())
156
+ except (OSError, AttributeError):
157
+ # Some file handles don't support fsync (e.g., StringIO in tests)
158
+ # This is fine - flush() is usually sufficient
159
+ pass
146
160
  self._trace_file.close()
147
161
 
162
+ # Ensure file exists and has content before proceeding
163
+ if not self._path.exists() or self._path.stat().st_size == 0:
164
+ # No events were emitted, nothing to upload
165
+ if self.logger:
166
+ self.logger.warning("No trace events to upload (file is empty or missing)")
167
+ return
168
+
148
169
  # Generate index after closing file
149
170
  self._generate_index()
150
171
 
@@ -165,21 +186,36 @@ class CloudTraceSink(TraceSink):
165
186
  """
166
187
  Internal upload method with progress tracking.
167
188
 
189
+ Extracts screenshots from trace events, uploads them separately,
190
+ then removes screenshot_base64 from events before uploading trace.
191
+
168
192
  Args:
169
193
  on_progress: Optional callback(uploaded_bytes, total_bytes) for progress updates
170
194
  """
171
195
  try:
172
- # Read and compress
173
- with open(self._path, "rb") as f:
196
+ # Step 1: Extract screenshots from trace events
197
+ screenshots = self._extract_screenshots_from_trace()
198
+ self.screenshot_count = len(screenshots)
199
+
200
+ # Step 2: Upload screenshots separately
201
+ if screenshots:
202
+ self._upload_screenshots(screenshots, on_progress)
203
+
204
+ # Step 3: Create cleaned trace file (without screenshot_base64)
205
+ cleaned_trace_path = self._path.with_suffix(".cleaned.jsonl")
206
+ self._create_cleaned_trace(cleaned_trace_path)
207
+
208
+ # Step 4: Read and compress cleaned trace
209
+ with open(cleaned_trace_path, "rb") as f:
174
210
  trace_data = f.read()
175
211
 
176
212
  compressed_data = gzip.compress(trace_data)
177
213
  compressed_size = len(compressed_data)
178
214
 
179
- # Measure trace file size (NEW)
215
+ # Measure trace file size
180
216
  self.trace_file_size_bytes = compressed_size
181
217
 
182
- # Log file sizes if logger is provided (NEW)
218
+ # Log file sizes if logger is provided
183
219
  if self.logger:
184
220
  self.logger.info(
185
221
  f"Trace file size: {self.trace_file_size_bytes / 1024 / 1024:.2f} MB"
@@ -192,8 +228,9 @@ class CloudTraceSink(TraceSink):
192
228
  if on_progress:
193
229
  on_progress(0, compressed_size)
194
230
 
195
- # Upload to DigitalOcean Spaces via pre-signed URL
196
- print(f"📤 [Sentience] Uploading trace to cloud ({compressed_size} bytes)...")
231
+ # Step 5: Upload cleaned trace to cloud
232
+ if self.logger:
233
+ self.logger.info(f"Uploading trace to cloud ({compressed_size} bytes)")
197
234
 
198
235
  response = requests.put(
199
236
  self.upload_url,
@@ -208,6 +245,8 @@ class CloudTraceSink(TraceSink):
208
245
  if response.status_code == 200:
209
246
  self._upload_successful = True
210
247
  print("✅ [Sentience] Trace uploaded successfully")
248
+ if self.logger:
249
+ self.logger.info("Trace uploaded successfully")
211
250
 
212
251
  # Report progress: complete
213
252
  if on_progress:
@@ -219,22 +258,28 @@ class CloudTraceSink(TraceSink):
219
258
  # Call /v1/traces/complete to report file sizes
220
259
  self._complete_trace()
221
260
 
222
- # Delete file only on successful upload
223
- if os.path.exists(self._path):
224
- try:
225
- os.remove(self._path)
226
- except Exception:
227
- pass # Ignore cleanup errors
261
+ # Delete files only on successful upload
262
+ self._cleanup_files()
263
+
264
+ # Clean up temporary cleaned trace file
265
+ if cleaned_trace_path.exists():
266
+ cleaned_trace_path.unlink()
228
267
  else:
229
268
  self._upload_successful = False
230
269
  print(f"❌ [Sentience] Upload failed: HTTP {response.status_code}")
231
- print(f" Response: {response.text}")
270
+ print(f" Response: {response.text[:200]}")
232
271
  print(f" Local trace preserved at: {self._path}")
272
+ if self.logger:
273
+ self.logger.error(
274
+ f"Upload failed: HTTP {response.status_code}, Response: {response.text[:200]}"
275
+ )
233
276
 
234
277
  except Exception as e:
235
278
  self._upload_successful = False
236
279
  print(f"❌ [Sentience] Error uploading trace: {e}")
237
280
  print(f" Local trace preserved at: {self._path}")
281
+ if self.logger:
282
+ self.logger.error(f"Error uploading trace: {e}")
238
283
  # Don't raise - preserve trace locally even if upload fails
239
284
 
240
285
  def _generate_index(self) -> None:
@@ -242,10 +287,15 @@ class CloudTraceSink(TraceSink):
242
287
  try:
243
288
  from .trace_indexing import write_trace_index
244
289
 
245
- write_trace_index(str(self._path))
290
+ # Use frontend format to ensure 'step' field is present (1-based)
291
+ # Frontend derives sequence from step.step - 1, so step must be valid
292
+ index_path = Path(str(self._path).replace(".jsonl", ".index.json"))
293
+ write_trace_index(str(self._path), str(index_path), frontend_format=True)
246
294
  except Exception as e:
247
295
  # Non-fatal: log but don't crash
248
296
  print(f"⚠️ Failed to generate trace index: {e}")
297
+ if self.logger:
298
+ self.logger.warning(f"Failed to generate trace index: {e}")
249
299
 
250
300
  def _upload_index(self) -> None:
251
301
  """
@@ -292,17 +342,35 @@ class CloudTraceSink(TraceSink):
292
342
  self.logger.warning("No upload URL in index upload response")
293
343
  return
294
344
 
295
- # Read and compress index file
296
- with open(index_path, "rb") as f:
297
- index_data = f.read()
345
+ # Read index file and update trace_file.path to cloud storage path
346
+ with open(index_path, encoding="utf-8") as f:
347
+ index_json = json.load(f)
348
+
349
+ # Extract cloud storage path from trace upload URL
350
+ # upload_url format: https://...digitaloceanspaces.com/traces/{run_id}.jsonl.gz
351
+ # Extract path: traces/{run_id}.jsonl.gz
352
+ try:
353
+ from urllib.parse import urlparse
354
+
355
+ parsed_url = urlparse(self.upload_url)
356
+ # Extract path after domain (e.g., /traces/run-123.jsonl.gz -> traces/run-123.jsonl.gz)
357
+ cloud_trace_path = parsed_url.path.lstrip("/")
358
+ # Update trace_file.path in index
359
+ if "trace_file" in index_json and isinstance(index_json["trace_file"], dict):
360
+ index_json["trace_file"]["path"] = cloud_trace_path
361
+ except Exception as e:
362
+ if self.logger:
363
+ self.logger.warning(f"Failed to extract cloud path from upload URL: {e}")
298
364
 
365
+ # Serialize updated index to JSON
366
+ index_data = json.dumps(index_json, indent=2).encode("utf-8")
299
367
  compressed_index = gzip.compress(index_data)
300
368
  index_size = len(compressed_index)
369
+ self.index_file_size_bytes = index_size # Track index file size
301
370
 
302
371
  if self.logger:
303
372
  self.logger.info(f"Index file size: {index_size / 1024:.2f} KB")
304
-
305
- print(f"📤 [Sentience] Uploading trace index ({index_size} bytes)...")
373
+ self.logger.info(f"Uploading trace index ({index_size} bytes)")
306
374
 
307
375
  # Upload index to cloud storage
308
376
  index_response = requests.put(
@@ -316,7 +384,8 @@ class CloudTraceSink(TraceSink):
316
384
  )
317
385
 
318
386
  if index_response.status_code == 200:
319
- print("✅ [Sentience] Trace index uploaded successfully")
387
+ if self.logger:
388
+ self.logger.info("Trace index uploaded successfully")
320
389
 
321
390
  # Delete local index file after successful upload
322
391
  try:
@@ -325,20 +394,108 @@ class CloudTraceSink(TraceSink):
325
394
  pass # Ignore cleanup errors
326
395
  else:
327
396
  if self.logger:
328
- self.logger.warning(
329
- f"Index upload failed: HTTP {index_response.status_code}"
330
- )
331
- print(f"⚠️ [Sentience] Index upload failed: HTTP {index_response.status_code}")
397
+ self.logger.warning(f"Index upload failed: HTTP {index_response.status_code}")
332
398
 
333
399
  except Exception as e:
334
400
  # Non-fatal: log but don't crash
335
401
  if self.logger:
336
402
  self.logger.warning(f"Error uploading trace index: {e}")
337
- print(f"⚠️ [Sentience] Error uploading trace index: {e}")
403
+
404
+ def _infer_final_status_from_trace(
405
+ self, events: list[dict[str, Any]], run_end: dict[str, Any] | None
406
+ ) -> str:
407
+ """
408
+ Infer final status from trace events by reading the trace file.
409
+
410
+ Returns:
411
+ Final status: "success", "failure", "partial", or "unknown"
412
+ """
413
+ try:
414
+ # Read trace file to analyze events
415
+ with open(self._path, encoding="utf-8") as f:
416
+ events = []
417
+ for line in f:
418
+ line = line.strip()
419
+ if not line:
420
+ continue
421
+ try:
422
+ event = json.loads(line)
423
+ events.append(event)
424
+ except json.JSONDecodeError:
425
+ continue
426
+
427
+ if not events:
428
+ return "unknown"
429
+
430
+ # Check for run_end event with status
431
+ for event in reversed(events):
432
+ if event.get("type") == "run_end":
433
+ status = event.get("data", {}).get("status")
434
+ if status in ("success", "failure", "partial", "unknown"):
435
+ return status
436
+
437
+ # Infer from error events
438
+ has_errors = any(e.get("type") == "error" for e in events)
439
+ if has_errors:
440
+ # Check if there are successful steps too (partial success)
441
+ step_ends = [e for e in events if e.get("type") == "step_end"]
442
+ if step_ends:
443
+ return "partial"
444
+ return "failure"
445
+
446
+ # If we have step_end events and no errors, likely success
447
+ step_ends = [e for e in events if e.get("type") == "step_end"]
448
+ if step_ends:
449
+ return "success"
450
+
451
+ return "unknown"
452
+
453
+ except Exception:
454
+ # If we can't read the trace, default to unknown
455
+ return "unknown"
456
+
457
+ def _extract_stats_from_trace(self) -> TraceStats:
458
+ """
459
+ Extract execution statistics from trace file.
460
+
461
+ Returns:
462
+ TraceStats with stats fields for /v1/traces/complete
463
+ """
464
+ try:
465
+ # Check if file exists before reading
466
+ if not self._path.exists():
467
+ if self.logger:
468
+ self.logger.warning(f"Trace file not found: {self._path}")
469
+ return TraceStats(
470
+ total_steps=0,
471
+ total_events=0,
472
+ duration_ms=None,
473
+ final_status="unknown",
474
+ started_at=None,
475
+ ended_at=None,
476
+ )
477
+
478
+ # Read trace file to extract stats
479
+ events = TraceFileManager.read_events(self._path)
480
+ # Use TraceFileManager to extract stats (with custom status inference)
481
+ return TraceFileManager.extract_stats(
482
+ events, infer_status_func=self._infer_final_status_from_trace
483
+ )
484
+ except Exception as e:
485
+ if self.logger:
486
+ self.logger.warning(f"Error extracting stats from trace: {e}")
487
+ return TraceStats(
488
+ total_steps=0,
489
+ total_events=0,
490
+ duration_ms=None,
491
+ final_status="unknown",
492
+ started_at=None,
493
+ ended_at=None,
494
+ )
338
495
 
339
496
  def _complete_trace(self) -> None:
340
497
  """
341
- Call /v1/traces/complete to report file sizes to gateway.
498
+ Call /v1/traces/complete to report file sizes and stats to gateway.
342
499
 
343
500
  This is a best-effort call - failures are logged but don't affect upload success.
344
501
  """
@@ -347,15 +504,24 @@ class CloudTraceSink(TraceSink):
347
504
  return
348
505
 
349
506
  try:
507
+ # Extract stats from trace file
508
+ stats = self._extract_stats_from_trace()
509
+
510
+ # Build completion payload with stats and file size fields
511
+ completion_payload = {
512
+ **stats.model_dump(), # Convert TraceStats to dict
513
+ "trace_file_size_bytes": self.trace_file_size_bytes,
514
+ "screenshot_total_size_bytes": self.screenshot_total_size_bytes,
515
+ "screenshot_count": self.screenshot_count,
516
+ "index_file_size_bytes": self.index_file_size_bytes,
517
+ }
518
+
350
519
  response = requests.post(
351
520
  f"{self.api_url}/v1/traces/complete",
352
521
  headers={"Authorization": f"Bearer {self.api_key}"},
353
522
  json={
354
523
  "run_id": self.run_id,
355
- "stats": {
356
- "trace_file_size_bytes": self.trace_file_size_bytes,
357
- "screenshot_total_size_bytes": self.screenshot_total_size_bytes,
358
- },
524
+ "stats": completion_payload,
359
525
  },
360
526
  timeout=10,
361
527
  )
@@ -374,6 +540,262 @@ class CloudTraceSink(TraceSink):
374
540
  if self.logger:
375
541
  self.logger.warning(f"Error reporting trace completion: {e}")
376
542
 
543
+ def _extract_screenshots_from_trace(self) -> dict[int, dict[str, Any]]:
544
+ """
545
+ Extract screenshots from trace events.
546
+
547
+ Returns:
548
+ dict mapping sequence number to screenshot data:
549
+ {seq: {"base64": str, "format": str, "step_id": str}}
550
+ """
551
+ screenshots: dict[int, dict[str, Any]] = {}
552
+ sequence = 0
553
+
554
+ try:
555
+ # Check if file exists before reading
556
+ if not self._path.exists():
557
+ if self.logger:
558
+ self.logger.warning(f"Trace file not found: {self._path}")
559
+ return screenshots
560
+
561
+ events = TraceFileManager.read_events(self._path)
562
+ for event in events:
563
+ # Check if this is a snapshot event with screenshot
564
+ if event.get("type") == "snapshot":
565
+ data = event.get("data", {})
566
+ screenshot_base64 = data.get("screenshot_base64")
567
+
568
+ if screenshot_base64:
569
+ sequence += 1
570
+ screenshots[sequence] = {
571
+ "base64": screenshot_base64,
572
+ "format": data.get("screenshot_format", "jpeg"),
573
+ "step_id": event.get("step_id"),
574
+ }
575
+ except Exception as e:
576
+ if self.logger:
577
+ self.logger.error(f"Error extracting screenshots: {e}")
578
+
579
+ return screenshots
580
+
581
+ def _create_cleaned_trace(self, output_path: Path) -> None:
582
+ """
583
+ Create trace file without screenshot_base64 fields.
584
+
585
+ Args:
586
+ output_path: Path to write cleaned trace file
587
+ """
588
+ try:
589
+ # Check if file exists before reading
590
+ if not self._path.exists():
591
+ if self.logger:
592
+ self.logger.warning(f"Trace file not found: {self._path}")
593
+ # Create empty cleaned trace file
594
+ output_path.parent.mkdir(parents=True, exist_ok=True)
595
+ output_path.touch()
596
+ return
597
+
598
+ events = TraceFileManager.read_events(self._path)
599
+ with open(output_path, "w", encoding="utf-8") as outfile:
600
+ for event in events:
601
+ # Remove screenshot_base64 from snapshot events
602
+ if event.get("type") == "snapshot":
603
+ data = event.get("data", {})
604
+ if "screenshot_base64" in data:
605
+ # Create copy without screenshot fields
606
+ cleaned_data = {
607
+ k: v
608
+ for k, v in data.items()
609
+ if k not in ("screenshot_base64", "screenshot_format")
610
+ }
611
+ event["data"] = cleaned_data
612
+
613
+ # Write cleaned event
614
+ TraceFileManager.write_event(outfile, event)
615
+ except Exception as e:
616
+ if self.logger:
617
+ self.logger.error(f"Error creating cleaned trace: {e}")
618
+ raise
619
+
620
+ def _request_screenshot_urls(self, sequences: list[int]) -> dict[int, str]:
621
+ """
622
+ Request pre-signed upload URLs for screenshots from gateway.
623
+
624
+ Args:
625
+ sequences: List of screenshot sequence numbers
626
+
627
+ Returns:
628
+ dict mapping sequence number to upload URL
629
+ """
630
+ if not self.api_key or not sequences:
631
+ return {}
632
+
633
+ try:
634
+ response = requests.post(
635
+ f"{self.api_url}/v1/screenshots/init",
636
+ headers={"Authorization": f"Bearer {self.api_key}"},
637
+ json={
638
+ "run_id": self.run_id,
639
+ "sequences": sequences,
640
+ },
641
+ timeout=10,
642
+ )
643
+
644
+ if response.status_code == 200:
645
+ data = response.json()
646
+ # Gateway returns sequences as strings in JSON, convert to int keys
647
+ upload_urls = data.get("upload_urls", {})
648
+ result = {int(k): v for k, v in upload_urls.items()}
649
+ if self.logger:
650
+ self.logger.info(f"Received {len(result)} screenshot upload URLs")
651
+ return result
652
+ else:
653
+ error_msg = f"Failed to get screenshot URLs: HTTP {response.status_code}"
654
+ if self.logger:
655
+ # Try to get error details
656
+ try:
657
+ error_data = response.json()
658
+ error_detail = error_data.get("error") or error_data.get("message", "")
659
+ if error_detail:
660
+ self.logger.warning(f"{error_msg}: {error_detail}")
661
+ else:
662
+ self.logger.warning(f"{error_msg}: {response.text[:200]}")
663
+ except Exception:
664
+ self.logger.warning(f"{error_msg}: {response.text[:200]}")
665
+ return {}
666
+ except Exception as e:
667
+ error_msg = f"Error requesting screenshot URLs: {e}"
668
+ if self.logger:
669
+ self.logger.warning(error_msg)
670
+ return {}
671
+
672
+ def _upload_screenshots(
673
+ self,
674
+ screenshots: dict[int, dict[str, Any]],
675
+ on_progress: Callable[[int, int], None] | None = None,
676
+ ) -> None:
677
+ """
678
+ Upload screenshots extracted from trace events.
679
+
680
+ Steps:
681
+ 1. Request pre-signed URLs from gateway (/v1/screenshots/init)
682
+ 2. Decode base64 to image bytes
683
+ 3. Upload screenshots in parallel (10 concurrent workers)
684
+ 4. Track upload progress
685
+
686
+ Args:
687
+ screenshots: dict mapping sequence to screenshot data
688
+ on_progress: Optional callback(uploaded_count, total_count)
689
+ """
690
+ if not screenshots:
691
+ return
692
+
693
+ # 1. Request pre-signed URLs from gateway
694
+ sequences = sorted(screenshots.keys())
695
+ if self.logger:
696
+ self.logger.info(f"Requesting upload URLs for {len(sequences)} screenshot(s)")
697
+ upload_urls = self._request_screenshot_urls(sequences)
698
+
699
+ if not upload_urls:
700
+ if self.logger:
701
+ self.logger.warning(
702
+ "No screenshot upload URLs received, skipping upload. "
703
+ "This may indicate API key permission issue, gateway error, or network problem."
704
+ )
705
+ return
706
+
707
+ # 2. Upload screenshots in parallel
708
+ uploaded_count = 0
709
+ total_count = len(upload_urls)
710
+ failed_sequences: list[int] = []
711
+
712
+ def upload_one(seq: int, url: str) -> bool:
713
+ """Upload a single screenshot. Returns True if successful."""
714
+ try:
715
+ screenshot_data = screenshots[seq]
716
+ base64_str = screenshot_data["base64"]
717
+ format_str = screenshot_data.get("format", "jpeg")
718
+
719
+ # Decode base64 to image bytes
720
+ image_bytes = base64.b64decode(base64_str)
721
+ image_size = len(image_bytes)
722
+
723
+ # Update total size
724
+ self.screenshot_total_size_bytes += image_size
725
+
726
+ # Upload to pre-signed URL
727
+ response = requests.put(
728
+ url,
729
+ data=image_bytes, # Binary image data
730
+ headers={
731
+ "Content-Type": f"image/{format_str}",
732
+ },
733
+ timeout=30, # 30 second timeout per screenshot
734
+ )
735
+
736
+ if response.status_code == 200:
737
+ if self.logger:
738
+ self.logger.info(
739
+ f"Screenshot {seq} uploaded successfully ({image_size / 1024:.1f} KB)"
740
+ )
741
+ return True
742
+ else:
743
+ error_msg = f"Screenshot {seq} upload failed: HTTP {response.status_code}"
744
+ if self.logger:
745
+ try:
746
+ error_detail = response.text[:200]
747
+ if error_detail:
748
+ self.logger.warning(f"{error_msg}: {error_detail}")
749
+ else:
750
+ self.logger.warning(error_msg)
751
+ except Exception:
752
+ self.logger.warning(error_msg)
753
+ return False
754
+ except Exception as e:
755
+ error_msg = f"Screenshot {seq} upload error: {e}"
756
+ if self.logger:
757
+ self.logger.warning(error_msg)
758
+ return False
759
+
760
+ # Upload in parallel (max 10 concurrent)
761
+ with ThreadPoolExecutor(max_workers=10) as executor:
762
+ futures = {
763
+ executor.submit(upload_one, seq, url): seq for seq, url in upload_urls.items()
764
+ }
765
+
766
+ for future in as_completed(futures):
767
+ seq = futures[future]
768
+ if future.result():
769
+ uploaded_count += 1
770
+ if on_progress:
771
+ on_progress(uploaded_count, total_count)
772
+ else:
773
+ failed_sequences.append(seq)
774
+
775
+ # 3. Report results
776
+ if uploaded_count == total_count:
777
+ total_size_mb = self.screenshot_total_size_bytes / 1024 / 1024
778
+ if self.logger:
779
+ self.logger.info(
780
+ f"All {total_count} screenshots uploaded successfully "
781
+ f"(total size: {total_size_mb:.2f} MB)"
782
+ )
783
+ else:
784
+ if self.logger:
785
+ self.logger.warning(
786
+ f"Uploaded {uploaded_count}/{total_count} screenshots. "
787
+ f"Failed sequences: {failed_sequences if failed_sequences else 'none'}"
788
+ )
789
+
790
+ def _cleanup_files(self) -> None:
791
+ """Delete local files after successful upload."""
792
+ # Delete trace file
793
+ if os.path.exists(self._path):
794
+ try:
795
+ os.remove(self._path)
796
+ except Exception:
797
+ pass # Ignore cleanup errors
798
+
377
799
  def __enter__(self):
378
800
  """Context manager support."""
379
801
  return self