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
@@ -13,6 +13,7 @@ class TraceFileInfo:
13
13
  path: str
14
14
  size_bytes: int
15
15
  sha256: str
16
+ line_count: int | None = None # Number of lines in the trace file
16
17
 
17
18
  def to_dict(self) -> dict:
18
19
  return asdict(self)
@@ -28,6 +29,12 @@ class TraceSummary:
28
29
  step_count: int
29
30
  error_count: int
30
31
  final_url: str | None
32
+ status: Literal["success", "failure", "partial", "unknown"] | None = None
33
+ agent_name: str | None = None # Agent name from run_start event
34
+ duration_ms: int | None = None # Calculated duration in milliseconds
35
+ counters: dict[str, int] | None = (
36
+ None # Aggregated counters (snapshot_count, action_count, error_count)
37
+ )
31
38
 
32
39
  def to_dict(self) -> dict:
33
40
  return asdict(self)
@@ -78,17 +85,18 @@ class StepIndex:
78
85
  step_index: int
79
86
  step_id: str
80
87
  goal: str | None
81
- status: Literal["ok", "error", "partial"]
88
+ status: Literal["success", "failure", "partial", "unknown"]
82
89
  ts_start: str
83
90
  ts_end: str
84
91
  offset_start: int
85
92
  offset_end: int
86
- url_before: str | None
87
- url_after: str | None
88
- snapshot_before: SnapshotInfo
89
- snapshot_after: SnapshotInfo
90
- action: ActionInfo
91
- counters: StepCounters
93
+ line_number: int | None = None # Line number for byte-range fetching
94
+ url_before: str | None = None
95
+ url_after: str | None = None
96
+ snapshot_before: SnapshotInfo = field(default_factory=SnapshotInfo)
97
+ snapshot_after: SnapshotInfo = field(default_factory=SnapshotInfo)
98
+ action: ActionInfo = field(default_factory=ActionInfo)
99
+ counters: StepCounters = field(default_factory=StepCounters)
92
100
 
93
101
  def to_dict(self) -> dict:
94
102
  result = asdict(self)
@@ -109,3 +117,83 @@ class TraceIndex:
109
117
  def to_dict(self) -> dict:
110
118
  """Convert to dictionary for JSON serialization."""
111
119
  return asdict(self)
