Skip to content

Commit 4aeb0e8

Browse files
committed
Phase 1: completed PyndanticAI
1 parent fe55ebc commit 4aeb0e8

File tree

10 files changed

+445
-6
lines changed

10 files changed

+445
-6
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Example: PydanticAI + Sentience self-correcting action loop using URL guards.
3+
4+
Run:
5+
pip install sentienceapi[pydanticai]
6+
python examples/pydantic_ai_self_correcting_click.py
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from sentience import AsyncSentienceBrowser
12+
from sentience.integrations.pydanticai import SentiencePydanticDeps, register_sentience_tools
13+
14+
15+
async def main() -> None:
16+
from pydantic_ai import Agent
17+
18+
browser = AsyncSentienceBrowser(headless=False)
19+
await browser.start()
20+
await browser.page.goto("https://example.com") # replace with a real target
21+
22+
agent = Agent(
23+
"openai:gpt-5",
24+
deps_type=SentiencePydanticDeps,
25+
output_type=str,
26+
instructions=(
27+
"Navigate on the site and click the appropriate link/button. "
28+
"After clicking, use assert_eventually_url_matches to confirm the URL changed as expected."
29+
),
30+
)
31+
register_sentience_tools(agent)
32+
33+
deps = SentiencePydanticDeps(browser=browser)
34+
result = await agent.run("Click something that navigates, then confirm URL changed.", deps=deps)
35+
print(result.output)
36+
37+
await browser.close()
38+
39+
40+
if __name__ == "__main__":
41+
import asyncio
42+
43+
asyncio.run(main())
44+
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Example: PydanticAI + Sentience typed extraction (Phase 1 integration).
3+
4+
Run:
5+
pip install sentienceapi[pydanticai]
6+
python examples/pydantic_ai_typed_extraction.py
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pydantic import BaseModel, Field
12+
13+
from sentience import AsyncSentienceBrowser
14+
from sentience.integrations.pydanticai import SentiencePydanticDeps, register_sentience_tools
15+
16+
17+
class ProductInfo(BaseModel):
18+
title: str = Field(..., description="Product title")
19+
price: str = Field(..., description="Displayed price string")
20+
21+
22+
async def main() -> None:
23+
from pydantic_ai import Agent
24+
25+
browser = AsyncSentienceBrowser(headless=False)
26+
await browser.start()
27+
await browser.page.goto("https://example.com") # replace with a real target
28+
29+
agent = Agent(
30+
"openai:gpt-5",
31+
deps_type=SentiencePydanticDeps,
32+
output_type=ProductInfo,
33+
instructions="Extract the product title and price from the page.",
34+
)
35+
register_sentience_tools(agent)
36+
37+
deps = SentiencePydanticDeps(browser=browser)
38+
result = await agent.run("Extract title and price.", deps=deps)
39+
print(result.output)
40+
41+
await browser.close()
42+
43+
44+
if __name__ == "__main__":
45+
import asyncio
46+
47+
asyncio.run(main())
48+

