gl-browser-use-binary 0.0.0b5__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.
@@ -0,0 +1,103 @@
1
+ """Browser infrastructure abstractions for GL Browser Use SDK.
2
+
3
+ Authors:
4
+ Reinhart Linanda (reinhart.linanda@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Generic, TypeVar
12
+
13
+ from gl_browser_use.storage.base import ObjectStorage
14
+
15
+ VIDEO_FILE_NAME_PREFIX = "session_"
16
+
17
+ ClientType = TypeVar("ClientType")
18
+ SessionType = TypeVar("SessionType")
19
+
20
+
21
+ @dataclass
22
+ class BrowserInfrastructureContext(Generic[ClientType, SessionType]):
23
+ """Runtime context for an active browser infrastructure session."""
24
+
25
+ client: ClientType
26
+ session: SessionType
27
+ session_id: str
28
+ streaming_url: str
29
+ metadata: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ class BrowserInfrastructure(ABC, Generic[ClientType, SessionType]):
33
+ """Abstract contract for browser infrastructure providers.
34
+
35
+ Provider-specific configuration (API keys, endpoints, timeouts) belongs to
36
+ the concrete subclass; this base only defines the behavioral surface.
37
+ """
38
+
39
+ @property
40
+ @abstractmethod
41
+ def provider(self) -> str:
42
+ """Return the human-readable infrastructure provider name."""
43
+
44
+ @property
45
+ def supports_recording(self) -> bool:
46
+ """Whether this infrastructure can produce session recordings."""
47
+ return False
48
+
49
+ @abstractmethod
50
+ async def create_session(self) -> BrowserInfrastructureContext[ClientType, SessionType]:
51
+ """Create a new browser infrastructure session and return the active context."""
52
+
53
+ @abstractmethod
54
+ async def release_session(self, context: BrowserInfrastructureContext[ClientType, SessionType]) -> None:
55
+ """Release or dispose browser infrastructure session resources."""
56
+
57
+ @abstractmethod
58
+ def cdp_url(self, session_id: str) -> str:
59
+ """Build a CDP endpoint URL for the provided browser infrastructure session ID."""
60
+
61
+ def recording_filename(self, session_id: str) -> str:
62
+ """Return the preferred recording object name for the provided browser infrastructure session ID."""
63
+ return f"{VIDEO_FILE_NAME_PREFIX}{session_id}.mp4"
64
+
65
+ def resolve_recording_url(self, session_id: str, storage: ObjectStorage | None) -> str | None:
66
+ """Resolve a recording URL from storage without scheduling any new work."""
67
+ if storage is None or not storage.is_available or not session_id:
68
+ return None
69
+ return storage.get_file_url(self.recording_filename(session_id))
70
+
71
+ def can_record(
72
+ self,
73
+ context: BrowserInfrastructureContext[ClientType, SessionType] | None,
74
+ storage: ObjectStorage | None,
75
+ ) -> bool:
76
+ """Return True when an active recording can be produced for ``context``.
77
+
78
+ This encapsulates the "recording is supported, a live context exists,
79
+ and storage is available" check that clients use before scheduling a
80
+ recording or resolving a recording URL.
81
+ """
82
+ return (
83
+ self.supports_recording
84
+ and context is not None
85
+ and bool(getattr(context, "session_id", ""))
86
+ and storage is not None
87
+ and storage.is_available
88
+ )
89
+
90
+ @abstractmethod
91
+ def schedule_recording(
92
+ self,
93
+ context: BrowserInfrastructureContext[ClientType, SessionType],
94
+ storage: ObjectStorage | None,
95
+ ) -> None:
96
+ """Optionally schedule background recording for the provided context."""
97
+
98
+
99
+ __all__ = [
100
+ "BrowserInfrastructure",
101
+ "BrowserInfrastructureContext",
102
+ "VIDEO_FILE_NAME_PREFIX",
103
+ ]
@@ -0,0 +1,56 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, field
3
+ from gl_browser_use.storage.base import ObjectStorage
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ __all__ = ['BrowserInfrastructure', 'BrowserInfrastructureContext', 'VIDEO_FILE_NAME_PREFIX']
7
+
8
+ VIDEO_FILE_NAME_PREFIX: str
9
+ ClientType = TypeVar('ClientType')
10
+ SessionType = TypeVar('SessionType')
11
+
12
+ @dataclass
13
+ class BrowserInfrastructureContext(Generic[ClientType, SessionType]):
14
+ """Runtime context for an active browser infrastructure session."""
15
+ client: ClientType
16
+ session: SessionType
17
+ session_id: str
18
+ streaming_url: str
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+ class BrowserInfrastructure(ABC, Generic[ClientType, SessionType]):
22
+ """Abstract contract for browser infrastructure providers.
23
+
24
+ Provider-specific configuration (API keys, endpoints, timeouts) belongs to
25
+ the concrete subclass; this base only defines the behavioral surface.
26
+ """
27
+ @property
28
+ @abstractmethod
29
+ def provider(self) -> str:
30
+ """Return the human-readable infrastructure provider name."""
31
+ @property
32
+ def supports_recording(self) -> bool:
33
+ """Whether this infrastructure can produce session recordings."""
34
+ @abstractmethod
35
+ async def create_session(self) -> BrowserInfrastructureContext[ClientType, SessionType]:
36
+ """Create a new browser infrastructure session and return the active context."""
37
+ @abstractmethod
38
+ async def release_session(self, context: BrowserInfrastructureContext[ClientType, SessionType]) -> None:
39
+ """Release or dispose browser infrastructure session resources."""
40
+ @abstractmethod
41
+ def cdp_url(self, session_id: str) -> str:
42
+ """Build a CDP endpoint URL for the provided browser infrastructure session ID."""
43
+ def recording_filename(self, session_id: str) -> str:
44
+ """Return the preferred recording object name for the provided browser infrastructure session ID."""
45
+ def resolve_recording_url(self, session_id: str, storage: ObjectStorage | None) -> str | None:
46
+ """Resolve a recording URL from storage without scheduling any new work."""
47
+ def can_record(self, context: BrowserInfrastructureContext[ClientType, SessionType] | None, storage: ObjectStorage | None) -> bool:
48
+ '''Return True when an active recording can be produced for ``context``.
49
+
50
+ This encapsulates the "recording is supported, a live context exists,
51
+ and storage is available" check that clients use before scheduling a
52
+ recording or resolving a recording URL.
53
+ '''
54
+ @abstractmethod
55
+ def schedule_recording(self, context: BrowserInfrastructureContext[ClientType, SessionType], storage: ObjectStorage | None) -> None:
56
+ """Optionally schedule background recording for the provided context."""
@@ -0,0 +1,296 @@
1
+ """Steel-backed browser infrastructure for GL Browser Use SDK.
2
+
3
+ Authors:
4
+ Reinhart Linanda (reinhart.linanda@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ import re
13
+ import shutil
14
+ import tempfile
15
+ import threading
16
+ import time
17
+ from io import BufferedWriter
18
+ from pathlib import Path
19
+ from urllib.parse import urljoin
20
+
21
+ import requests
22
+ from steel import Steel
23
+ from steel.types.session import Session
24
+
25
+ from gl_browser_use.errors import BrowserUseConfigurationError
26
+ from gl_browser_use.infrastructure.base import (
27
+ VIDEO_FILE_NAME_PREFIX,
28
+ BrowserInfrastructure,
29
+ BrowserInfrastructureContext,
30
+ )
31
+ from gl_browser_use.storage.base import ObjectStorage
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ DEFAULT_STEEL_BASE_URL = "https://api.steel.dev"
36
+ DEFAULT_STEEL_WS_URL = "wss://connect.steel.dev"
37
+ DEFAULT_STEEL_TIMEOUT_IN_MS = 600_000
38
+
39
+ MANIFEST_TEMP_SUFFIX = ".m3u8"
40
+
41
+
42
+ class _SteelSessionRecorder:
43
+ """Capture Steel HLS sessions and upload recordings to object storage."""
44
+
45
+ def __init__(self, base_url: str, api_key: str, *, storage: ObjectStorage | None = None) -> None:
46
+ self.base_url = base_url
47
+ self.api_key = api_key
48
+ self.storage = storage
49
+ self._manifest_poll_timeout_minutes = 1
50
+ self._manifest_poll_interval_seconds = 2
51
+
52
+ @staticmethod
53
+ def safe_session_id(session_id: str) -> str:
54
+ return "".join(char for char in session_id if char.isalnum() or char in {"-", "_"})
55
+
56
+ @classmethod
57
+ def video_filename(cls, session_id: str, extension: str = ".mp4") -> str:
58
+ safe_session_id = cls.safe_session_id(session_id)
59
+ normalized_extension = extension if extension.startswith(".") else f".{extension}"
60
+ return f"{VIDEO_FILE_NAME_PREFIX}{safe_session_id}{normalized_extension}"
61
+
62
+ @staticmethod
63
+ def _parse_manifest_entries(manifest_text: str, manifest_url: str) -> tuple[str | None, list[str]]:
64
+ init_url = None
65
+ segments: list[str] = []
66
+
67
+ for line in manifest_text.splitlines():
68
+ line = line.strip()
69
+ if not line:
70
+ continue
71
+ if line.startswith("#EXT-X-MAP"):
72
+ match = re.search(r'URI="([^"]+)"', line)
73
+ if match:
74
+ init_url = match.group(1)
75
+ continue
76
+ if line.startswith("#"):
77
+ continue
78
+ segments.append(urljoin(manifest_url, line))
79
+
80
+ if init_url:
81
+ init_url = urljoin(manifest_url, init_url)
82
+
83
+ return init_url, segments
84
+
85
+ @staticmethod
86
+ def _stream_url(session: requests.Session, url: str, headers: dict[str, str], output_file: BufferedWriter) -> None:
87
+ with session.get(url, headers=headers, stream=True, timeout=120) as response:
88
+ response.raise_for_status()
89
+ for chunk in response.iter_content(chunk_size=64 * 1024):
90
+ if chunk:
91
+ output_file.write(chunk)
92
+
93
+ @staticmethod
94
+ def _cleanup_temp_directory(video_dir: str) -> None:
95
+ try:
96
+ shutil.rmtree(video_dir)
97
+ except OSError:
98
+ pass
99
+
100
+ def _download_manifest(self, session_id: str, dest: Path) -> str | None:
101
+ if not self.base_url or not self.api_key:
102
+ return None
103
+
104
+ url = f"{self.base_url.rstrip('/')}/v1/sessions/{session_id}/hls"
105
+ headers = {"steel-api-key": self.api_key}
106
+ deadline = time.monotonic() + (self._manifest_poll_timeout_minutes * 60)
107
+
108
+ while True:
109
+ response = requests.get(url, headers=headers, timeout=60)
110
+ if response.ok:
111
+ dest.write_text(response.text)
112
+ return response.url
113
+
114
+ pollable = response.status_code in {404, 408, 429, 503}
115
+ if not pollable or time.monotonic() >= deadline:
116
+ response.raise_for_status()
117
+
118
+ time.sleep(self._manifest_poll_interval_seconds)
119
+
120
+ def _merge_segments(self, manifest_path: Path, manifest_url: str, output_path: Path) -> bool:
121
+ if not self.api_key:
122
+ return False
123
+
124
+ headers = {"steel-api-key": self.api_key}
125
+ manifest_text = manifest_path.read_text()
126
+ init_url, segments = self._parse_manifest_entries(manifest_text, manifest_url)
127
+ if not segments:
128
+ return False
129
+
130
+ output_path.parent.mkdir(parents=True, exist_ok=True)
131
+ with requests.Session() as session, open(output_path, "wb") as output_file:
132
+ if init_url:
133
+ self._stream_url(session, init_url, headers, output_file)
134
+ for segment_url in segments:
135
+ self._stream_url(session, segment_url, headers, output_file)
136
+ return True
137
+
138
+ async def _download_and_merge_manifest(self, session_id: str, output_path: Path) -> bool:
139
+ manifest_path = Path(tempfile.NamedTemporaryFile(suffix=MANIFEST_TEMP_SUFFIX, delete=False).name)
140
+
141
+ try:
142
+ manifest_url = await asyncio.to_thread(self._download_manifest, session_id, manifest_path)
143
+ if not manifest_url:
144
+ return False
145
+
146
+ return await asyncio.to_thread(self._merge_segments, manifest_path, manifest_url, output_path)
147
+ except Exception:
148
+ logger.debug(
149
+ "Failed to download/merge Steel HLS manifest for session %s",
150
+ session_id,
151
+ exc_info=True,
152
+ )
153
+ return False
154
+ finally:
155
+ manifest_path.unlink(missing_ok=True)
156
+
157
+ async def _upload_to_storage(self, video_path: Path, video_filename: str) -> bool:
158
+ if self.storage is None or not self.storage.is_available:
159
+ return False
160
+ if not video_path.exists():
161
+ return False
162
+ return self.storage.upload_file(str(video_path), video_filename) is not None
163
+
164
+ async def record_session_to_video(self, session_id: str) -> bool:
165
+ if not self.storage or not self.storage.is_available or not session_id:
166
+ return False
167
+
168
+ video_dir = tempfile.mkdtemp()
169
+ try:
170
+ video_filename = self.video_filename(session_id)
171
+ output_path = Path(video_dir) / video_filename
172
+ merged = await self._download_and_merge_manifest(session_id, output_path)
173
+ if not merged:
174
+ return False
175
+ return await self._upload_to_storage(output_path, video_filename)
176
+ finally:
177
+ self._cleanup_temp_directory(video_dir)
178
+
179
+ @classmethod
180
+ def schedule_recording_in_background(
181
+ cls,
182
+ session_id: str,
183
+ base_url: str,
184
+ api_key: str,
185
+ storage: ObjectStorage | None,
186
+ ) -> threading.Thread | None:
187
+ if not session_id or not storage or not storage.is_available:
188
+ return None
189
+
190
+ recorder = cls(base_url=base_url, api_key=api_key, storage=storage)
191
+
192
+ def _run() -> None:
193
+ try:
194
+ asyncio.run(recorder.record_session_to_video(session_id=session_id))
195
+ except Exception as error: # pragma: no cover - defensive logging
196
+ logger.warning("Background recording failed for session %s: %s", session_id, error, exc_info=True)
197
+
198
+ thread_name = f"steel-recording-{cls.safe_session_id(session_id)}"
199
+ thread = threading.Thread(target=_run, daemon=True, name=thread_name)
200
+ thread.start()
201
+ return thread
202
+
203
+
204
+ class SteelBrowserInfrastructure(BrowserInfrastructure[Steel, Session]):
205
+ """Concrete Steel implementation for browser infrastructure sessions."""
206
+
207
+ def __init__(
208
+ self,
209
+ api_key: str | None = None,
210
+ base_url: str = DEFAULT_STEEL_BASE_URL,
211
+ ws_url: str = DEFAULT_STEEL_WS_URL,
212
+ timeout_in_ms: int = DEFAULT_STEEL_TIMEOUT_IN_MS,
213
+ ) -> None:
214
+ """Initialize the Steel browser infrastructure.
215
+
216
+ ``api_key`` defaults to the ``STEEL_API_KEY`` environment variable
217
+ resolved at construction time (not import time) when not provided.
218
+ """
219
+ resolved_api_key = api_key if api_key is not None else os.getenv("STEEL_API_KEY")
220
+ if not resolved_api_key:
221
+ raise BrowserUseConfigurationError("api_key is required.")
222
+ if timeout_in_ms <= 0:
223
+ raise BrowserUseConfigurationError("timeout_in_ms must be > 0.")
224
+
225
+ self.api_key = resolved_api_key
226
+ self.base_url = base_url
227
+ self.ws_url = ws_url
228
+ self.timeout_in_ms = timeout_in_ms
229
+
230
+ @property
231
+ def provider(self) -> str:
232
+ """Return the human-readable infrastructure provider name."""
233
+ return "steel"
234
+
235
+ @property
236
+ def supports_recording(self) -> bool:
237
+ """Whether this infrastructure can produce session recordings.
238
+
239
+ ``True`` indicates Steel supports recording. Recording is only
240
+ actually started when an available :class:`ObjectStorage` is also
241
+ provided to the :class:`BrowserUseClient`.
242
+ """
243
+ return True
244
+
245
+ async def create_session(self) -> BrowserInfrastructureContext[Steel, Session]:
246
+ """Create a new Steel session and return the active context."""
247
+ client = Steel(steel_api_key=self.api_key, base_url=self.base_url)
248
+ session = client.sessions.create(use_proxy=False, api_timeout=self.timeout_in_ms)
249
+ return BrowserInfrastructureContext(
250
+ client=client,
251
+ session=session,
252
+ session_id=str(getattr(session, "id", "")),
253
+ streaming_url=str(getattr(session, "debug_url", "")),
254
+ metadata={"provider": self.provider},
255
+ )
256
+
257
+ async def release_session(self, context: BrowserInfrastructureContext[Steel, Session]) -> None:
258
+ """Release or dispose Steel session resources."""
259
+ if not context.client or not context.session:
260
+ return
261
+
262
+ for attempt in range(3):
263
+ try:
264
+ context.client.sessions.release(context.session.id)
265
+ return
266
+ except Exception:
267
+ if attempt == 2:
268
+ raise
269
+ await asyncio.sleep(0.5 * (attempt + 1))
270
+
271
+ def cdp_url(self, session_id: str) -> str:
272
+ """Build a CDP endpoint URL for the provided Steel session ID."""
273
+ return f"{self.ws_url}?apiKey={self.api_key}&sessionId={session_id}"
274
+
275
+ def recording_filename(self, session_id: str) -> str:
276
+ """Return the preferred recording object name for the provided Steel session ID."""
277
+ return _SteelSessionRecorder.video_filename(session_id)
278
+
279
+ def schedule_recording(
280
+ self, context: BrowserInfrastructureContext[Steel, Session], storage: ObjectStorage | None
281
+ ) -> None:
282
+ """Schedule background recording for the provided context."""
283
+ if not self.supports_recording or not context.session_id:
284
+ return
285
+ try:
286
+ _SteelSessionRecorder.schedule_recording_in_background(
287
+ session_id=context.session_id,
288
+ base_url=self.base_url,
289
+ api_key=self.api_key,
290
+ storage=storage,
291
+ )
292
+ except Exception as error:
293
+ logger.debug("Failed to start background recording for Steel session %s: %s", context.session_id, error)
294
+
295
+
296
+ __all__ = ["SteelBrowserInfrastructure"]
@@ -0,0 +1,56 @@
1
+ import threading
2
+ from _typeshed import Incomplete
3
+ from gl_browser_use.infrastructure.base import BrowserInfrastructure, BrowserInfrastructureContext
4
+ from gl_browser_use.storage.base import ObjectStorage
5
+ from steel import Steel
6
+ from steel.types.session import Session
7
+
8
+ __all__ = ['SteelBrowserInfrastructure']
9
+
10
+ class _SteelSessionRecorder:
11
+ """Capture Steel HLS sessions and upload recordings to object storage."""
12
+ base_url: Incomplete
13
+ api_key: Incomplete
14
+ storage: Incomplete
15
+ def __init__(self, base_url: str, api_key: str, *, storage: ObjectStorage | None = None) -> None: ...
16
+ @staticmethod
17
+ def safe_session_id(session_id: str) -> str: ...
18
+ @classmethod
19
+ def video_filename(cls, session_id: str, extension: str = '.mp4') -> str: ...
20
+ async def record_session_to_video(self, session_id: str) -> bool: ...
21
+ @classmethod
22
+ def schedule_recording_in_background(cls, session_id: str, base_url: str, api_key: str, storage: ObjectStorage | None) -> threading.Thread | None: ...
23
+
24
+ class SteelBrowserInfrastructure(BrowserInfrastructure[Steel, Session]):
25
+ """Concrete Steel implementation for browser infrastructure sessions."""
26
+ api_key: Incomplete
27
+ base_url: Incomplete
28
+ ws_url: Incomplete
29
+ timeout_in_ms: Incomplete
30
+ def __init__(self, api_key: str | None = None, base_url: str = ..., ws_url: str = ..., timeout_in_ms: int = ...) -> None:
31
+ """Initialize the Steel browser infrastructure.
32
+
33
+ ``api_key`` defaults to the ``STEEL_API_KEY`` environment variable
34
+ resolved at construction time (not import time) when not provided.
35
+ """
36
+ @property
37
+ def provider(self) -> str:
38
+ """Return the human-readable infrastructure provider name."""
39
+ @property
40
+ def supports_recording(self) -> bool:
41
+ """Whether this infrastructure can produce session recordings.
42
+
43
+ ``True`` indicates Steel supports recording. Recording is only
44
+ actually started when an available :class:`ObjectStorage` is also
45
+ provided to the :class:`BrowserUseClient`.
46
+ """
47
+ async def create_session(self) -> BrowserInfrastructureContext[Steel, Session]:
48
+ """Create a new Steel session and return the active context."""
49
+ async def release_session(self, context: BrowserInfrastructureContext[Steel, Session]) -> None:
50
+ """Release or dispose Steel session resources."""
51
+ def cdp_url(self, session_id: str) -> str:
52
+ """Build a CDP endpoint URL for the provided Steel session ID."""
53
+ def recording_filename(self, session_id: str) -> str:
54
+ """Return the preferred recording object name for the provided Steel session ID."""
55
+ def schedule_recording(self, context: BrowserInfrastructureContext[Steel, Session], storage: ObjectStorage | None) -> None:
56
+ """Schedule background recording for the provided context."""
@@ -0,0 +1,45 @@
1
+ """Result and event models for GL Browser Use SDK.
2
+
3
+ Authors:
4
+ Reinhart Linanda (reinhart.linanda@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal
11
+
12
+ BrowserUseRunStatus = Literal["success", "error", "cancelled"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BrowserUseStreamEvent:
17
+ """Structured stream event emitted during ``BrowserUseClient`` execution."""
18
+
19
+ event_type: str
20
+ content: str
21
+ thinking_and_activity_info: dict[str, Any] = field(default_factory=dict)
22
+ is_final: bool = False
23
+ tool_info: dict[str, Any] | None = None
24
+ metadata: dict[str, Any] | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class BrowserUseRunResult:
29
+ """Final result returned for a completed run."""
30
+
31
+ status: BrowserUseRunStatus
32
+ task: str
33
+ final_output: str
34
+ session_id: str | None = None
35
+ streaming_url: str | None = None
36
+ recording_url: str | None = None
37
+ steps: int = 0
38
+ error: str | None = None
39
+ events: list[BrowserUseStreamEvent] = field(default_factory=list)
40
+ metadata: dict[str, Any] = field(default_factory=dict)
41
+
42
+ @property
43
+ def is_success(self) -> bool:
44
+ """Return True if the run was successful."""
45
+ return self.status == "success"
@@ -0,0 +1,32 @@
1
+ from _typeshed import Incomplete
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+ BrowserUseRunStatus: Incomplete
6
+
7
+ @dataclass(frozen=True)
8
+ class BrowserUseStreamEvent:
9
+ """Structured stream event emitted during ``BrowserUseClient`` execution."""
10
+ event_type: str
11
+ content: str
12
+ thinking_and_activity_info: dict[str, Any] = field(default_factory=dict)
13
+ is_final: bool = ...
14
+ tool_info: dict[str, Any] | None = ...
15
+ metadata: dict[str, Any] | None = ...
16
+
17
+ @dataclass(frozen=True)
18
+ class BrowserUseRunResult:
19
+ """Final result returned for a completed run."""
20
+ status: BrowserUseRunStatus
21
+ task: str
22
+ final_output: str
23
+ session_id: str | None = ...
24
+ streaming_url: str | None = ...
25
+ recording_url: str | None = ...
26
+ steps: int = ...
27
+ error: str | None = ...
28
+ events: list[BrowserUseStreamEvent] = field(default_factory=list)
29
+ metadata: dict[str, Any] = field(default_factory=dict)
30
+ @property
31
+ def is_success(self) -> bool:
32
+ """Return True if the run was successful."""