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
@@ -0,0 +1,126 @@
1
+ """
2
+ Snapshot comparison utilities for diff_status detection.
3
+
4
+ Implements change detection logic for the Diff Overlay feature.
5
+
6
+ Uses shared canonicalization helpers from canonicalization.py to ensure
7
+ consistent comparison behavior with trace_indexing/indexer.py.
8
+ """
9
+
10
+ from .canonicalization import bbox_changed, content_changed
11
+ from .models import Element, Snapshot
12
+
13
+
14
+ class SnapshotDiff:
15
+ """
16
+ Utility for comparing snapshots and computing diff_status for elements.
17
+
18
+ Implements the logic described in DIFF_STATUS_GAP_ANALYSIS.md:
19
+ - ADDED: Element exists in current but not in previous
20
+ - REMOVED: Element existed in previous but not in current
21
+ - MODIFIED: Element exists in both but has changed
22
+ - MOVED: Element exists in both but position changed
23
+
24
+ Uses canonicalized comparisons (normalized text, rounded bbox) to reduce
25
+ noise from insignificant changes like sub-pixel rendering differences
26
+ or whitespace variations.
27
+ """
28
+
29
+ @staticmethod
30
+ def _element_to_dict(el: Element) -> dict:
31
+ """Convert Element model to dict for canonicalization helpers."""
32
+ return {
33
+ "id": el.id,
34
+ "role": el.role,
35
+ "text": el.text,
36
+ "bbox": {
37
+ "x": el.bbox.x,
38
+ "y": el.bbox.y,
39
+ "width": el.bbox.width,
40
+ "height": el.bbox.height,
41
+ },
42
+ "visual_cues": {
43
+ "is_primary": el.visual_cues.is_primary,
44
+ "is_clickable": el.visual_cues.is_clickable,
45
+ },
46
+ }
47
+
48
+ @staticmethod
49
+ def compute_diff_status(
50
+ current: Snapshot,
51
+ previous: Snapshot | None,
52
+ ) -> list[Element]:
53
+ """
54
+ Compare current snapshot with previous and set diff_status on elements.
55
+
56
+ Uses canonicalized comparisons:
57
+ - Text is normalized (trimmed, collapsed whitespace, lowercased)
58
+ - Bbox is rounded to 2px grid to ignore sub-pixel differences
59
+
60
+ Args:
61
+ current: Current snapshot
62
+ previous: Previous snapshot (None if this is the first snapshot)
63
+
64
+ Returns:
65
+ List of elements with diff_status set (includes REMOVED elements from previous)
66
+ """
67
+ # If no previous snapshot, all current elements are ADDED
68
+ if previous is None:
69
+ result = []
70
+ for el in current.elements:
71
+ # Create a copy with diff_status set
72
+ el_dict = el.model_dump()
73
+ el_dict["diff_status"] = "ADDED"
74
+ result.append(Element(**el_dict))
75
+ return result
76
+
77
+ # Build lookup maps by element ID
78
+ current_by_id = {el.id: el for el in current.elements}
79
+ previous_by_id = {el.id: el for el in previous.elements}
80
+
81
+ current_ids = set(current_by_id.keys())
82
+ previous_ids = set(previous_by_id.keys())
83
+
84
+ result: list[Element] = []
85
+
86
+ # Process current elements
87
+ for el in current.elements:
88
+ el_dict = el.model_dump()
89
+
90
+ if el.id not in previous_ids:
91
+ # Element is new - mark as ADDED
92
+ el_dict["diff_status"] = "ADDED"
93
+ else:
94
+ # Element existed before - check for changes using canonicalized comparisons
95
+ prev_el = previous_by_id[el.id]
96
+
97
+ # Convert to dicts for canonicalization helpers
98
+ el_data = SnapshotDiff._element_to_dict(el)
99
+ prev_el_data = SnapshotDiff._element_to_dict(prev_el)
100
+
101
+ has_bbox_changed = bbox_changed(el_data["bbox"], prev_el_data["bbox"])
102
+ has_content_changed = content_changed(el_data, prev_el_data)
103
+
104
+ if has_bbox_changed and has_content_changed:
105
+ # Both position and content changed - mark as MODIFIED
106
+ el_dict["diff_status"] = "MODIFIED"
107
+ elif has_bbox_changed:
108
+ # Only position changed - mark as MOVED
109
+ el_dict["diff_status"] = "MOVED"
110
+ elif has_content_changed:
111
+ # Only content changed - mark as MODIFIED
112
+ el_dict["diff_status"] = "MODIFIED"
113
+ else:
114
+ # No change - don't set diff_status (frontend expects undefined)
115
+ el_dict["diff_status"] = None
116
+
117
+ result.append(Element(**el_dict))
118
+
119
+ # Process removed elements (existed in previous but not in current)
120
+ for prev_id in previous_ids - current_ids:
121
+ prev_el = previous_by_id[prev_id]
122
+ el_dict = prev_el.model_dump()
123
+ el_dict["diff_status"] = "REMOVED"
124
+ result.append(Element(**el_dict))
125
+
126
+ return result
sentience/text_search.py CHANGED
@@ -2,8 +2,10 @@
2
2
  Text search utilities - find text and get pixel coordinates