sentience/integrations/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@
44
This package is intended for framework integrations (e.g., PydanticAI, LangChain/LangGraph).
55
Public APIs should be introduced deliberately once the integration surface is stable.
66
"""
7-

sentience/integrations/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,3 @@ class AssertionResult(BaseModel):
4444
passed: bool
4545
reason: str = ""
4646
details: dict[str, Any] = {}
47-
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
PydanticAI integration helpers (optional).
3+
4+
This module does NOT import `pydantic_ai` at import time so the base SDK can be
5+
installed without the optional dependency. Users should install:
6+
7+
pip install sentienceapi[pydanticai]
8+
9+
and then use `register_sentience_tools(...)` with a PydanticAI `Agent`.
10+
"""
11+
12+
from .deps import SentiencePydanticDeps
13+
from .toolset import register_sentience_tools
14+
15+
__all__ = ["SentiencePydanticDeps", "register_sentience_tools"]
16+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from sentience.browser import AsyncSentienceBrowser
7+
8+
9+
@dataclass
10+
class SentiencePydanticDeps:
11+
"""
12+
Dependencies passed into PydanticAI tools via ctx.deps.
13+
14+
At minimum we carry the live `AsyncSentienceBrowser`.
15+
"""
16+
17+
browser: AsyncSentienceBrowser
18+
runtime: Any | None = None
19+
tracer: Any | None = None
20+
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import re
5+
import time
6+
from typing import Any, Literal
7+
8+
from sentience.actions import click_async, press_async, type_text_async
9+
from sentience.integrations.models import AssertionResult, BrowserState, ElementSummary
10+
from sentience.models import ReadResult, SnapshotOptions, TextRectSearchResult
11+
from sentience.read import read_async
12+
from sentience.snapshot import snapshot_async
13+
from sentience.text_search import find_text_rect_async
14+
15+
from .deps import SentiencePydanticDeps
16+
17+
18+
def register_sentience_tools(agent: Any) -> dict[str, Any]:
19+
"""
20+
Register Sentience tools on a PydanticAI agent.
21+
22+
This function is intentionally lightweight and avoids importing `pydantic_ai`
23+
at module import time. It expects `agent` to provide a `.tool` decorator
24+
compatible with PydanticAI's `Agent.tool`.
25+
26+
Returns:
27+
Mapping of tool name -> underlying coroutine function (useful for tests).
28+
"""
29+
30+
@agent.tool
31+
async def snapshot_state(
32+
ctx: Any,
33+
limit: int = 50,
34+
include_screenshot: bool = False,
35+
) -> BrowserState:
36+
"""
37+
Take a bounded snapshot of the current page and return a small typed summary.
38+
"""
39+
deps: SentiencePydanticDeps = ctx.deps
40+
opts = SnapshotOptions(limit=limit, screenshot=include_screenshot)
41+
snap = await snapshot_async(deps.browser, opts)
42+
elements = [
43+
ElementSummary(
44+
id=e.id,
45+
role=e.role,
46+
text=e.text,
47+
importance=e.importance,
48+
bbox=e.bbox,
49+
)
50+
for e in snap.elements
51+
]
52+
return BrowserState(url=snap.url, elements=elements)
53+
54+
@agent.tool
55+
async def read_page(
56+
ctx: Any,
57+
format: Literal["raw", "text", "markdown"] = "text",
58+
enhance_markdown: bool = True,
59+
) -> ReadResult:
60+
"""
61+
Read page content as raw HTML, text, or markdown.
62+
"""
63+
deps: SentiencePydanticDeps = ctx.deps
64+
return await read_async(deps.browser, output_format=format, enhance_markdown=enhance_markdown)
65+
66+
@agent.tool
67+
async def click(
68+
ctx: Any,
69+
element_id: int,
70+
):
71+
"""
72+
Click an element by Sentience element id (from snapshot).
73+
"""
74+
deps: SentiencePydanticDeps = ctx.deps
75+
return await click_async(deps.browser, element_id)
76+
77+
@agent.tool
78+
async def type_text(
79+
ctx: Any,
80+
element_id: int,
81+
text: str,
82+
):
83+
"""
84+
Type text into an element by Sentience element id (from snapshot).
85+
"""
86+
deps: SentiencePydanticDeps = ctx.deps
87+
return await type_text_async(deps.browser, element_id, text)
88+
89+
@agent.tool
90+
async def press_key(
91+
ctx: Any,
92+
key: str,
93+
):
94+
"""
95+
Press a keyboard key (Enter, Escape, Tab, etc.).
96+
"""
97+
deps: SentiencePydanticDeps = ctx.deps
98+
return await press_async(deps.browser, key)
99+
100+
@agent.tool
101+
async def find_text_rect(
102+
ctx: Any,
103+
text: str,
104+
case_sensitive: bool = False,
105+
whole_word: bool = False,
106+
max_results: int = 10,
107+
) -> TextRectSearchResult:
108+
"""
109+
Find text occurrences and return pixel coordinates.
110+
"""
111+
deps: SentiencePydanticDeps = ctx.deps
112+
return await find_text_rect_async(
113+
deps.browser,
114+
text,
115+
case_sensitive=case_sensitive,
116+
whole_word=whole_word,
117+
max_results=max_results,
118+
)
119+
120+
@agent.tool
121+
async def verify_url_matches(
122+
ctx: Any,
123+
pattern: str,
124+
flags: int = 0,
125+
) -> AssertionResult:
126+
"""
127+
Verify the current page URL matches a regex pattern.
128+
"""
129+
deps: SentiencePydanticDeps = ctx.deps
130+
if not deps.browser.page:
131+
return AssertionResult(passed=False, reason="Browser not started (page is None)")
132+
133+
url = deps.browser.page.url
134+
ok = re.search(pattern, url, flags) is not None
135+
return AssertionResult(
136+
passed=ok,
137+
reason="" if ok else f"URL did not match pattern. url={url!r} pattern={pattern!r}",
138+
details={"url": url, "pattern": pattern},
139+
)
140+
141+
@agent.tool
142+
async def verify_text_present(
143+
ctx: Any,
144+
text: str,
145+
*,
146+
format: Literal["text", "markdown", "raw"] = "text",
147+
case_sensitive: bool = False,
148+
) -> AssertionResult:
149+
"""
150+
Verify a text substring is present in `read_page()` output.
151+
"""
152+
deps: SentiencePydanticDeps = ctx.deps
153+
result = await read_async(deps.browser, output_format=format, enhance_markdown=True)
154+
if result.status != "success":
155+
return AssertionResult(passed=False, reason=f"read failed: {result.error}", details={})
156+
157+
haystack = result.content if case_sensitive else result.content.lower()
158+
needle = text if case_sensitive else text.lower()
159+
ok = needle in haystack
160+
return AssertionResult(
161+
passed=ok,
162+
reason="" if ok else f"Text not present: {text!r}",
163+
details={"format": format, "query": text, "length": result.length},
164+
)
165+
166+
@agent.tool
167+
async def assert_eventually_url_matches(
168+
ctx: Any,
169+
pattern: str,
170+
*,
171+
timeout_s: float = 10.0,
172+
poll_s: float = 0.25,
173+
flags: int = 0,
174+
) -> AssertionResult:
175+
"""
176+
Retry until the page URL matches `pattern` or timeout is reached.
177+
"""
178+
deadline = time.monotonic() + timeout_s
179+
last = None
180+
while time.monotonic() <= deadline:
181+
last = await verify_url_matches(ctx, pattern, flags)
182+
if last.passed:
183+
return last
184+
await asyncio.sleep(poll_s)
185+
return last or AssertionResult(passed=False, reason="No attempts executed", details={})
186+
187+
return {
188+
"snapshot_state": snapshot_state,
189+
"read_page": read_page,
190+
"click": click,
191+
"type_text": type_text,
192+
"press_key": press_key,
193+
"find_text_rect": find_text_rect,
194+
"verify_url_matches": verify_url_matches,
195+
"verify_text_present": verify_text_present,
196+
"assert_eventually_url_matches": assert_eventually_url_matches,
197+
}
198+

