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.

Files changed (63) hide show
  1. sentience/__init__.py +14 -5
  2. sentience/_extension_loader.py +40 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +408 -25
  5. sentience/agent.py +804 -310
  6. sentience/agent_config.py +3 -0
  7. sentience/async_api.py +101 -0
  8. sentience/base_agent.py +95 -0
  9. sentience/browser.py +594 -25
  10. sentience/browser_evaluator.py +299 -0
  11. sentience/cloud_tracing.py +458 -36
  12. sentience/conversational_agent.py +79 -45
  13. sentience/element_filter.py +136 -0
  14. sentience/expect.py +98 -2
  15. sentience/extension/background.js +56 -185
  16. sentience/extension/content.js +117 -289
  17. sentience/extension/injected_api.js +799 -1374
  18. sentience/extension/manifest.json +1 -1
  19. sentience/extension/pkg/sentience_core.js +190 -396
  20. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  21. sentience/extension/release.json +47 -47
  22. sentience/formatting.py +9 -53
  23. sentience/inspector.py +183 -1
  24. sentience/llm_interaction_handler.py +191 -0
  25. sentience/llm_provider.py +256 -28
  26. sentience/llm_provider_utils.py +120 -0
  27. sentience/llm_response_builder.py +153 -0
  28. sentience/models.py +66 -1
  29. sentience/overlay.py +109 -2
  30. sentience/protocols.py +228 -0
  31. sentience/query.py +1 -1
  32. sentience/read.py +95 -3
  33. sentience/recorder.py +223 -3
  34. sentience/schemas/trace_v1.json +102 -9
  35. sentience/screenshot.py +48 -2
  36. sentience/sentience_methods.py +86 -0
  37. sentience/snapshot.py +309 -64
  38. sentience/snapshot_diff.py +141 -0
  39. sentience/text_search.py +119 -5
  40. sentience/trace_event_builder.py +129 -0
  41. sentience/trace_file_manager.py +197 -0
  42. sentience/trace_indexing/index_schema.py +95 -7
  43. sentience/trace_indexing/indexer.py +117 -14
  44. sentience/tracer_factory.py +119 -6
  45. sentience/tracing.py +172 -8
  46. sentience/utils/__init__.py +40 -0
  47. sentience/utils/browser.py +46 -0
  48. sentience/utils/element.py +257 -0
  49. sentience/utils/formatting.py +59 -0
  50. sentience/utils.py +1 -1
  51. sentience/visual_agent.py +2056 -0
  52. sentience/wait.py +70 -4
  53. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/METADATA +61 -22
  54. sentienceapi-0.92.2.dist-info/RECORD +65 -0
  55. sentienceapi-0.92.2.dist-info/licenses/LICENSE +24 -0
  56. sentienceapi-0.92.2.dist-info/licenses/LICENSE-APACHE +201 -0
  57. sentienceapi-0.92.2.dist-info/licenses/LICENSE-MIT +21 -0
  58. sentience/extension/test-content.js +0 -4
  59. sentienceapi-0.90.12.dist-info/RECORD +0 -46
  60. sentienceapi-0.90.12.dist-info/licenses/LICENSE.md +0 -43
  61. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/WHEEL +0 -0
  62. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/entry_points.txt +0 -0
  63. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/top_level.txt +0 -0
sentience/text_search.py CHANGED
@@ -2,7 +2,8 @@
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
7
8
 
8
9
 
