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