3
3
  """
4
4
 
5
- from .browser import SentienceBrowser
5
+ from .browser import AsyncSentienceBrowser, SentienceBrowser
6
+ from .browser_evaluator import BrowserEvaluator
6
7
  from .models import TextRectSearchResult
8
+ from .sentience_methods import SentienceMethod
7
9
 
8
10
 
9
11
  def find_text_rect(
@@ -88,18 +90,131 @@ def find_text_rect(
88
90
  # Limit max_results to prevent performance issues
89
91
  max_results = min(max_results, 100)
90
92
 
93
+ # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
94
+ # The new architecture loads injected_api.js asynchronously, so window.sentience
95
+ # may not be immediately available after page load
96
+ BrowserEvaluator.wait_for_extension(browser.page, timeout_ms=5000)
97
+
98
+ # Verify findTextRect method exists (for older extension versions that don't have it)
99
+ if not BrowserEvaluator.verify_method_exists(browser.page, SentienceMethod.FIND_TEXT_RECT):
100
+ raise RuntimeError(
101
+ "window.sentience.findTextRect is not available. "
102
+ "Please update the Sentience extension to the latest version."
103
+ )
104
+
105
+ # Call the extension's findTextRect method
106
+ result_dict = browser.page.evaluate(
107
+ """
108
+ (options) => {
109
+ return window.sentience.findTextRect(options);
110
+ }
111
+ """,
112
+ {
113
+ "text": text,
114
+ "caseSensitive": case_sensitive,
115
+ "wholeWord": whole_word,
116
+ "maxResults": max_results,
117
+ },
118
+ )
119
+
120
+ # Parse and validate with Pydantic
121
+ return TextRectSearchResult(**result_dict)
122
+
123
+
124
+ async def find_text_rect_async(
125
+ browser: AsyncSentienceBrowser,
126
+ text: str,
127
+ case_sensitive: bool = False,
128
+ whole_word: bool = False,
129
+ max_results: int = 10,
130
+ ) -> TextRectSearchResult:
131
+ """
132
+ Find all occurrences of text on the page and get their exact pixel coordinates (async).
133
+
134
+ This function searches for text in all visible text nodes on the page and returns
135
+ the bounding rectangles for each match. Useful for:
136
+ - Finding specific UI elements by their text content
137
+ - Locating buttons, links, or labels without element IDs
138
+ - Getting exact coordinates for click automation
139
+ - Highlighting search results visually
140
+
141
+ Args:
142
+ browser: AsyncSentienceBrowser instance
143
+ text: Text to search for (required)
144
+ case_sensitive: If True, search is case-sensitive (default: False)
145
+ whole_word: If True, only match whole words surrounded by whitespace (default: False)
146
+ max_results: Maximum number of matches to return (default: 10, max: 100)
147
+
148
+ Returns:
149
+ TextRectSearchResult with:
150
+ - status: "success" or "error"
151
+ - query: The search text
152
+ - case_sensitive: Whether search was case-sensitive
153
+ - whole_word: Whether whole-word matching was used
154
+ - matches: Number of matches found
155
+ - results: List of TextMatch objects, each containing:
156
+ - text: The matched text
157
+ - rect: Absolute rectangle (with scroll offset)
158
+ - viewport_rect: Viewport-relative rectangle
159
+ - context: Surrounding text (before/after)
160
+ - in_viewport: Whether visible in current viewport
161
+ - viewport: Current viewport dimensions and scroll position
162
+ - error: Error message if status is "error"
163
+
164
+ Examples:
165
+ # Find "Sign In" button
166
+ result = await find_text_rect_async(browser, "Sign In")
167
+ if result.status == "success" and result.results:
168
+ first_match = result.results[0]
169
+ print(f"Found at: ({first_match.rect.x}, {first_match.rect.y})")
170
+ print(f"Size: {first_match.rect.width}x{first_match.rect.height}")
171
+ print(f"In viewport: {first_match.in_viewport}")
172
+
173
+ # Case-sensitive search
174
+ result = await find_text_rect_async(browser, "LOGIN", case_sensitive=True)
175
+
176
+ # Whole word only
177
+ result = await find_text_rect_async(browser, "log", whole_word=True) # Won't match "login"
178
+
179
+ # Find all matches and click the first visible one
180
+ result = await find_text_rect_async(browser, "Buy Now", max_results=5)
181
+ if result.status == "success" and result.results:
182
+ for match in result.results:
183
+ if match.in_viewport:
184
+ # Use click_rect_async from actions module
185
+ from sentience.actions import click_rect_async
186
+ click_result = await click_rect_async(browser, {
187
+ "x": match.rect.x,
188
+ "y": match.rect.y,
189
+ "w": match.rect.width,
190
+ "h": match.rect.height
191
+ })
192
+ break
193
+ """
194
+ if not browser.page:
195
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
196
+
197
+ if not text or not text.strip():
198
+ return TextRectSearchResult(
199
+ status="error",
200
+ error="Text parameter is required and cannot be empty",
201
+ )
202
+
203
+ # Limit max_results to prevent performance issues
204
+ max_results = min(max_results, 100)
205
+
91
206
  # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
92
207
  # The new architecture loads injected_api.js asynchronously, so window.sentience
93
208
  # may not be immediately available after page load
94
209
  try:
95
- browser.page.wait_for_function(
210
+ await browser.page.wait_for_function(
96
211
  "typeof window.sentience !== 'undefined'",
97
212
  timeout=5000, # 5 second timeout
98
213
  )
99
214
  except Exception as e:
100
215
  # Gather diagnostics if wait fails
101
216
  try:
102
- diag = browser.page.evaluate(
217
+ diag = await browser.page.evaluate(
103
218
  """() => ({
104
219
  sentience_defined: typeof window.sentience !== 'undefined',
105
220
  extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
@@ -116,7 +231,7 @@ def find_text_rect(
116
231
 
117
232
  # Verify findTextRect method exists (for older extension versions that don't have it)
118
233
  try:
119
- has_find_text_rect = browser.page.evaluate(
234
+ has_find_text_rect = await browser.page.evaluate(
120
235
  "typeof window.sentience.findTextRect !== 'undefined'"
121
236
  )
122
237
  if not has_find_text_rect:
@@ -130,7 +245,7 @@ def find_text_rect(
130
245
  raise RuntimeError(f"Failed to verify findTextRect availability: {e}") from e
131
246
 
132
247
  # Call the extension's findTextRect method
133
- result_dict = browser.page.evaluate(
248
+ result_dict = await browser.page.evaluate(
134
249
  """
135
250
  (options) => {
136
251
  return window.sentience.findTextRect(options);
@@ -0,0 +1,148 @@
1
+ """
2
+ Trace event building utilities for agent-based tracing.
3
+
4
+ This module provides centralized trace event building logic to reduce duplication
5
+ across agent implementations.
6
+ """
7
+
8
+ from typing import Any, Optional
9
+
10
+ from .models import AgentActionResult, Element, Snapshot
11
+
12
+
13
+ class TraceEventBuilder:
14
+ """
15
+ Helper for building trace events with consistent structure.
16
+
17
+ Provides static methods for building common trace event types:
18
+ - snapshot_taken events
19
+ - step_end events
20
+ """
21
+
22
+ @staticmethod
23
+ def build_snapshot_event(
24
+ snapshot: Snapshot,
25
+ include_all_elements: bool = True,
26
+ ) -> dict[str, Any]:
27
+ """
28
+ Build snapshot_taken trace event data.
29
+
30
+ Args:
31
+ snapshot: Snapshot to build event from
32
+ include_all_elements: If True, include all elements (for DOM tree display).
33
+ If False, use filtered elements only.
34
+
35
+ Returns:
36
+ Dictionary with snapshot event data
37
+ """
38
+ # Normalize importance values to importance_score (0-1 range) per snapshot
39
+ # Min-max normalization: (value - min) / (max - min)
40
+ importance_values = [el.importance for el in snapshot.elements]
41
+
42
+ if importance_values:
43
+ min_importance = min(importance_values)
44
+ max_importance = max(importance_values)
45
+ importance_range = max_importance - min_importance
46
+ else:
47
+ min_importance = 0
48
+ max_importance = 0
49
+ importance_range = 0
50
+
51
+ # Include ALL elements with full data for DOM tree display
52
+ # Add importance_score field normalized to [0, 1]
53
+ elements_data = []
54
+ for el in snapshot.elements:
55
+ el_dict = el.model_dump()
56
+
57
+ # Compute normalized importance_score
58
+ if importance_range > 0:
59
+ importance_score = (el.importance - min_importance) / importance_range
60
+ else:
61
+ # If all elements have same importance, set to 0.5
62
+ importance_score = 0.5
63
+
64
+ el_dict["importance_score"] = importance_score
65
+ elements_data.append(el_dict)
66
+
67
+ return {
68
+ "url": snapshot.url,
69
+ "element_count": len(snapshot.elements),
70
+ "timestamp": snapshot.timestamp,
71
+ "elements": elements_data, # Full element data for DOM tree
72
+ }
73
+
74
+ @staticmethod
75
+ def build_step_end_event(
76
+ step_id: str,
77
+ step_index: int,
78
+ goal: str,
79
+ attempt: int,
80
+ pre_url: str,
81
+ post_url: str,
82
+ snapshot_digest: str | None,
83
+ llm_data: dict[str, Any],
84
+ exec_data: dict[str, Any],
85
+ verify_data: dict[str, Any],
86
+ pre_elements: list[dict[str, Any]] | None = None,
87
+ assertions: list[dict[str, Any]] | None = None,
88
+ ) -> dict[str, Any]:
89
+ """
90
+ Build step_end trace event data.
91
+
92
+ Args:
93
+ step_id: Unique step identifier
94
+ step_index: Step index (0-based)
95
+ goal: User's goal for this step
96
+ attempt: Attempt number (0-based)
97
+ pre_url: URL before action execution
98
+ post_url: URL after action execution
99
+ snapshot_digest: Digest of snapshot before action
100
+ llm_data: LLM interaction data
101
+ exec_data: Action execution data
102
+ verify_data: Verification data
103
+ pre_elements: Optional list of elements from pre-snapshot (with diff_status)
104
+ assertions: Optional list of assertion results from AgentRuntime
105
+
106
+ Returns:
107
+ Dictionary with step_end event data
108
+ """
109
+ pre_data: dict[str, Any] = {
110
+ "url": pre_url,
111
+ "snapshot_digest": snapshot_digest,
112
+ }
113
+
114
+ # Add elements to pre field if provided (for diff overlay support)
115
+ if pre_elements is not None:
116
+ pre_data["elements"] = pre_elements
117
+
118
+ # Build verify data with assertions if provided
119
+ final_verify_data = verify_data.copy()
120
+ if assertions:
121
+ # Ensure signals dict exists
122
+ if "signals" not in final_verify_data:
123
+ final_verify_data["signals"] = {}
124
+
125
+ # Add assertions to signals
126
+ final_verify_data["signals"]["assertions"] = assertions
127
+
128
+ # Check for task completion (assertions marked as required that passed)
129
+ for a in assertions:
130
+ if a.get("passed") and a.get("required"):
131
+ final_verify_data["signals"]["task_done"] = True
132
+ final_verify_data["signals"]["task_done_label"] = a.get("label")
133
+ break
134
+
135
+ return {
136
+ "v": 1,
137
+ "step_id": step_id,
138
+ "step_index": step_index,
139
+ "goal": goal,
140
+ "attempt": attempt,
141
+ "pre": pre_data,
142
+ "llm": llm_data,
143
+ "exec": exec_data,
144
+ "post": {
145
+ "url": post_url,
146
+ },
147
+ "verify": final_verify_data,
148
+ }
@@ -0,0 +1,197 @@
1
+ """
2
+ Trace file management utilities for consistent file operations.
3
+
4
+ This module provides helper functions for common trace file operations
5
+ shared between JsonlTraceSink and CloudTraceSink.
6
+ """
7
+
8
+ import json
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ from .models import TraceStats
14
+
15
+
16
+ class TraceFileManager:
17
+ """
18
+ Helper for common trace file operations.
19
+
20
+ Provides static methods for file operations shared across trace sinks.
21
+ """
22
+
23
+ @staticmethod
24
+ def write_event(file_handle: Any, event: dict[str, Any]) -> None:
25
+ """
26
+ Write a trace event to a file handle as JSONL.
27
+
28
+ Args:
29
+ file_handle: Open file handle (must be writable)
30
+ event: Event dictionary to write
31
+ """
32
+ json_str = json.dumps(event, ensure_ascii=False)
33
+ file_handle.write(json_str + "\n")
34
+ file_handle.flush() # Ensure written to disk
35
+
36
+ @staticmethod
37
+ def ensure_directory(path: Path) -> None:
38
+ """
39
+ Ensure the parent directory of a path exists.
40
+
41
+ Args:
42
+ path: File path whose parent directory should exist
43
+ """
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+
46
+ @staticmethod
47
+ def read_events(path: Path) -> list[dict[str, Any]]:
48
+ """
49
+ Read all events from a JSONL trace file.
50
+
51
+ Args:
52
+ path: Path to JSONL trace file
53
+
54
+ Returns:
55
+ List of event dictionaries
56
+
57
+ Raises:
58
+ FileNotFoundError: If file doesn't exist
59
+ json.JSONDecodeError: If file contains invalid JSON
60
+ """
61
+ events = []
62
+ with open(path, encoding="utf-8") as f:
63
+ for line in f:
64
+ line = line.strip()
65
+ if not line:
66
+ continue
67
+ try:
68
+ event = json.loads(line)
69
+ events.append(event)
70
+ except json.JSONDecodeError:
71
+ # Skip invalid lines but continue reading
72
+ continue
73
+ return events
74
+
75
+ @staticmethod
76
+ def extract_stats(
77
+ events: list[dict[str, Any]],
78
+ infer_status_func: None | (
79
+ Callable[[list[dict[str, Any]], dict[str, Any] | None], str]
80
+ ) = None,
81
+ ) -> TraceStats:
82
+ """
83
+ Extract execution statistics from trace events.
84
+
85
+ This is a common operation shared between JsonlTraceSink and CloudTraceSink.
86
+
87
+ Args:
88
+ events: List of trace event dictionaries
89
+ infer_status_func: Optional function to infer final_status from events.
90
+ If None, uses default inference logic.
91
+
92
+ Returns:
93
+ TraceStats with execution statistics
94
+ """
95
+ if not events:
96
+ return TraceStats(
97
+ total_steps=0,
98
+ total_events=0,
99
+ duration_ms=None,
100
+ final_status="unknown",
101
+ started_at=None,
102
+ ended_at=None,
103
+ )
104
+
105
+ # Find run_start and run_end events
106
+ run_start = next((e for e in events if e.get("type") == "run_start"), None)
107
+ run_end = next((e for e in events if e.get("type") == "run_end"), None)
108
+
109
+ # Extract timestamps
110
+ started_at: str | None = None
111
+ ended_at: str | None = None
112
+ if run_start:
113
+ started_at = run_start.get("ts")
114
+ if run_end:
115
+ ended_at = run_end.get("ts")
116
+
117
+ # Calculate duration
118
+ duration_ms: int | None = None
119
+ if started_at and ended_at:
120
+ try:
121
+ from datetime import datetime
122
+
123
+ start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
124
+ end_dt = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
125
+ delta = end_dt - start_dt
126
+ duration_ms = int(delta.total_seconds() * 1000)
127
+ except Exception:
128
+ pass
129
+
130
+ # Count steps (from step_start events, only first attempt)
131
+ step_indices = set()
132
+ for event in events:
133
+ if event.get("type") == "step_start":
134
+ step_index = event.get("data", {}).get("step_index")
135
+ if step_index is not None:
136
+ step_indices.add(step_index)
137
+ total_steps = len(step_indices) if step_indices else 0
138
+
139
+ # If run_end has steps count, use that (more accurate)
140
+ if run_end:
141
+ steps_from_end = run_end.get("data", {}).get("steps")
142
+ if steps_from_end is not None:
143
+ total_steps = max(total_steps, steps_from_end)
144
+
145
+ # Count total events
146
+ total_events = len(events)
147
+
148
+ # Infer final status
149
+ if infer_status_func:
150
+ final_status = infer_status_func(events, run_end)
151
+ else:
152
+ final_status = TraceFileManager._infer_final_status(events, run_end)
153
+
154
+ return TraceStats(
155
+ total_steps=total_steps,
156
+ total_events=total_events,
157
+ duration_ms=duration_ms,
158
+ final_status=final_status,
159
+ started_at=started_at,
160
+ ended_at=ended_at,
161
+ )
162
+
163
+ @staticmethod
164
+ def _infer_final_status(
165
+ events: list[dict[str, Any]],
166
+ run_end: dict[str, Any] | None,
167
+ ) -> str:
168
+ """
169
+ Infer final status from trace events.
170
+
171
+ Args:
172
+ events: List of trace event dictionaries
173
+ run_end: Optional run_end event dictionary
174
+
175
+ Returns:
176
+ Final status string: "success", "failure", "partial", or "unknown"
177
+ """
178
+ # Check for run_end event with status
179
+ if run_end:
180
+ status = run_end.get("data", {}).get("status")
181
+ if status in ("success", "failure", "partial", "unknown"):
182
+ return status
183
+
184
+ # Infer from error events
185
+ has_errors = any(e.get("type") == "error" for e in events)
186
+ if has_errors:
187
+ step_ends = [e for e in events if e.get("type") == "step_end"]
188
+ if step_ends:
189
+ return "partial"
190
+ else:
191
+ return "failure"
192
+ else:
193
+ step_ends = [e for e in events if e.get("type") == "step_end"]
194
+ if step_ends:
195
+ return "success"
196
+ else:
197
+ return "unknown"