120
+
121
+ def to_sentience_studio_dict(self) -> dict:
122
+ """
123
+ Convert to SS-compatible format.
124
+
125
+ Maps SDK field names to frontend expectations:
126
+ - created_at -> generated_at
127
+ - first_ts -> start_time
128
+ - last_ts -> end_time
129
+ - step_index (0-based) -> step (1-based)
130
+ - ts_start -> timestamp
131
+ - Filters out "unknown" status
132
+ """
133
+ from datetime import datetime
134
+
135
+ # Calculate duration if not already set
136
+ duration_ms = self.summary.duration_ms
137
+ if duration_ms is None and self.summary.first_ts and self.summary.last_ts:
138
+ try:
139
+ start = datetime.fromisoformat(self.summary.first_ts.replace("Z", "+00:00"))
140
+ end = datetime.fromisoformat(self.summary.last_ts.replace("Z", "+00:00"))
141
+ duration_ms = int((end - start).total_seconds() * 1000)
142
+ except (ValueError, AttributeError):
143
+ duration_ms = None
144
+
145
+ # Aggregate counters if not already set
146
+ counters = self.summary.counters
147
+ if counters is None:
148
+ snapshot_count = sum(step.counters.snapshots for step in self.steps)
149
+ action_count = sum(step.counters.actions for step in self.steps)
150
+ counters = {
151
+ "snapshot_count": snapshot_count,
152
+ "action_count": action_count,
153
+ "error_count": self.summary.error_count,
154
+ }
155
+
156
+ return {
157
+ "version": self.version,
158
+ "run_id": self.run_id,
159
+ "generated_at": self.created_at, # Renamed from created_at
160
+ "trace_file": {
161
+ "path": self.trace_file.path,
162
+ "size_bytes": self.trace_file.size_bytes,
163
+ "line_count": self.trace_file.line_count, # Added
164
+ },
165
+ "summary": {
166
+ "agent_name": self.summary.agent_name, # Added
167
+ "total_steps": self.summary.step_count, # Renamed from step_count
168
+ "status": (
169
+ self.summary.status if self.summary.status != "unknown" else None
170
+ ), # Filter out unknown
171
+ "start_time": self.summary.first_ts, # Renamed from first_ts
172
+ "end_time": self.summary.last_ts, # Renamed from last_ts
173
+ "duration_ms": duration_ms, # Added
174
+ "counters": counters, # Added
175
+ },
176
+ "steps": [
177
+ {
178
+ "step": s.step_index + 1, # Convert 0-based to 1-based
179
+ "byte_offset": s.offset_start,
180
+ "line_number": s.line_number, # Added
181
+ "timestamp": s.ts_start, # Use start time
182
+ "action": {
183
+ "type": s.action.type or "",
184
+ "goal": s.goal, # Move goal into action
185
+ "digest": s.action.args_digest,
186
+ },
187
+ "snapshot": (
188
+ {
189
+ "url": s.snapshot_after.url,
190
+ "digest": s.snapshot_after.digest,
191
+ }
192
+ if s.snapshot_after.url
193
+ else None
194
+ ),
195
+ "status": s.status if s.status != "unknown" else None, # Filter out unknown
196
+ }
197
+ for s in self.steps
198
+ ],
199
+ }
@@ -7,8 +7,9 @@ import json
7
7
  import os
8
8
  from datetime import datetime, timezone
9
9
  from pathlib import Path
10
- from typing import Any, Dict, List
10
+ from typing import Any, Optional
11
11
 
