Skip to content

Commit 5a4fe92

Browse files
authored
Merge pull request #146 from SentienceAPI/assert_v2
Smart assert v1 + v2
2 parents bb2c93a + b5cf83c commit 5a4fe92

19 files changed

+1310
-157
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,17 +299,17 @@ jobs:
299299
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
300300
301301
print("=== Final Pre-Test Verification ===")
302-
302+
303303
# First, verify the source file directly
304304
source_file = 'sentience/agent_runtime.py'
305305
print(f"=== Checking source file: {source_file} ===")
306306
if not os.path.exists(source_file):
307307
print(f"ERROR: Source file {source_file} not found!")
308308
sys.exit(1)
309-
309+
310310
with open(source_file, 'r', encoding='utf-8') as f:
311311
source_content = f.read()
312-
312+
313313
# Check if the bug exists and try to fix it one more time (in case auto-fix didn't run)
314314
if 'self.assertTrue(' in source_content:
315315
print('WARNING: Found self.assertTrue( in source file. Attempting to fix...')
@@ -332,11 +332,11 @@ jobs:
332332
print('OK: Source file uses self.assert_( correctly')
333333
else:
334334
print('WARNING: Could not find assert_ method in source file')
335-
335+
336336
# Now check the installed package
337337
print("\n=== Checking installed package ===")
338338
import sentience.agent_runtime
339-
339+
340340
# Verify it's using local source (editable install)
341341
import sentience
342342
pkg_path = os.path.abspath(sentience.__file__)
@@ -348,7 +348,7 @@ jobs:
348348
print(f' This might be using PyPI package instead of local source!')
349349
else:
350350
print(f'OK: Package is from local source: {pkg_path}')
351-
351+
352352
src = inspect.getsource(sentience.agent_runtime.AgentRuntime.assert_done)
353353
354354
print("assert_done method source:")

README.md

Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,20 @@ Use `AgentRuntime` to add Jest-style assertions to your agent loops. Verify brow
3131
```python
3232
import asyncio
3333
from sentience import AsyncSentienceBrowser, AgentRuntime
34-
from sentience.verification import url_contains, exists, all_of
34+
from sentience.verification import (
35+
url_contains,
36+
exists,
37+
all_of,
38+
is_enabled,
39+
is_checked,
40+
value_equals,
41+
)
3542
from sentience.tracing import Tracer, JsonlTraceSink
3643

3744
async def main():
3845
# Create tracer
3946
tracer = Tracer(run_id="my-run", sink=JsonlTraceSink("trace.jsonl"))
40-
47+
4148
# Create browser and runtime
4249
async with AsyncSentienceBrowser() as browser:
4350
page = await browser.new_page()
@@ -46,30 +53,43 @@ async def main():
4653
page=page,
4754
tracer=tracer
4855
)
49-
56+
5057
# Navigate and take snapshot
5158
await page.goto("https://example.com")
5259
runtime.begin_step("Verify page loaded")
5360
await runtime.snapshot()
54-
55-
# Run assertions (Jest-style)
61+
62+
# v1: deterministic assertions (Jest-style)
5663
runtime.assert_(url_contains("example.com"), label="on_correct_domain")
5764
runtime.assert_(exists("role=heading"), label="has_heading")
5865
runtime.assert_(all_of([
5966
exists("role=button"),
6067
exists("role=link")
6168
]), label="has_interactive_elements")
62-
69+
70+
# v1: state-aware assertions (when Gateway refinement is enabled)
71+
runtime.assert_(is_enabled("role=button"), label="button_enabled")
72+
runtime.assert_(is_checked("role=checkbox name~'subscribe'"), label="subscribe_checked_if_present")
73+
runtime.assert_(value_equals("role=textbox name~'email'", "user@example.com"), label="email_value_if_present")
74+
75+
# v2: retry loop with snapshot confidence gating + exhaustion
76+
ok = await runtime.check(
77+
exists("role=heading"),
78+
label="heading_eventually_visible",
79+
required=True,
80+
).eventually(timeout_s=10.0, poll_s=0.25, min_confidence=0.7, max_snapshot_attempts=3)
81+
print("eventually() result:", ok)
82+
6383
# Check task completion
6484
if runtime.assert_done(exists("text~'Example'"), label="task_complete"):
6585
print("✅ Task completed!")
66-
86+
6787
print(f"Task done: {runtime.is_task_done}")
6888

6989
asyncio.run(main())
7090
```
7191