tests/unit/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,3 @@ def _sync_playwright():
6565
# Expose submodules on the top-level module for completeness
6666
playwright_mod.async_api = async_api_mod
6767
playwright_mod.sync_api = sync_api_mod
68-

tests/unit/test_integration_phase0_contracts.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import pytest
22

3+
from sentience.models import SnapshotOptions
34
from sentience.read import read
45
from sentience.snapshot import snapshot
56
from sentience.text_search import find_text_rect
6-
from sentience.models import SnapshotOptions
77

88

99
class _FakePage:
@@ -95,7 +95,9 @@ def test_read_passes_requested_format():
9595
result = read(browser, output_format="text", enhance_markdown=False) # type: ignore[arg-type]
9696
assert result.format == "text"
9797

98-
read_calls = [(expr, arg) for (expr, arg) in page.evaluate_calls if "window.sentience.read" in expr]
98+
read_calls = [
99+
(expr, arg) for (expr, arg) in page.evaluate_calls if "window.sentience.read" in expr
100+
]
99101
assert len(read_calls) == 1
100102
_, options = read_calls[0]
101103
assert options == {"format": "text"}
@@ -117,4 +119,3 @@ def test_find_text_rect_unavailable_raises(monkeypatch):
117119
find_text_rect(browser, "Sign In") # type: ignore[arg-type]
118120

119121
assert "window.sentience.findTextRect is not available" in str(e.value)
120-

0 commit comments

Comments
 (0)