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,70 @@
1
+ """
2
+ Assertion DSL for Sentience SDK.
3
+
4
+ This module provides a Playwright/Cypress-like assertion API for verifying
5
+ browser state in agent verification loops.
6
+
7
+ Main exports:
8
+ - E: Element query builder (filters elements by role, text, href, etc.)
9
+ - expect: Expectation builder (creates predicates from queries)
10
+ - in_dominant_list: Query over dominant group elements (ordinal access)
11
+
12
+ Example usage:
13
+ from sentience.asserts import E, expect, in_dominant_list
14
+
15
+ # Basic presence assertions
16
+ runtime.assert_(
17
+ expect(E(role="button", text_contains="Save")).to_exist(),
18
+ label="save_button_visible"
19
+ )
20
+
21
+ # Visibility assertions
22
+ runtime.assert_(
23
+ expect(E(text_contains="Checkout")).to_be_visible(),
24
+ label="checkout_visible"
25
+ )
26
+
27
+ # Global text assertions
28
+ runtime.assert_(
29
+ expect.text_present("Welcome back"),
30
+ label="user_logged_in"
31
+ )
32
+ runtime.assert_(
33
+ expect.no_text("Error"),
34
+ label="no_error_message"
35
+ )
36
+
37
+ # Ordinal assertions on dominant group
38
+ runtime.assert_(
39
+ expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN"),
40
+ label="first_item_is_show_hn"
41
+ )
42
+
43
+ # Task completion
44
+ runtime.assert_done(
45
+ expect.text_present("Order confirmed"),
46
+ label="checkout_complete"
47
+ )
48
+
49
+ The DSL compiles to existing Predicate functions, so it works seamlessly
50
+ with AgentRuntime.assert_() and assert_done().
51
+ """
52
+
53
+ from .expect import EventuallyConfig, EventuallyWrapper, ExpectBuilder, expect, with_eventually
54
+ from .query import E, ElementQuery, ListQuery, MultiQuery, in_dominant_list
55
+
56
+ __all__ = [
57
+ # Query builders
58
+ "E",
59
+ "ElementQuery",
60
+ "ListQuery",
61
+ "MultiQuery",
62
+ "in_dominant_list",
63
+ # Expectation builders
64
+ "expect",
65
+ "ExpectBuilder",
66
+ # Eventually helpers
67
+ "with_eventually",
68
+ "EventuallyWrapper",
69
+ "EventuallyConfig",
70
+ ]
@@ -0,0 +1,621 @@
1
+ """
2
+ Expectation builder for assertion DSL.
3
+
4
+ This module provides the expect() builder that creates fluent assertions
5
+ which compile to existing Predicate objects.
6
+
7
+ Key classes:
8
+ - ExpectBuilder: Fluent builder for element-based assertions
9
+ - EventuallyBuilder: Wrapper for retry logic (.eventually())
10
+
11
+ The expect() function is the main entry point. It returns a builder that
12
+ can be chained with matchers:
13
+ expect(E(role="button")).to_exist()
14
+ expect(E(text_contains="Error")).not_to_exist()
15
+ expect.text_present("Welcome")
16
+
17
+ All builders compile to Predicate functions compatible with AgentRuntime.assert_().
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import time
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from ..verification import AssertContext, AssertOutcome, Predicate
28
+ from .query import ElementQuery, ListQuery, MultiQuery, _MultiTextPredicate
29
+
30
+ if TYPE_CHECKING:
31
+ from ..models import Snapshot
32
+
33
+
34
+ # Default values for .eventually()
35
+ DEFAULT_TIMEOUT = 10 # seconds
36
+ DEFAULT_POLL = 0.2 # seconds
37
+ DEFAULT_MAX_RETRIES = 3
38
+
39
+
40
+ @dataclass
41
+ class EventuallyConfig:
42
+ """Configuration for .eventually() retry logic."""
43
+
44
+ timeout: float = DEFAULT_TIMEOUT # Max time to wait (seconds)
45
+ poll: float = DEFAULT_POLL # Interval between retries (seconds)
46
+ max_retries: int = DEFAULT_MAX_RETRIES # Max number of retry attempts
47
+
48
+
49
+ class ExpectBuilder:
50
+ """
51
+ Fluent builder for element-based assertions.
52
+
53
+ Created by expect(E(...)) or expect(in_dominant_list().nth(k)).
54
+
55
+ Methods return Predicate functions that can be passed to runtime.assert_().
56
+
57
+ Example:
58
+ expect(E(role="button")).to_exist()
59
+ expect(E(text_contains="Error")).not_to_exist()
60
+ expect(E(role="link")).to_be_visible()
61
+ """
62
+
63
+ def __init__(self, query: ElementQuery | MultiQuery | _MultiTextPredicate):
64
+ """
65
+ Initialize builder with query.
66
+
67
+ Args:
68
+ query: ElementQuery, MultiQuery, or _MultiTextPredicate
69
+ """
70
+ self._query = query
71
+
72
+ def to_exist(self) -> Predicate:
73
+ """
74
+ Assert that at least one element matches the query.
75
+
76
+ Returns:
77
+ Predicate function for use with runtime.assert_()
78
+
79
+ Example:
80
+ runtime.assert_(
81
+ expect(E(role="button", text_contains="Save")).to_exist(),
82
+ label="save_button_exists"
83
+ )
84
+ """
85
+ query = self._query
86
+
87
+ def _pred(ctx: AssertContext) -> AssertOutcome:
88
+ snap = ctx.snapshot
89
+ if snap is None:
90
+ return AssertOutcome(
91
+ passed=False,
92
+ reason="no snapshot available",
93
+ details={"query": _query_to_dict(query)},
94
+ )
95
+
96
+ if isinstance(query, ElementQuery):
97
+ matches = query.find_all(snap)
98
+ ok = len(matches) > 0
99
+ return AssertOutcome(
100
+ passed=ok,
101
+ reason="" if ok else f"no elements matched query: {_query_to_dict(query)}",
102
+ details={"query": _query_to_dict(query), "matched": len(matches)},
103
+ )
104
+ else:
105
+ return AssertOutcome(
106
+ passed=False,
107
+ reason="to_exist() requires ElementQuery",
108
+ details={},
109
+ )
110
+
111
+ return _pred
112
+
113
+ def not_to_exist(self) -> Predicate:
114
+ """
115
+ Assert that NO elements match the query.
116
+
117
+ Useful for asserting absence of error messages, loading indicators, etc.
118
+
119
+ Returns:
120
+ Predicate function for use with runtime.assert_()
121
+
122
+ Example:
123
+ runtime.assert_(
124
+ expect(E(text_contains="Error")).not_to_exist(),
125
+ label="no_error_message"
126
+ )
127
+ """
128
+ query = self._query
129
+
130
+ def _pred(ctx: AssertContext) -> AssertOutcome:
131
+ snap = ctx.snapshot
132
+ if snap is None:
133
+ return AssertOutcome(
134
+ passed=False,
135
+ reason="no snapshot available",
136
+ details={"query": _query_to_dict(query)},
137
+ )
138
+
139
+ if isinstance(query, ElementQuery):
140
+ matches = query.find_all(snap)
141
+ ok = len(matches) == 0
142
+ return AssertOutcome(
143
+ passed=ok,
144
+ reason=(
145
+ ""
146
+ if ok
147
+ else f"found {len(matches)} elements matching: {_query_to_dict(query)}"
148
+ ),
149
+ details={"query": _query_to_dict(query), "matched": len(matches)},
150
+ )
151
+ else:
152
+ return AssertOutcome(
153
+ passed=False,
154
+ reason="not_to_exist() requires ElementQuery",
155
+ details={},
156
+ )
157
+
158
+ return _pred
159
+
160
+ def to_be_visible(self) -> Predicate:
161
+ """
162
+ Assert that element exists AND is visible (in_viewport=True, occluded=False).
163
+
164
+ Returns:
165
+ Predicate function for use with runtime.assert_()
166
+
167
+ Example:
168
+ runtime.assert_(
169
+ expect(E(text_contains="Checkout")).to_be_visible(),
170
+ label="checkout_button_visible"
171
+ )
172
+ """
173
+ query = self._query
174
+
175
+ def _pred(ctx: AssertContext) -> AssertOutcome:
176
+ snap = ctx.snapshot
177
+ if snap is None:
178
+ return AssertOutcome(
179
+ passed=False,
180
+ reason="no snapshot available",
181
+ details={"query": _query_to_dict(query)},
182
+ )
183
+
184
+ if isinstance(query, ElementQuery):
185
+ matches = query.find_all(snap)
186
+ if len(matches) == 0:
187
+ return AssertOutcome(
188
+ passed=False,
189
+ reason=f"no elements matched query: {_query_to_dict(query)}",
190
+ details={"query": _query_to_dict(query), "matched": 0},
191
+ )
192
+
193
+ # Check visibility of first match
194
+ el = matches[0]
195
+ is_visible = el.in_viewport and not el.is_occluded
196
+ return AssertOutcome(
197
+ passed=is_visible,
198
+ reason=(
199
+ ""
200
+ if is_visible
201
+ else f"element found but not visible (in_viewport={el.in_viewport}, is_occluded={el.is_occluded})"
202
+ ),
203
+ details={
204
+ "query": _query_to_dict(query),
205
+ "element_id": el.id,
206
+ "in_viewport": el.in_viewport,
207
+ "is_occluded": el.is_occluded,
208
+ },
209
+ )
210
+ else:
211
+ return AssertOutcome(
212
+ passed=False,
213
+ reason="to_be_visible() requires ElementQuery",
214
+ details={},
215
+ )
216
+
217
+ return _pred
218
+
219
+ def to_have_text_contains(self, text: str) -> Predicate:
220
+ """
221
+ Assert that element's text contains the specified substring.
222
+
223
+ Args:
224
+ text: Substring to search for (case-insensitive)
225
+
226
+ Returns:
227
+ Predicate function for use with runtime.assert_()
228
+
229
+ Example:
230
+ runtime.assert_(
231
+ expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN"),
232
+ label="first_item_is_show_hn"
233
+ )
234
+ """
235
+ query = self._query
236
+
237
+ def _pred(ctx: AssertContext) -> AssertOutcome:
238
+ snap = ctx.snapshot
239
+ if snap is None:
240
+ return AssertOutcome(
241
+ passed=False,
242
+ reason="no snapshot available",
243
+ details={"query": _query_to_dict(query), "expected_text": text},
244
+ )
245
+
246
+ if isinstance(query, ElementQuery):
247
+ matches = query.find_all(snap)
248
+ if len(matches) == 0:
249
+ return AssertOutcome(
250
+ passed=False,
251
+ reason=f"no elements matched query: {_query_to_dict(query)}",
252
+ details={
253
+ "query": _query_to_dict(query),
254
+ "matched": 0,
255
+ "expected_text": text,
256
+ },
257
+ )
258
+
259
+ # Check text of first match
260
+ el = matches[0]
261
+ el_text = el.text or ""
262
+ ok = text.lower() in el_text.lower()
263
+ return AssertOutcome(
264
+ passed=ok,
265
+ reason=(
266
+ "" if ok else f"element text '{el_text[:100]}' does not contain '{text}'"
267
+ ),
268
+ details={
269
+ "query": _query_to_dict(query),
270
+ "element_id": el.id,
271
+ "element_text": el_text[:200],
272
+ "expected_text": text,
273
+ },
274
+ )
275
+ elif isinstance(query, _MultiTextPredicate):
276
+ # This is from MultiQuery.any_text_contains()
277
+ # Already handled by that method
278
+ return AssertOutcome(
279
+ passed=False,
280
+ reason="use any_text_contains() for MultiQuery",
281
+ details={},
282
+ )
283
+ else:
284
+ return AssertOutcome(
285
+ passed=False,
286
+ reason="to_have_text_contains() requires ElementQuery",
287
+ details={},
288
+ )
289
+
290
+ return _pred
291
+
292
+
293
+ class _ExpectFactory:
294
+ """
295
+ Factory for creating ExpectBuilder instances and global assertions.
296
+
297
+ This is the main entry point for the assertion DSL.
298
+
299
+ Usage:
300
+ from sentience.asserts import expect, E
301
+
302
+ # Element-based assertions
303
+ expect(E(role="button")).to_exist()
304
+ expect(E(text_contains="Error")).not_to_exist()
305
+
306
+ # Global text assertions
307
+ expect.text_present("Welcome back")
308
+ expect.no_text("Error")
309
+ """
310
+
311
+ def __call__(
312
+ self,
313
+ query: ElementQuery | ListQuery | MultiQuery | _MultiTextPredicate,
314
+ ) -> ExpectBuilder:
315
+ """
316
+ Create an expectation builder for the given query.
317
+
318
+ Args:
319
+ query: ElementQuery, ListQuery.nth() result, MultiQuery, or _MultiTextPredicate
320
+
321
+ Returns:
322
+ ExpectBuilder for chaining matchers
323
+
324
+ Example:
325
+ expect(E(role="button")).to_exist()
326
+ expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN")
327
+ """
328
+ if isinstance(query, (ElementQuery, MultiQuery, _MultiTextPredicate)):
329
+ return ExpectBuilder(query)
330
+ else:
331
+ raise TypeError(
332
+ f"expect() requires ElementQuery, MultiQuery, or _MultiTextPredicate, got {type(query)}"
333
+ )
334
+
335
+ def text_present(self, text: str) -> Predicate:
336
+ """
337
+ Global assertion: check if text is present anywhere on the page.
338
+
339
+ Searches across all element text_norm fields.
340
+
341
+ Args:
342
+ text: Text to search for (case-insensitive substring)
343
+
344
+ Returns:
345
+ Predicate function for use with runtime.assert_()
346
+
347
+ Example:
348
+ runtime.assert_(
349
+ expect.text_present("Welcome back"),
350
+ label="user_logged_in"
351
+ )
352
+ """
353
+
354
+ def _pred(ctx: AssertContext) -> AssertOutcome:
355
+ snap = ctx.snapshot
356
+ if snap is None:
357
+ return AssertOutcome(
358
+ passed=False,
359
+ reason="no snapshot available",
360
+ details={"search_text": text},
361
+ )
362
+
363
+ # Search all element texts
364
+ text_lower = text.lower()
365
+ for el in snap.elements:
366
+ el_text = el.text or ""
367
+ if text_lower in el_text.lower():
368
+ return AssertOutcome(
369
+ passed=True,
370
+ reason="",
371
+ details={"search_text": text, "found_in_element": el.id},
372
+ )
373
+
374
+ return AssertOutcome(
375
+ passed=False,
376
+ reason=f"text '{text}' not found on page",
377
+ details={"search_text": text, "elements_searched": len(snap.elements)},
378
+ )
379
+
380
+ return _pred
381
+
382
+ def no_text(self, text: str) -> Predicate:
383
+ """
384
+ Global assertion: check that text is NOT present anywhere on the page.
385
+
386
+ Searches across all element text_norm fields.
387
+
388
+ Args:
389
+ text: Text that should not be present (case-insensitive substring)
390
+
391
+ Returns:
392
+ Predicate function for use with runtime.assert_()
393
+
394
+ Example:
395
+ runtime.assert_(
396
+ expect.no_text("Error"),
397
+ label="no_error_message"
398
+ )
399
+ """
400
+
401
+ def _pred(ctx: AssertContext) -> AssertOutcome:
402
+ snap = ctx.snapshot
403
+ if snap is None:
404
+ return AssertOutcome(
405
+ passed=False,
406
+ reason="no snapshot available",
407
+ details={"search_text": text},
408
+ )
409
+
410
+ # Search all element texts
411
+ text_lower = text.lower()
412
+ for el in snap.elements:
413
+ el_text = el.text or ""
414
+ if text_lower in el_text.lower():
415
+ return AssertOutcome(
416
+ passed=False,
417
+ reason=f"text '{text}' found in element id={el.id}",
418
+ details={
419
+ "search_text": text,
420
+ "found_in_element": el.id,
421
+ "element_text": el_text[:200],
422
+ },
423
+ )
424
+
425
+ return AssertOutcome(
426
+ passed=True,
427
+ reason="",
428
+ details={"search_text": text, "elements_searched": len(snap.elements)},
429
+ )
430
+
431
+ return _pred
432
+
433
+
434
+ # Create the singleton factory
435
+ expect = _ExpectFactory()
436
+
437
+
438
+ def _query_to_dict(query: ElementQuery | MultiQuery | _MultiTextPredicate | Any) -> dict[str, Any]:
439
+ """Convert query to a serializable dict for debugging."""
440
+ if isinstance(query, ElementQuery):
441
+ result = {}
442
+ if query.role:
443
+ result["role"] = query.role
444
+ if query.name:
445
+ result["name"] = query.name
446
+ if query.text:
447
+ result["text"] = query.text
448
+ if query.text_contains:
449
+ result["text_contains"] = query.text_contains
450
+ if query.href_contains:
451
+ result["href_contains"] = query.href_contains
452
+ if query.in_viewport is not None:
453
+ result["in_viewport"] = query.in_viewport
454
+ if query.occluded is not None:
455
+ result["occluded"] = query.occluded
456
+ if query.group:
457
+ result["group"] = query.group
458
+ if query.in_dominant_group is not None:
459
+ result["in_dominant_group"] = query.in_dominant_group
460
+ if query._group_index is not None:
461
+ result["group_index"] = query._group_index
462
+ if query._from_dominant_list:
463
+ result["from_dominant_list"] = True
464
+ return result
465
+ elif isinstance(query, MultiQuery):
466
+ return {"type": "multi", "limit": query.limit}
467
+ elif isinstance(query, _MultiTextPredicate):
468
+ return {
469
+ "type": "multi_text",
470
+ "text": query.text,
471
+ "check_type": query.check_type,
472
+ }
473
+ else:
474
+ return {"type": str(type(query))}
475
+
476
+
477
+ class EventuallyWrapper:
478
+ """
479
+ Wrapper that adds retry logic to a predicate.
480
+
481
+ Created by calling .eventually() on an ExpectBuilder method result.
482
+ This is a helper that executes retries by taking fresh snapshots.
483
+
484
+ Note: .eventually() returns an async function that must be awaited.
485
+ """
486
+
487
+ def __init__(
488
+ self,
489
+ predicate: Predicate,
490
+ config: EventuallyConfig,
491
+ ):
492
+ """
493
+ Initialize eventually wrapper.
494
+
495
+ Args:
496
+ predicate: The predicate to retry
497
+ config: Retry configuration
498
+ """
499
+ self._predicate = predicate
500
+ self._config = config
501
+
502
+ async def evaluate(self, ctx: AssertContext, snapshot_fn) -> AssertOutcome:
503
+ """
504
+ Evaluate predicate with retry logic.
505
+
506
+ Args:
507
+ ctx: Initial assertion context
508
+ snapshot_fn: Async function to take fresh snapshots
509
+
510
+ Returns:
511
+ AssertOutcome from successful evaluation or last failed attempt
512
+ """
513
+ start_time = time.monotonic()
514
+ last_outcome: AssertOutcome | None = None
515
+ attempts = 0
516
+
517
+ while True:
518
+ # Check timeout (higher precedence than max_retries)
519
+ elapsed = time.monotonic() - start_time
520
+ if elapsed >= self._config.timeout:
521
+ if last_outcome:
522
+ last_outcome.reason = f"timeout after {elapsed:.1f}s: {last_outcome.reason}"
523
+ return last_outcome
524
+ return AssertOutcome(
525
+ passed=False,
526
+ reason=f"timeout after {elapsed:.1f}s",
527
+ details={"attempts": attempts},
528
+ )
529
+
530
+ # Check max retries
531
+ if attempts >= self._config.max_retries:
532
+ if last_outcome:
533
+ last_outcome.reason = (
534
+ f"max retries ({self._config.max_retries}) exceeded: {last_outcome.reason}"
535
+ )
536
+ return last_outcome
537
+ return AssertOutcome(
538
+ passed=False,
539
+ reason=f"max retries ({self._config.max_retries}) exceeded",
540
+ details={"attempts": attempts},
541
+ )
542
+
543
+ # Take fresh snapshot if not first attempt
544
+ if attempts > 0:
545
+ try:
546
+ fresh_snapshot = await snapshot_fn()
547
+ ctx = AssertContext(
548
+ snapshot=fresh_snapshot,
549
+ url=fresh_snapshot.url if fresh_snapshot else ctx.url,
550
+ step_id=ctx.step_id,
551
+ )
552
+ except Exception as e:
553
+ last_outcome = AssertOutcome(
554
+ passed=False,
555
+ reason=f"failed to take snapshot: {e}",
556
+ details={"attempts": attempts, "error": str(e)},
557
+ )
558
+ attempts += 1
559
+ await asyncio.sleep(self._config.poll)
560
+ continue
561
+
562
+ # Evaluate predicate
563
+ outcome = self._predicate(ctx)
564
+ if outcome.passed:
565
+ outcome.details["attempts"] = attempts + 1
566
+ return outcome
567
+
568
+ last_outcome = outcome
569
+ attempts += 1
570
+
571
+ # Wait before next retry
572
+ if attempts < self._config.max_retries:
573
+ # Check if we'd exceed timeout with the poll delay
574
+ if (time.monotonic() - start_time + self._config.poll) < self._config.timeout:
575
+ await asyncio.sleep(self._config.poll)
576
+ else:
577
+ # No point waiting, we'll timeout anyway
578
+ last_outcome.reason = (
579
+ f"timeout after {time.monotonic() - start_time:.1f}s: {last_outcome.reason}"
580
+ )
581
+ return last_outcome
582
+
583
+ return last_outcome or AssertOutcome(passed=False, reason="unexpected state")
584
+
585
+
586
+ def with_eventually(
587
+ predicate: Predicate,
588
+ timeout: float = DEFAULT_TIMEOUT,
589
+ poll: float = DEFAULT_POLL,
590
+ max_retries: int = DEFAULT_MAX_RETRIES,
591
+ ) -> EventuallyWrapper:
592
+ """
593
+ Wrap a predicate with retry logic.
594
+
595
+ This is the Python API for .eventually(). Since Python predicates
596
+ are synchronous, this returns a wrapper that provides an async
597
+ evaluate() method for use with the runtime.
598
+
599
+ Args:
600
+ predicate: Predicate to wrap
601
+ timeout: Max time to wait (seconds, default 10)
602
+ poll: Interval between retries (seconds, default 0.2)
603
+ max_retries: Max number of retry attempts (default 3)
604
+
605
+ Returns:
606
+ EventuallyWrapper with async evaluate() method
607
+
608
+ Example:
609
+ wrapper = with_eventually(
610
+ expect(E(role="button")).to_exist(),
611
+ timeout=5,
612
+ max_retries=10
613
+ )
614
+ result = await wrapper.evaluate(ctx, runtime.snapshot)
615
+ """
616
+ config = EventuallyConfig(
617
+ timeout=timeout,
618
+ poll=poll,
619
+ max_retries=max_retries,
620
+ )
621
+ return EventuallyWrapper(predicate, config)