72-
**See example:** [`examples/agent_runtime_verification.py`](examples/agent_runtime_verification.py)
92+
**See examples:** [`examples/asserts/`](examples/asserts/)
7393

7494
## 🚀 Quick Start: Choose Your Abstraction Level
7595

@@ -183,56 +203,35 @@ scroll_to(browser, button.id, behavior='instant', block='start')
183203
---
184204

185205
<details>
186-
<summary><h2>💼 Real-World Example: Amazon Shopping Bot</h2></summary>
206+
<summary><h2>💼 Real-World Example: Assertion-driven navigation</h2></summary>
187207

188-
This example demonstrates navigating Amazon, finding products, and adding items to cart:
208+
This example shows how to use **assertions + `.eventually()`** to make an agent loop resilient:
189209

190210
```python
191-
from sentience import SentienceBrowser, snapshot, find, click
192-
import time
193-
194-
with SentienceBrowser(headless=False) as browser:
195-
# Navigate to Amazon Best Sellers
196-
browser.goto("https://www.amazon.com/gp/bestsellers/", wait_until="domcontentloaded")
197-
time.sleep(2) # Wait for dynamic content
198-
199-
# Take snapshot and find products
200-
snap = snapshot(browser)
201-
print(f"Found {len(snap.elements)} elements")
202-
203-
# Find first product in viewport using spatial filtering
204-
products = [
205-
el for el in snap.elements
206-
if el.role == "link"
207-
and el.visual_cues.is_clickable
208-
and el.in_viewport
209-
and not el.is_occluded
210-
and el.bbox.y < 600 # First row
211-
]
212-
213-
if products:
214-
# Sort by position (left to right, top to bottom)
215-
products.sort(key=lambda e: (e.bbox.y, e.bbox.x))
216-
first_product = products[0]
211+
import asyncio
212+
import os
213+
from sentience import AsyncSentienceBrowser, AgentRuntime
214+
from sentience.tracing import Tracer, JsonlTraceSink
215+
from sentience.verification import url_contains, exists
217216

218-
print(f"Clicking: {first_product.text}")
219-
result = click(browser, first_product.id)
217+
async def main():
218+
tracer = Tracer(run_id="verified-run", sink=JsonlTraceSink("trace_verified.jsonl"))
219+
async with AsyncSentienceBrowser(headless=True) as browser:
220+
page = await browser.new_page()
221+
runtime = await AgentRuntime.from_sentience_browser(browser=browser, page=page, tracer=tracer)
222+
runtime.sentience_api_key = os.getenv("SENTIENCE_API_KEY") # optional, enables Gateway diagnostics
220223

221-
# Wait for product page
222-
browser.page.wait_for_load_state("networkidle")
223-
time.sleep(2)
224+
await page.goto("https://example.com")
225+
runtime.begin_step("Verify we're on the right page")
224226

225-
# Find and click "Add to Cart" button
226-
product_snap = snapshot(browser)
227-
add_to_cart = find(product_snap, "role=button text~'add to cart'")
227+
await runtime.check(url_contains("example.com"), label="on_domain", required=True).eventually(
228+
timeout_s=10.0, poll_s=0.25, min_confidence=0.7, max_snapshot_attempts=3
229+
)
230+
runtime.assert_(exists("role=heading"), label="heading_present")
228231

229-
if add_to_cart:
230-
cart_result = click(browser, add_to_cart.id)
231-
print(f"Added to cart: {cart_result.success}")
232+
asyncio.run(main())
232233
```
233234