12
+ from ..canonicalization import canonicalize_element
12
13
  from .index_schema import (
13
14
  ActionInfo,
14
15
  SnapshotInfo,
@@ -20,30 +21,6 @@ from .index_schema import (
20
21
  )
21
22
 
22
23
 
23
- def _normalize_text(text: str | None, max_len: int = 80) -> str:
24
- """Normalize text for digest: trim, collapse whitespace, lowercase, cap length."""
25
- if not text:
26
- return ""
27
- # Trim and collapse whitespace
28
- normalized = " ".join(text.split())
29
- # Lowercase
30
- normalized = normalized.lower()
31
- # Cap length
32
- if len(normalized) > max_len:
33
- normalized = normalized[:max_len]
34
- return normalized
35
-
36
-
37
- def _round_bbox(bbox: dict[str, float], precision: int = 2) -> dict[str, int]:
38
- """Round bbox coordinates to reduce noise (default: 2px precision)."""
39
- return {
40
- "x": round(bbox.get("x", 0) / precision) * precision,
41
- "y": round(bbox.get("y", 0) / precision) * precision,
42
- "width": round(bbox.get("width", 0) / precision) * precision,
43
- "height": round(bbox.get("height", 0) / precision) * precision,
44
- }
45
-
46
-
47
24
  def _compute_snapshot_digest(snapshot_data: dict[str, Any]) -> str:
48
25
  """
49
26
  Compute stable digest of snapshot for diffing.
@@ -55,18 +32,8 @@ def _compute_snapshot_digest(snapshot_data: dict[str, Any]) -> str:
55
32
  viewport = snapshot_data.get("viewport", {})
56
33
  elements = snapshot_data.get("elements", [])
57
34
 
58
- # Canonicalize elements
59
- canonical_elements = []
60
- for elem in elements:
61
- canonical_elem = {
62
- "id": elem.get("id"),
63
- "role": elem.get("role", ""),
64
- "text_norm": _normalize_text(elem.get("text")),
65
- "bbox": _round_bbox(elem.get("bbox", {"x": 0, "y": 0, "width": 0, "height": 0})),
66
- "is_primary": elem.get("is_primary", False),
67
- "is_clickable": elem.get("is_clickable", False),
68
- }
69
- canonical_elements.append(canonical_elem)
35
+ # Canonicalize elements using shared helper
36
+ canonical_elements = [canonicalize_element(elem) for elem in elements]
70
37
 
71
38
  # Sort by element id for determinism
72
39
  canonical_elements.sort(key=lambda e: e.get("id", 0))
@@ -149,15 +116,21 @@ def build_trace_index(trace_path: str) -> TraceIndex:
149
116
  event_count = 0
150
117
  error_count = 0
151
118
  final_url = None
119
+ run_end_status = None # Track status from run_end event
120
+ agent_name = None # Extract from run_start event
121
+ line_count = 0 # Track total line count
152
122
 
153
123
  steps_by_id: dict[str, StepIndex] = {}
154
124
  step_order: list[str] = [] # Track order of first appearance
155
125
 
156
- # Stream through file, tracking byte offsets
126
+ # Stream through file, tracking byte offsets and line numbers
157
127
  with open(trace_path, "rb") as f:
158
128
  byte_offset = 0
129
+ line_number = 0 # Track line number for each event
159
130
 
160
131
  for line_bytes in f:
132
+ line_number += 1
133
+ line_count += 1
161
134
  line_len = len(line_bytes)
162
135
 
163
136
  try:
@@ -182,6 +155,10 @@ def build_trace_index(trace_path: str) -> TraceIndex:
182
155
  if event_type == "error":
183
156
  error_count += 1
184
157
 
158
+ # Extract agent_name from run_start event
159
+ if event_type == "run_start":
160
+ agent_name = data.get("agent")
161
+
185
162
  # Initialize step if first time seeing this step_id
186
163
  if step_id not in steps_by_id:
187
164
  step_order.append(step_id)
@@ -189,11 +166,12 @@ def build_trace_index(trace_path: str) -> TraceIndex:
189
166
  step_index=len(step_order),
190
167
  step_id=step_id,
191
168
  goal=None,
192
- status="partial",
169
+ status="failure", # Default to failure (will be updated by step_end event)
193
170
  ts_start=ts,
194
171
  ts_end=ts,
195
172
  offset_start=byte_offset,
196
173
  offset_end=byte_offset + line_len,
174
+ line_number=line_number, # Track line number
197
175
  url_before=None,
198
176
  url_after=None,
199
177
  snapshot_before=SnapshotInfo(),
@@ -207,6 +185,7 @@ def build_trace_index(trace_path: str) -> TraceIndex:
207
185
  # Update step metadata
208
186
  step.ts_end = ts
209
187
  step.offset_end = byte_offset + line_len
188
+ step.line_number = line_number # Update line number on each event
210
189
  step.counters.events += 1
211
190
 
212
191
  # Handle specific event types
@@ -214,7 +193,8 @@ def build_trace_index(trace_path: str) -> TraceIndex:
214
193
  step.goal = data.get("goal")
215
194
  step.url_before = data.get("pre_url")
216
195
 
217
- elif event_type == "snapshot":
196
+ elif event_type == "snapshot" or event_type == "snapshot_taken":
197
+ # Handle both "snapshot" (current) and "snapshot_taken" (schema) for backward compatibility
218
198
  snapshot_id = data.get("snapshot_id")
219
199
  url = data.get("url")
220
200
  digest = _compute_snapshot_digest(data)
@@ -231,7 +211,8 @@ def build_trace_index(trace_path: str) -> TraceIndex:
231
211
  step.counters.snapshots += 1
232
212
  final_url = url
233
213
 
234
- elif event_type == "action":
214
+ elif event_type == "action" or event_type == "action_executed":
215
+ # Handle both "action" (current) and "action_executed" (schema) for backward compatibility
235
216
  step.action = ActionInfo(
236
217
  type=data.get("type"),
237
218
  target_element_id=data.get("target_element_id"),
@@ -240,18 +221,83 @@ def build_trace_index(trace_path: str) -> TraceIndex:
240
221
  )
241
222
  step.counters.actions += 1
242
223
 
243
- elif event_type == "llm_response":
224
+ elif event_type == "llm_response" or event_type == "llm_called":
225
+ # Handle both "llm_response" (current) and "llm_called" (schema) for backward compatibility
244
226
  step.counters.llm_calls += 1
245
227
 
246
228
  elif event_type == "error":
247
- step.status = "error"
229
+ step.status = "failure"
248
230
 
249
231
  elif event_type == "step_end":
250
- if step.status != "error":
251
- step.status = "ok"
232
+ # Determine status from step_end event data
233
+ # Frontend expects: success, failure, or partial
234
+ # Logic: success = exec.success && verify.passed
235
+ # partial = exec.success && !verify.passed
236
+ # failure = !exec.success
237
+ exec_data = data.get("exec", {})
238
+ verify_data = data.get("verify", {})
239
+
240
+ exec_success = exec_data.get("success", False)
241
+ verify_passed = verify_data.get("passed", False)
242
+
243
+ if exec_success and verify_passed:
244
+ step.status = "success"
245
+ elif exec_success and not verify_passed:
246
+ step.status = "partial"
247
+ elif not exec_success:
248
+ step.status = "failure"
249
+ else:
250
+ # Fallback: if step_end exists but no exec/verify data, default to failure
251
+ step.status = "failure"
252
+
253
+ elif event_type == "run_end":
254
+ # Extract status from run_end event
255
+ run_end_status = data.get("status")
256
+ # Validate status value
257
+ if run_end_status not in ["success", "failure", "partial", "unknown"]:
258
+ run_end_status = None
252
259
 
253
260
  byte_offset += line_len
254
261
 
262
+ # Use run_end status if available, otherwise infer from step statuses
263
+ if run_end_status is None:
264
+ step_statuses = [step.status for step in steps_by_id.values()]
265
+ if step_statuses:
266
+ # Infer overall status from step statuses
267
+ if all(s == "success" for s in step_statuses):
268
+ run_end_status = "success"
269
+ elif any(s == "failure" for s in step_statuses):
270
+ # If any failure and no successes, it's failure; otherwise partial
271
+ if any(s == "success" for s in step_statuses):
272
+ run_end_status = "partial"
273
+ else:
274
+ run_end_status = "failure"
275
+ elif any(s == "partial" for s in step_statuses):
276
+ run_end_status = "partial"
277
+ else:
278
+ run_end_status = "failure" # Default to failure instead of unknown
279
+ else:
280
+ run_end_status = "failure" # Default to failure instead of unknown
281
+
282
+ # Calculate duration
283
+ duration_ms = None
284
+ if first_ts and last_ts:
285
+ try:
286
+ start = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
287
+ end = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
288
+ duration_ms = int((end - start).total_seconds() * 1000)
289
+ except (ValueError, AttributeError):
290
+ duration_ms = None
291
+
292
+ # Aggregate counters
293
+ snapshot_count = sum(step.counters.snapshots for step in steps_by_id.values())
294
+ action_count = sum(step.counters.actions for step in steps_by_id.values())
295
+ counters = {
296
+ "snapshot_count": snapshot_count,
297
+ "action_count": action_count,
298
+ "error_count": error_count,
299
+ }
300
+
255
301
  # Build summary
256
302
  summary = TraceSummary(
257
303
  first_ts=first_ts,
@@ -260,6 +306,10 @@ def build_trace_index(trace_path: str) -> TraceIndex:
260
306
  step_count=len(steps_by_id),
261
307
  error_count=error_count,
262
308
  final_url=final_url,
309
+ status=run_end_status,
310
+ agent_name=agent_name,
311
+ duration_ms=duration_ms,
312
+ counters=counters,
263
313
  )
264
314
 
265
315
  # Build steps list in order
@@ -270,6 +320,7 @@ def build_trace_index(trace_path: str) -> TraceIndex:
270
320
  path=str(trace_path),
271
321
  size_bytes=os.path.getsize(trace_path),
272
322
  sha256=_compute_file_sha256(str(trace_path)),
323
+ line_count=line_count,
273
324
  )
274
325
 
275
326
  # Build final index
@@ -285,13 +336,16 @@ def build_trace_index(trace_path: str) -> TraceIndex:
285
336
  return index
286
337
 
287
338
 
288
- def write_trace_index(trace_path: str, index_path: str | None = None) -> str:
339
+ def write_trace_index(
340
+ trace_path: str, index_path: str | None = None, frontend_format: bool = False
341
+ ) -> str:
289
342
  """
290
343
  Build index and write to file.
291
344
 
292
345
  Args:
293
346
  trace_path: Path to trace JSONL file
294
347
  index_path: Optional custom path for index file (default: trace_path with .index.json)
348
+ frontend_format: If True, write in frontend-compatible format (default: False)
295
349
 
296
350
  Returns:
297
351
  Path to written index file
@@ -301,8 +355,11 @@ def write_trace_index(trace_path: str, index_path: str | None = None) -> str:
301
355
 
302
356
  index = build_trace_index(trace_path)
303
357
 
304
- with open(index_path, "w") as f:
305
- json.dump(index.to_dict(), f, indent=2)
358
+ with open(index_path, "w", encoding="utf-8") as f:
359
+ if frontend_format:
360
+ json.dump(index.to_sentience_studio_dict(), f, indent=2)
361
+ else:
362
+ json.dump(index.to_dict(), f, indent=2)
306
363
 
307
364
  return index_path
308
365
 
@@ -7,16 +7,16 @@ Provides convenient factory function for creating tracers with cloud upload supp
7
7
  import gzip
8
8
  import os
9
9
  import uuid
10
+ from collections.abc import Callable
10
11
  from pathlib import Path
12
+ from typing import Any, Optional
11
13
 
12
14
  import requests
13
15
 
14
16
  from sentience.cloud_tracing import CloudTraceSink, SentienceLogger
17
+ from sentience.constants import SENTIENCE_API_URL
15
18
  from sentience.tracing import JsonlTraceSink, Tracer
16
19
 
17
- # Sentience API base URL (constant)
18
- SENTIENCE_API_URL = "https://api.sentienceapi.com"
19
-
20
20
 
21
21
  def create_tracer(
22
22
  api_key: str | None = None,
@@ -24,6 +24,11 @@ def create_tracer(
24
24
  api_url: str | None = None,
25
25
  logger: SentienceLogger | None = None,
26
26
  upload_trace: bool = False,
27
+ goal: str | None = None,
28
+ agent_type: str | None = None,
29
+ llm_model: str | None = None,
30
+ start_url: str | None = None,
31
+ screenshot_processor: Callable[[str], str] | None = None,
27
32
  ) -> Tracer:
28
33
  """
29
34
  Create tracer with automatic tier detection.
@@ -42,15 +47,42 @@ def create_tracer(
42
47
  upload_trace: Enable cloud trace upload (default: False). When True and api_key
43
48
  is provided, traces will be uploaded to cloud. When False, traces
44
49
  are saved locally only.
50
+ goal: User's goal/objective for this trace run. This will be displayed as the
51
+ trace name in the frontend. Should be descriptive and action-oriented.
52
+ Example: "Add wireless headphones to cart on Amazon"
53
+ agent_type: Type of agent running (e.g., "SentienceAgent", "CustomAgent")
54
+ llm_model: LLM model used (e.g., "gpt-4-turbo", "claude-3-5-sonnet")
55
+ start_url: Starting URL of the agent run (e.g., "https://amazon.com")
56
+ screenshot_processor: Optional function to process screenshots before upload.
57
+ Takes base64 string, returns processed base64 string.
58
+ Useful for PII redaction or custom image processing.
45
59
 
46
60
  Returns:
47
61
  Tracer configured with appropriate sink
48
62
 
49
63
  Example:
50
- >>> # Pro tier user
51
- >>> tracer = create_tracer(api_key="sk_pro_xyz", run_id="demo")
64
+ >>> # Pro tier user with goal
65
+ >>> tracer = create_tracer(
66
+ ... api_key="sk_pro_xyz",
67
+ ... run_id="demo",
68
+ ... goal="Add headphones to cart",
69
+ ... agent_type="SentienceAgent",
70
+ ... llm_model="gpt-4-turbo",
71
+ ... start_url="https://amazon.com"
72
+ ... )
52
73
  >>> # Returns: Tracer with CloudTraceSink
53
74
  >>>
75
+ >>> # With screenshot processor for PII redaction
76
+ >>> def redact_pii(screenshot_base64: str) -> str:
77
+ ... # Your custom redaction logic
78
+ ... return redacted_screenshot
79
+ >>>
80
+ >>> tracer = create_tracer(
81
+ ... api_key="sk_pro_xyz",
82
+ ... screenshot_processor=redact_pii
83
+ ... )
84
+ >>> # Screenshots will be processed before upload
85
+ >>>
54
86
  >>> # Free tier user
55
87
  >>> tracer = create_tracer(run_id="demo")
56
88
  >>> # Returns: Tracer with JsonlTraceSink (local-only)
@@ -73,11 +105,28 @@ def create_tracer(
73
105
  # 1. Try to initialize Cloud Sink (Pro/Enterprise tier) if upload enabled
74
106
  if api_key and upload_trace:
75
107
  try:
108
+ # Build metadata object for trace initialization
109
+ # Only include non-empty fields to avoid sending empty strings
110
+ metadata: dict[str, str] = {}
111
+ if goal and goal.strip():
112
+ metadata["goal"] = goal.strip()
113
+ if agent_type and agent_type.strip():
114
+ metadata["agent_type"] = agent_type.strip()
115
+ if llm_model and llm_model.strip():
116
+ metadata["llm_model"] = llm_model.strip()
117
+ if start_url and start_url.strip():
118
+ metadata["start_url"] = start_url.strip()
119
+
120
+ # Build request payload
121
+ payload: dict[str, Any] = {"run_id": run_id}
122
+ if metadata:
123
+ payload["metadata"] = metadata
124
+
76
125
  # Request pre-signed upload URL from backend
77
126
  response = requests.post(
78
127
  f"{api_url}/v1/traces/init",
79
128
  headers={"Authorization": f"Bearer {api_key}"},
80
- json={"run_id": run_id},
129
+ json=payload,
81
130
  timeout=10,
82
131
  )
83
132
 
@@ -96,16 +145,46 @@ def create_tracer(
96
145
  api_url=api_url,
97
146
  logger=logger,
98
147
  ),
148
+ screenshot_processor=screenshot_processor,
99
149
  )
100
150
  else:
101
151
  print("⚠️ [Sentience] Cloud init response missing upload_url")
152
+ print(f" Response data: {data}")
102
153
  print(" Falling back to local-only tracing")
103
154
 
104
155
  elif response.status_code == 403:
105
156
  print("⚠️ [Sentience] Cloud tracing requires Pro tier")
157
+ try:
158
+ error_data = response.json()
159
+ error_msg = error_data.get("error") or error_data.get("message", "")
160
+ if error_msg:
161
+ print(f" API Error: {error_msg}")
162
+ except Exception:
163
+ pass
164
+ print(" Falling back to local-only tracing")
165
+ elif response.status_code == 401:
166
+ print("⚠️ [Sentience] Cloud init failed: HTTP 401 Unauthorized")
167
+ print(" API key is invalid or expired")
168
+ try:
169
+ error_data = response.json()
170
+ error_msg = error_data.get("error") or error_data.get("message", "")
171
+ if error_msg:
172
+ print(f" API Error: {error_msg}")
173
+ except Exception:
174
+ pass
106
175
  print(" Falling back to local-only tracing")
107
176
  else:
108
177
  print(f"⚠️ [Sentience] Cloud init failed: HTTP {response.status_code}")
178
+ try:
179
+ error_data = response.json()
180
+ error_msg = error_data.get("error") or error_data.get(
181
+ "message", "Unknown error"
182
+ )
183
+ print(f" Error: {error_msg}")
184
+ if "tier" in error_msg.lower() or "subscription" in error_msg.lower():
185
+ print(f" 💡 This may be a tier/subscription issue")
186
+ except Exception:
187
+ print(f" Response: {response.text[:200]}")
109
188
  print(" Falling back to local-only tracing")
110
189
 
111
190
  except requests.exceptions.Timeout:
@@ -125,7 +204,11 @@ def create_tracer(
125
204
  local_path = traces_dir / f"{run_id}.jsonl"
126
205
  print(f"💾 [Sentience] Local tracing: {local_path}")
127
206
 
128
- return Tracer(run_id=run_id, sink=JsonlTraceSink(str(local_path)))
207
+ return Tracer(
208
+ run_id=run_id,
209
+ sink=JsonlTraceSink(str(local_path)),
210
+ screenshot_processor=screenshot_processor,
211
+ )
129
212
 
130
213
 
131
214
  def _recover_orphaned_traces(api_key: str, api_url: str = SENTIENCE_API_URL) -> None:
@@ -149,10 +232,23 @@ def _recover_orphaned_traces(api_key: str, api_url: str = SENTIENCE_API_URL) ->
149
232
  if not orphaned:
150
233
  return
151
234
 
152
- print(f"⚠️ [Sentience] Found {len(orphaned)} un-uploaded trace(s) from previous runs")
235
+ # Filter out test files (run_ids that start with "test-" or are clearly test data)
236
+ # These are likely from local testing and shouldn't be uploaded
237
+ test_patterns = ["test-", "test_", "test."]
238
+ valid_orphaned = [
239
+ f
240
+ for f in orphaned
241
+ if not any(f.stem.startswith(pattern) for pattern in test_patterns)
242
+ and not f.stem.startswith("test")
243
+ ]
244
+
245
+ if not valid_orphaned:
246
+ return
247
+
248
+ print(f"⚠️ [Sentience] Found {len(valid_orphaned)} un-uploaded trace(s) from previous runs")
153
249
  print(" Attempting to upload now...")
154
250
 
155
- for trace_file in orphaned:
251
+ for trace_file in valid_orphaned:
156
252
  try:
157
253
  # Extract run_id from filename (format: {run_id}.jsonl)
158
254
  run_id = trace_file.stem
@@ -166,6 +262,21 @@ def _recover_orphaned_traces(api_key: str, api_url: str = SENTIENCE_API_URL) ->
166
262
  )
167
263
 
168
264
  if response.status_code != 200:
265
+ # HTTP 409 means trace already exists (already uploaded)
266
+ # Treat as success and delete local file
267
+ if response.status_code == 409:
268
+ print(f"✅ Trace {run_id} already exists in cloud (skipping re-upload)")
269
+ # Delete local file since it's already in cloud
270
+ try:
271
+ os.remove(trace_file)
272
+ except Exception:
273
+ pass # Ignore cleanup errors
274
+ continue
275
+ # HTTP 422 typically means invalid run_id (e.g., test files)
276
+ # Skip silently for 422, but log other errors
277
+ if response.status_code == 422:
278
+ # Likely a test file or invalid run_id, skip silently
279
+ continue
169
280
  print(f"❌ Failed to get upload URL for {run_id}: HTTP {response.status_code}")
170
281
  continue
171
282