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.
- sentience/__init__.py +14 -5
- sentience/_extension_loader.py +40 -0
- sentience/action_executor.py +215 -0
- sentience/actions.py +408 -25
- sentience/agent.py +804 -310
- sentience/agent_config.py +3 -0
- sentience/async_api.py +101 -0
- sentience/base_agent.py +95 -0
- sentience/browser.py +594 -25
- sentience/browser_evaluator.py +299 -0
- sentience/cloud_tracing.py +458 -36
- sentience/conversational_agent.py +79 -45
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +117 -289
- sentience/extension/injected_api.js +799 -1374
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.js +190 -396
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +256 -28
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +66 -1
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +1 -1
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +102 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +309 -64
- sentience/snapshot_diff.py +141 -0
- sentience/text_search.py +119 -5
- sentience/trace_event_builder.py +129 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +117 -14
- sentience/tracer_factory.py +119 -6
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/utils/element.py +257 -0
- sentience/utils/formatting.py +59 -0
- sentience/utils.py +1 -1
- sentience/visual_agent.py +2056 -0
- sentience/wait.py +70 -4
- {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/METADATA +61 -22
- sentienceapi-0.92.2.dist-info/RECORD +65 -0
- sentienceapi-0.92.2.dist-info/licenses/LICENSE +24 -0
- sentienceapi-0.92.2.dist-info/licenses/LICENSE-APACHE +201 -0
- sentienceapi-0.92.2.dist-info/licenses/LICENSE-MIT +21 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.12.dist-info/RECORD +0 -46
- sentienceapi-0.90.12.dist-info/licenses/LICENSE.md +0 -43
- {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/top_level.txt +0 -0
sentience/cloud_tracing.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
173
|
-
|
|
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
|
|
215
|
+
# Measure trace file size
|
|
180
216
|
self.trace_file_size_bytes = compressed_size
|
|
181
217
|
|
|
182
|
-
# Log file sizes if logger is provided
|
|
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
|
-
#
|
|
196
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
with open(index_path, "
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|