@@ -88,18 +89,131 @@ def find_text_rect(
88
89
  # Limit max_results to prevent performance issues
89
90
  max_results = min(max_results, 100)
90
91
 
92
+ # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
93
+ # The new architecture loads injected_api.js asynchronously, so window.sentience
94
+ # may not be immediately available after page load
95
+ BrowserEvaluator.wait_for_extension(browser.page, timeout_ms=5000)
96
+
97
+ # Verify findTextRect method exists (for older extension versions that don't have it)
98
+ if not BrowserEvaluator.verify_method_exists(browser.page, SentienceMethod.FIND_TEXT_RECT):
99
+ raise RuntimeError(
100
+ "window.sentience.findTextRect is not available. "
101
+ "Please update the Sentience extension to the latest version."
102
+ )
103
+
104
+ # Call the extension's findTextRect method
105
+ result_dict = browser.page.evaluate(
106
+ """
107
+ (options) => {
108
+ return window.sentience.findTextRect(options);
109
+ }
110
+ """,
111
+ {
112
+ "text": text,
113
+ "caseSensitive": case_sensitive,
114
+ "wholeWord": whole_word,
115
+ "maxResults": max_results,
116
+ },
117
+ )
118
+
119
+ # Parse and validate with Pydantic
120
+ return TextRectSearchResult(**result_dict)
121
+
122
+
123
+ async def find_text_rect_async(
124
+ browser: AsyncSentienceBrowser,
125
+ text: str,
126
+ case_sensitive: bool = False,
127
+ whole_word: bool = False,
128
+ max_results: int = 10,
129
+ ) -> TextRectSearchResult:
130
+ """
131
+ Find all occurrences of text on the page and get their exact pixel coordinates (async).
132
+
133
+ This function searches for text in all visible text nodes on the page and returns
134
+ the bounding rectangles for each match. Useful for:
135
+ - Finding specific UI elements by their text content
136
+ - Locating buttons, links, or labels without element IDs
137
+ - Getting exact coordinates for click automation
138
+ - Highlighting search results visually
139
+
140
+ Args:
141
+ browser: AsyncSentienceBrowser instance
142
+ text: Text to search for (required)
143
+ case_sensitive: If True, search is case-sensitive (default: False)
144
+ whole_word: If True, only match whole words surrounded by whitespace (default: False)
145
+ max_results: Maximum number of matches to return (default: 10, max: 100)
146
+
147
+ Returns:
148
+ TextRectSearchResult with:
149
+ - status: "success" or "error"
150
+ - query: The search text
151
+ - case_sensitive: Whether search was case-sensitive
152
+ - whole_word: Whether whole-word matching was used
153
+ - matches: Number of matches found
154
+ - results: List of TextMatch objects, each containing:
155
+ - text: The matched text
156
+ - rect: Absolute rectangle (with scroll offset)
157
+ - viewport_rect: Viewport-relative rectangle
158
+ - context: Surrounding text (before/after)
159
+ - in_viewport: Whether visible in current viewport
160
+ - viewport: Current viewport dimensions and scroll position
161
+ - error: Error message if status is "error"
162
+
163
+ Examples:
164
+ # Find "Sign In" button
165
+ result = await find_text_rect_async(browser, "Sign In")
166
+ if result.status == "success" and result.results:
167
+ first_match = result.results[0]
168
+ print(f"Found at: ({first_match.rect.x}, {first_match.rect.y})")
169
+ print(f"Size: {first_match.rect.width}x{first_match.rect.height}")
170
+ print(f"In viewport: {first_match.in_viewport}")
171
+
172
+ # Case-sensitive search
173
+ result = await find_text_rect_async(browser, "LOGIN", case_sensitive=True)
174
+
175
+ # Whole word only
176
+ result = await find_text_rect_async(browser, "log", whole_word=True) # Won't match "login"
177
+
178
+ # Find all matches and click the first visible one
179
+ result = await find_text_rect_async(browser, "Buy Now", max_results=5)
180
+ if result.status == "success" and result.results:
181
+ for match in result.results:
182
+ if match.in_viewport:
183
+ # Use click_rect_async from actions module
184
+ from sentience.actions import click_rect_async
185
+ click_result = await click_rect_async(browser, {
186
+ "x": match.rect.x,
187
+ "y": match.rect.y,
188
+ "w": match.rect.width,
189
+ "h": match.rect.height
190
+ })
191
+ break
192
+ """
193
+ if not browser.page:
194
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
195
+
196
+ if not text or not text.strip():
197
+ return TextRectSearchResult(
198
+ status="error",
199
+ error="Text parameter is required and cannot be empty",
200
+ )
201
+
202
+ # Limit max_results to prevent performance issues
203
+ max_results = min(max_results, 100)
204
+
91
205
  # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
92
206
  # The new architecture loads injected_api.js asynchronously, so window.sentience
93
207
  # may not be immediately available after page load
94
208
  try:
95
- browser.page.wait_for_function(
209
+ await browser.page.wait_for_function(
96
210
  "typeof window.sentience !== 'undefined'",
97
211
  timeout=5000, # 5 second timeout
98
212
  )
99
213
  except Exception as e:
100
214
  # Gather diagnostics if wait fails
101
215
  try:
102
- diag = browser.page.evaluate(
216
+ diag = await browser.page.evaluate(
103
217
  """() => ({
104
218
  sentience_defined: typeof window.sentience !== 'undefined',
105
219
  extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
@@ -116,7 +230,7 @@ def find_text_rect(
116
230
 
117
231
  # Verify findTextRect method exists (for older extension versions that don't have it)
118
232
  try:
119
- has_find_text_rect = browser.page.evaluate(
233
+ has_find_text_rect = await browser.page.evaluate(
120
234
  "typeof window.sentience.findTextRect !== 'undefined'"
121
235
  )
122
236
  if not has_find_text_rect:
@@ -130,7 +244,7 @@ def find_text_rect(
130
244
  raise RuntimeError(f"Failed to verify findTextRect availability: {e}") from e
131
245
 
132
246
  # Call the extension's findTextRect method
133
- result_dict = browser.page.evaluate(
247
+ result_dict = await browser.page.evaluate(
134
248
  """
135
249
  (options) => {
136
250
  return window.sentience.findTextRect(options);
@@ -0,0 +1,129 @@
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
+ ) -> dict[str, Any]:
88
+ """
89
+ Build step_end trace event data.
90
+
91
+ Args:
92
+ step_id: Unique step identifier
93
+ step_index: Step index (0-based)
94
+ goal: User's goal for this step
95
+ attempt: Attempt number (0-based)
96
+ pre_url: URL before action execution
97
+ post_url: URL after action execution
98
+ snapshot_digest: Digest of snapshot before action
99
+ llm_data: LLM interaction data
100
+ exec_data: Action execution data
101
+ verify_data: Verification data
102
+ pre_elements: Optional list of elements from pre-snapshot (with diff_status)
103
+
104
+ Returns:
105
+ Dictionary with step_end event data
106
+ """
107
+ pre_data: dict[str, Any] = {
108
+ "url": pre_url,
109
+ "snapshot_digest": snapshot_digest,
110
+ }
111
+
112
+ # Add elements to pre field if provided (for diff overlay support)
113
+ if pre_elements is not None:
114
+ pre_data["elements"] = pre_elements
115
+
116
+ return {
117
+ "v": 1,
118
+ "step_id": step_id,
119
+ "step_index": step_index,
120
+ "goal": goal,
121
+ "attempt": attempt,
122
+ "pre": pre_data,
123
+ "llm": llm_data,
124
+ "exec": exec_data,
125
+ "post": {
126
+ "url": post_url,
127
+ },
128
+ "verify": verify_data,
129
+ }
@@ -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"
@@ -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
+ }