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.
- sentience/__init__.py +120 -6
- sentience/_extension_loader.py +156 -1
- sentience/action_executor.py +217 -0
- sentience/actions.py +758 -30
- sentience/agent.py +806 -293
- sentience/agent_config.py +3 -0
- sentience/agent_runtime.py +840 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +89 -1141
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +372 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +483 -0
- sentience/base_agent.py +95 -0
- sentience/browser.py +678 -39
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cloud_tracing.py +507 -42
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +77 -43
- sentience/cursor_policy.py +142 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +150 -287
- sentience/extension/injected_api.js +1088 -1368
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.d.ts +22 -22
- sentience/extension/pkg/sentience_core.js +275 -433
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/failure_artifacts.py +241 -0
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/integrations/__init__.py +6 -0
- sentience/integrations/langchain/__init__.py +12 -0
- sentience/integrations/langchain/context.py +18 -0
- sentience/integrations/langchain/core.py +326 -0
- sentience/integrations/langchain/tools.py +180 -0
- sentience/integrations/models.py +46 -0
- sentience/integrations/pydanticai/__init__.py +15 -0
- sentience/integrations/pydanticai/deps.py +20 -0
- sentience/integrations/pydanticai/toolset.py +468 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +765 -66
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +595 -3
- sentience/ordinal.py +280 -0
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +67 -5
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +128 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +599 -55
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +120 -5
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +105 -48
- sentience/tracer_factory.py +120 -9
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/{utils.py → utils/element.py} +3 -42
- sentience/utils/formatting.py +59 -0
- sentience/verification.py +618 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +68 -2
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
- sentienceapi-0.98.0.dist-info/RECORD +92 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.16.dist-info/RECORD +0 -50
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
sentience/cloud_tracing.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
173
|
-
|
|
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
|
|
256
|
+
# Measure trace file size
|
|
180
257
|
self.trace_file_size_bytes = compressed_size
|
|
181
258
|
|
|
182
|
-
# Log file sizes if logger is provided
|
|
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
|
-
#
|
|
196
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
with open(index_path, "
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|