234-
**📖 See the complete tutorial:** [Amazon Shopping Guide](../docs/AMAZON_SHOPPING_GUIDE.md)
235-
236235
</details>
237236

238237
---

examples/asserts/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Assertions examples (v1 + v2)
2+
3+
These examples focus on **AgentRuntime assertions**:
4+
5+
- **v1**: deterministic, state-aware assertions (enabled/checked/value/expanded) + failure intelligence
6+
- **v2**: `.eventually()` retry loops with `min_confidence` gating + snapshot exhaustion, plus optional Python vision fallback
7+
8+
Run examples:
9+
10+
```bash
11+
cd sdk-python
12+
python examples/asserts/v1_state_assertions.py
13+
python examples/asserts/v2_eventually_min_confidence.py
14+
python examples/asserts/v2_vision_fallback.py
15+
```
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
v2: `.check(...).eventually(...)` with snapshot confidence gating + exhaustion.
3+
4+
This example shows:
5+
- retry loop semantics
6+
- `min_confidence` gating (snapshot_low_confidence -> snapshot_exhausted)
7+
- structured assertion records in traces
8+
"""
9+
10+
import asyncio
11+
import os
12+
13+
from sentience import AgentRuntime, AsyncSentienceBrowser
14+
from sentience.tracing import JsonlTraceSink, Tracer
15+
from sentience.verification import exists
16+
17+
18+
async def main() -> None:
19+
tracer = Tracer(run_id="asserts-v2", sink=JsonlTraceSink("trace_asserts_v2.jsonl"))
20+
sentience_api_key = os.getenv("SENTIENCE_API_KEY")
21+
22+
async with AsyncSentienceBrowser(headless=True) as browser:
23+
page = await browser.new_page()
24+
runtime = await AgentRuntime.from_sentience_browser(
25+
browser=browser, page=page, tracer=tracer
26+
)
27+
if sentience_api_key:
28+
runtime.sentience_api_key = sentience_api_key
29+
30+
await page.goto("https://example.com")
31+
runtime.begin_step("Assert v2 eventually")
32+
33+
ok = await runtime.check(
34+
exists("role=heading"),
35+
label="heading_eventually_visible",
36+
required=True,
37+
).eventually(
38+
timeout_s=10.0,
39+
poll_s=0.25,
40+
# If the Gateway reports snapshot.diagnostics.confidence, gate on it:
41+
min_confidence=0.7,
42+
max_snapshot_attempts=3,
43+
)
44+
45+
print("eventually() result:", ok)
46+
print("Final assertion:", runtime.get_assertions_for_step_end()["assertions"])
47+
48+
49+
if __name__ == "__main__":
50+
asyncio.run(main())
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
v1: State-aware assertions with AgentRuntime.
3+
4+
This example is meant to be run with a Pro/Enterprise API key so the Gateway
5+
can refine raw elements into SmartElements with state fields (enabled/checked/value/etc).
6+
7+
Env vars:
8+
- SENTIENCE_API_KEY (optional but recommended for v1 state assertions)
9+
"""
10+
11+
import asyncio
12+
import os
13+
14+
from sentience import AgentRuntime, AsyncSentienceBrowser
15+
from sentience.tracing import JsonlTraceSink, Tracer
16+
from sentience.verification import (
17+
exists,
18+
is_checked,
19+
is_disabled,
20+
is_enabled,
21+
is_expanded,
22+
value_contains,
23+
)
24+
25+
26+
async def main() -> None:
27+
tracer = Tracer(run_id="asserts-v1", sink=JsonlTraceSink("trace_asserts_v1.jsonl"))
28+
29+
sentience_api_key = os.getenv("SENTIENCE_API_KEY")
30+
31+
async with AsyncSentienceBrowser(headless=True) as browser:
32+
page = await browser.new_page()
33+
runtime = await AgentRuntime.from_sentience_browser(
34+
browser=browser, page=page, tracer=tracer
35+
)
36+
37+
# If you have a Pro/Enterprise key, set it on the runtime so snapshots use the Gateway.
38+
# (This improves selector quality and unlocks state-aware fields for assertions.)
39+
if sentience_api_key:
40+
runtime.sentience_api_key = sentience_api_key
41+
42+
await page.goto("https://example.com")
43+
runtime.begin_step("Assert v1 state")
44+
await runtime.snapshot()
45+
46+
# v1: state-aware assertions (examples)
47+
runtime.assert_(exists("role=heading"), label="has_heading")
48+
runtime.assert_(is_enabled("role=link"), label="some_link_enabled")
49+
runtime.assert_(
50+
is_disabled("role=button text~'continue'"), label="continue_disabled_if_present"
51+
)
52+
runtime.assert_(
53+
is_checked("role=checkbox name~'subscribe'"), label="subscribe_checked_if_present"
54+
)
55+
runtime.assert_(is_expanded("role=button name~'more'"), label="more_is_expanded_if_present")
56+
runtime.assert_(
57+
value_contains("role=textbox name~'email'", "@"), label="email_has_at_if_present"
58+
)
59+
60+
# Failure intelligence: if something fails you’ll see:
61+
# - details.reason_code
62+
# - details.nearest_matches (suggestions)
63+
64+
print("Assertions recorded:", runtime.get_assertions_for_step_end()["assertions"])
65+
66+
67+
if __name__ == "__main__":
68+
asyncio.run(main())
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
v2 (Python-only): vision fallback after snapshot exhaustion.
3+
4+
When `min_confidence` gating keeps failing (snapshot_exhausted), you can pass a
5+
vision-capable LLMProvider to `eventually()` and ask it for a strict YES/NO
6+
verification using a screenshot.
7+
8+
Env vars:
9+
- OPENAI_API_KEY (if using OpenAIProvider)
10+
- SENTIENCE_API_KEY (optional, recommended so diagnostics/confidence is present)
11+
"""
12+
13+
import asyncio
14+
import os
15+
16+
from sentience import AgentRuntime, AsyncSentienceBrowser
17+
from sentience.llm_provider import OpenAIProvider
18+
from sentience.tracing import JsonlTraceSink, Tracer
19+
from sentience.verification import exists
20+
21+
22+
async def main() -> None:
23+
tracer = Tracer(
24+
run_id="asserts-v2-vision", sink=JsonlTraceSink("trace_asserts_v2_vision.jsonl")
25+
)
26+
sentience_api_key = os.getenv("SENTIENCE_API_KEY")
27+
28+
# Any provider implementing supports_vision() + generate_with_image() works.
29+
vision = OpenAIProvider(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o")
30+
31+
async with AsyncSentienceBrowser(headless=True) as browser:
32+
page = await browser.new_page()
33+
runtime = await AgentRuntime.from_sentience_browser(
34+
browser=browser, page=page, tracer=tracer
35+
)
36+
if sentience_api_key:
37+
runtime.sentience_api_key = sentience_api_key
38+
39+
await page.goto("https://example.com")
40+
runtime.begin_step("Assert v2 vision fallback")
41+
42+
ok = await runtime.check(
43+
exists("text~'Example Domain'"), label="example_domain_text"
44+
).eventually(
45+
timeout_s=10.0,
46+
poll_s=0.25,
47+
min_confidence=0.7,
48+
max_snapshot_attempts=2,
49+
vision_provider=vision,
50+
vision_system_prompt="You are a strict visual verifier. Answer only YES or NO.",
51+
vision_user_prompt="In the screenshot, is the phrase 'Example Domain' visible? Answer YES or NO.",
52+
)
53+
54+
print("eventually() w/ vision result:", ok)
55+
56+
57+
if __name__ == "__main__":
58+
asyncio.run(main())

0 commit comments

Comments
 (0)