From 3ee96080579e9e537e9c1f6c704c4a66cbcadfdc Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 16:00:24 -0800 Subject: [PATCH 1/7] extend llm_provider to support vision; TS needed --- sentience/llm_provider.py | 216 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/sentience/llm_provider.py b/sentience/llm_provider.py index 650f17f..be8f179 100644 --- a/sentience/llm_provider.py +++ b/sentience/llm_provider.py @@ -81,6 +81,48 @@ def model_name(self) -> str: """ pass + def supports_vision(self) -> bool: + """ + Whether this provider supports image input for vision tasks. + + Override in subclasses that support vision-capable models. + + Returns: + True if provider supports vision, False otherwise + """ + return False + + def generate_with_image( + self, + system_prompt: str, + user_prompt: str, + image_base64: str, + **kwargs, + ) -> LLMResponse: + """ + Generate a response with image input (for vision-capable models). + + This method is used for vision fallback in assertions and visual agents. + Override in subclasses that support vision-capable models. + + Args: + system_prompt: System instruction/context + user_prompt: User query/request + image_base64: Base64-encoded image (PNG or JPEG) + **kwargs: Provider-specific parameters (temperature, max_tokens, etc.) + + Returns: + LLMResponse with content and token usage + + Raises: + NotImplementedError: If provider doesn't support vision + """ + raise NotImplementedError( + f"{type(self).__name__} does not support vision. " + "Use a vision-capable provider like OpenAIProvider with GPT-4o " + "or AnthropicProvider with Claude 3." + ) + class OpenAIProvider(LLMProvider): """ @@ -187,6 +229,92 @@ def supports_json_mode(self) -> bool: model_lower = self._model_name.lower() return any(x in model_lower for x in ["gpt-4", "gpt-3.5"]) + def supports_vision(self) -> bool: + """GPT-4o, GPT-4-turbo, and GPT-4-vision support vision.""" + model_lower = self._model_name.lower() + return any(x in model_lower for x in ["gpt-4o", "gpt-4-turbo", "gpt-4-vision"]) + + def generate_with_image( + self, + system_prompt: str, + user_prompt: str, + image_base64: str, + temperature: float = 0.0, + max_tokens: int | None = None, + **kwargs, + ) -> LLMResponse: + """ + Generate response with image input using OpenAI Vision API. + + Args: + system_prompt: System instruction + user_prompt: User query + image_base64: Base64-encoded image (PNG or JPEG) + temperature: Sampling temperature (0.0 = deterministic) + max_tokens: Maximum tokens to generate + **kwargs: Additional OpenAI API parameters + + Returns: + LLMResponse object + + Raises: + NotImplementedError: If model doesn't support vision + """ + if not self.supports_vision(): + raise NotImplementedError( + f"Model {self._model_name} does not support vision. " + "Use gpt-4o, gpt-4-turbo, or gpt-4-vision-preview." + ) + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + # Vision message format with image_url + messages.append( + { + "role": "user", + "content": [ + {"type": "text", "text": user_prompt}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{image_base64}"}, + }, + ], + } + ) + + # Build API parameters + api_params = { + "model": self._model_name, + "messages": messages, + "temperature": temperature, + } + + if max_tokens: + api_params["max_tokens"] = max_tokens + + # Merge additional parameters + api_params.update(kwargs) + + # Call OpenAI API + try: + response = self.client.chat.completions.create(**api_params) + except Exception as e: + handle_provider_error(e, "OpenAI", "generate response with image") + + choice = response.choices[0] + usage = response.usage + + return LLMResponseBuilder.from_openai_format( + content=choice.message.content, + prompt_tokens=usage.prompt_tokens if usage else None, + completion_tokens=usage.completion_tokens if usage else None, + total_tokens=usage.total_tokens if usage else None, + model_name=response.model, + finish_reason=choice.finish_reason, + ) + @property def model_name(self) -> str: return self._model_name @@ -277,6 +405,94 @@ def supports_json_mode(self) -> bool: """Anthropic doesn't have native JSON mode (requires prompt engineering)""" return False + def supports_vision(self) -> bool: + """Claude 3 models (Opus, Sonnet, Haiku) all support vision.""" + model_lower = self._model_name.lower() + return any(x in model_lower for x in ["claude-3", "claude-3.5"]) + + def generate_with_image( + self, + system_prompt: str, + user_prompt: str, + image_base64: str, + temperature: float = 0.0, + max_tokens: int = 1024, + **kwargs, + ) -> LLMResponse: + """ + Generate response with image input using Anthropic Vision API. + + Args: + system_prompt: System instruction + user_prompt: User query + image_base64: Base64-encoded image (PNG or JPEG) + temperature: Sampling temperature + max_tokens: Maximum tokens to generate (required by Anthropic) + **kwargs: Additional Anthropic API parameters + + Returns: + LLMResponse object + + Raises: + NotImplementedError: If model doesn't support vision + """ + if not self.supports_vision(): + raise NotImplementedError( + f"Model {self._model_name} does not support vision. " + "Use Claude 3 models (claude-3-opus, claude-3-sonnet, claude-3-haiku)." + ) + + # Anthropic vision message format + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": image_base64, + }, + }, + { + "type": "text", + "text": user_prompt, + }, + ], + } + ] + + # Build API parameters + api_params = { + "model": self._model_name, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": messages, + } + + if system_prompt: + api_params["system"] = system_prompt + + # Merge additional parameters + api_params.update(kwargs) + + # Call Anthropic API + try: + response = self.client.messages.create(**api_params) + except Exception as e: + handle_provider_error(e, "Anthropic", "generate response with image") + + content = response.content[0].text if response.content else "" + + return LLMResponseBuilder.from_anthropic_format( + content=content, + input_tokens=response.usage.input_tokens if hasattr(response, "usage") else None, + output_tokens=response.usage.output_tokens if hasattr(response, "usage") else None, + model_name=response.model, + stop_reason=response.stop_reason, + ) + @property def model_name(self) -> str: return self._model_name From 7477ebe44aebdb9a7178e84655348faebcaf6d9f Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 16:38:26 -0800 Subject: [PATCH 2/7] Assert_ DSL v0 with renaming v0 protocol --- examples/agent_runtime_verification.py | 4 +- .../browser-use/agent_runtime_browser_use.py | 6 +- sentience/__init__.py | 4 +- sentience/agent_runtime.py | 14 +- sentience/asserts/__init__.py | 70 ++ sentience/asserts/expect.py | 621 ++++++++++++++++++ sentience/asserts/query.py | 383 +++++++++++ sentience/backends/__init__.py | 4 +- sentience/backends/actions.py | 24 +- sentience/backends/cdp_backend.py | 6 +- sentience/backends/playwright_backend.py | 8 +- .../backends/{protocol_v0.py => protocol.py} | 2 +- sentience/backends/snapshot.py | 20 +- tests/test_agent_runtime.py | 4 +- tests/test_asserts.py | 537 +++++++++++++++ tests/test_backends.py | 12 +- 16 files changed, 1665 insertions(+), 54 deletions(-) create mode 100644 sentience/asserts/__init__.py create mode 100644 sentience/asserts/expect.py create mode 100644 sentience/asserts/query.py rename sentience/backends/{protocol_v0.py => protocol.py} (99%) create mode 100644 tests/test_asserts.py diff --git a/examples/agent_runtime_verification.py b/examples/agent_runtime_verification.py index 537cd12..92bddfc 100644 --- a/examples/agent_runtime_verification.py +++ b/examples/agent_runtime_verification.py @@ -5,7 +5,7 @@ The AgentRuntime provides assertion predicates to verify browser state during execution. Key features: -- BrowserBackendV0 protocol: Framework-agnostic browser integration +- BrowserBackend protocol: Framework-agnostic browser integration - Predicate helpers: url_matches, url_contains, exists, not_exists, element_count - Combinators: all_of, any_of for complex conditions - Task completion: assert_done() for goal verification @@ -44,7 +44,7 @@ async def main(): page = await browser.new_page() # 3. Create AgentRuntime using from_sentience_browser factory - # This wraps the browser/page into the new BrowserBackendV0 architecture + # This wraps the browser/page into the new BrowserBackend architecture runtime = await AgentRuntime.from_sentience_browser( browser=browser, page=page, diff --git a/examples/browser-use/agent_runtime_browser_use.py b/examples/browser-use/agent_runtime_browser_use.py index fe5ddaa..1944f48 100644 --- a/examples/browser-use/agent_runtime_browser_use.py +++ b/examples/browser-use/agent_runtime_browser_use.py @@ -1,12 +1,12 @@ """ Example: Agent Runtime with browser-use Integration -Demonstrates how to use AgentRuntime with browser-use library via BrowserBackendV0 protocol. +Demonstrates how to use AgentRuntime with browser-use library via BrowserBackend protocol. This pattern enables framework-agnostic browser integration for agent verification loops. Key features: - BrowserUseAdapter: Wraps browser-use BrowserSession into CDPBackendV0 -- BrowserBackendV0 protocol: Minimal interface for browser operations +- BrowserBackend protocol: Minimal interface for browser operations - Direct AgentRuntime construction: No need for from_sentience_browser factory Requirements: @@ -58,7 +58,7 @@ async def main(): await session.start() try: - # 3. Create BrowserBackendV0 using BrowserUseAdapter + # 3. Create BrowserBackend using BrowserUseAdapter # This wraps the browser-use session into the standard backend protocol adapter = BrowserUseAdapter(session) backend = await adapter.create_backend() diff --git a/sentience/__init__.py b/sentience/__init__.py index d3663df..11a6f3a 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -19,7 +19,7 @@ # Backend-agnostic actions (aliased to avoid conflict with existing actions) # Browser backends (for browser-use integration) from .backends import ( - BrowserBackendV0, + BrowserBackend, BrowserUseAdapter, BrowserUseCDPTransport, CachedSnapshot, @@ -132,7 +132,7 @@ "verify_extension_version", "verify_extension_version_async", # Browser backends (for browser-use integration) - "BrowserBackendV0", + "BrowserBackend", "CDPTransport", "CDPBackendV0", "PlaywrightBackend", diff --git a/sentience/agent_runtime.py b/sentience/agent_runtime.py index ed8d8c1..ae1c437 100644 --- a/sentience/agent_runtime.py +++ b/sentience/agent_runtime.py @@ -2,7 +2,7 @@ Agent runtime for verification loop support. This module provides a thin runtime wrapper that combines: -1. Browser session management (via BrowserBackendV0 protocol) +1. Browser session management (via BrowserBackend protocol) 2. Snapshot/query helpers 3. Tracer for event emission 4. Assertion/verification methods @@ -72,7 +72,7 @@ if TYPE_CHECKING: from playwright.async_api import Page - from .backends.protocol_v0 import BrowserBackendV0 + from .backends.protocol import BrowserBackend from .browser import AsyncSentienceBrowser from .tracing import Tracer @@ -90,7 +90,7 @@ class AgentRuntime: to the tracer for Studio timeline display. Attributes: - backend: BrowserBackendV0 instance for browser operations + backend: BrowserBackend instance for browser operations tracer: Tracer for event emission step_id: Current step identifier step_index: Current step index (0-based) @@ -99,16 +99,16 @@ class AgentRuntime: def __init__( self, - backend: BrowserBackendV0, + backend: BrowserBackend, tracer: Tracer, snapshot_options: SnapshotOptions | None = None, sentience_api_key: str | None = None, ): """ - Initialize agent runtime with any BrowserBackendV0-compatible browser. + Initialize agent runtime with any BrowserBackend-compatible browser. Args: - backend: Any browser implementing BrowserBackendV0 protocol. + backend: Any browser implementing BrowserBackend protocol. Examples: - CDPBackendV0 (for browser-use via BrowserUseAdapter) - PlaywrightBackend (future, for direct Playwright) @@ -157,7 +157,7 @@ async def from_sentience_browser( Create AgentRuntime from AsyncSentienceBrowser (backward compatibility). This factory method wraps an AsyncSentienceBrowser + Page combination - into the new BrowserBackendV0-based AgentRuntime. + into the new BrowserBackend-based AgentRuntime. Args: browser: AsyncSentienceBrowser instance diff --git a/sentience/asserts/__init__.py b/sentience/asserts/__init__.py new file mode 100644 index 0000000..85e9351 --- /dev/null +++ b/sentience/asserts/__init__.py @@ -0,0 +1,70 @@ +""" +Assertion DSL for Sentience SDK. + +This module provides a Playwright/Cypress-like assertion API for verifying +browser state in agent verification loops. + +Main exports: +- E: Element query builder (filters elements by role, text, href, etc.) +- expect: Expectation builder (creates predicates from queries) +- in_dominant_list: Query over dominant group elements (ordinal access) + +Example usage: + from sentience.asserts import E, expect, in_dominant_list + + # Basic presence assertions + runtime.assert_( + expect(E(role="button", text_contains="Save")).to_exist(), + label="save_button_visible" + ) + + # Visibility assertions + runtime.assert_( + expect(E(text_contains="Checkout")).to_be_visible(), + label="checkout_visible" + ) + + # Global text assertions + runtime.assert_( + expect.text_present("Welcome back"), + label="user_logged_in" + ) + runtime.assert_( + expect.no_text("Error"), + label="no_error_message" + ) + + # Ordinal assertions on dominant group + runtime.assert_( + expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN"), + label="first_item_is_show_hn" + ) + + # Task completion + runtime.assert_done( + expect.text_present("Order confirmed"), + label="checkout_complete" + ) + +The DSL compiles to existing Predicate functions, so it works seamlessly +with AgentRuntime.assert_() and assert_done(). +""" + +from .expect import EventuallyConfig, EventuallyWrapper, ExpectBuilder, expect, with_eventually +from .query import E, ElementQuery, ListQuery, MultiQuery, in_dominant_list + +__all__ = [ + # Query builders + "E", + "ElementQuery", + "ListQuery", + "MultiQuery", + "in_dominant_list", + # Expectation builders + "expect", + "ExpectBuilder", + # Eventually helpers + "with_eventually", + "EventuallyWrapper", + "EventuallyConfig", +] diff --git a/sentience/asserts/expect.py b/sentience/asserts/expect.py new file mode 100644 index 0000000..423b598 --- /dev/null +++ b/sentience/asserts/expect.py @@ -0,0 +1,621 @@ +""" +Expectation builder for assertion DSL. + +This module provides the expect() builder that creates fluent assertions +which compile to existing Predicate objects. + +Key classes: +- ExpectBuilder: Fluent builder for element-based assertions +- EventuallyBuilder: Wrapper for retry logic (.eventually()) + +The expect() function is the main entry point. It returns a builder that +can be chained with matchers: + expect(E(role="button")).to_exist() + expect(E(text_contains="Error")).not_to_exist() + expect.text_present("Welcome") + +All builders compile to Predicate functions compatible with AgentRuntime.assert_(). +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from ..verification import AssertContext, AssertOutcome, Predicate +from .query import ElementQuery, ListQuery, MultiQuery, _MultiTextPredicate + +if TYPE_CHECKING: + from ..models import Snapshot + + +# Default values for .eventually() +DEFAULT_TIMEOUT = 10 # seconds +DEFAULT_POLL = 0.2 # seconds +DEFAULT_MAX_RETRIES = 3 + + +@dataclass +class EventuallyConfig: + """Configuration for .eventually() retry logic.""" + + timeout: float = DEFAULT_TIMEOUT # Max time to wait (seconds) + poll: float = DEFAULT_POLL # Interval between retries (seconds) + max_retries: int = DEFAULT_MAX_RETRIES # Max number of retry attempts + + +class ExpectBuilder: + """ + Fluent builder for element-based assertions. + + Created by expect(E(...)) or expect(in_dominant_list().nth(k)). + + Methods return Predicate functions that can be passed to runtime.assert_(). + + Example: + expect(E(role="button")).to_exist() + expect(E(text_contains="Error")).not_to_exist() + expect(E(role="link")).to_be_visible() + """ + + def __init__(self, query: ElementQuery | MultiQuery | _MultiTextPredicate): + """ + Initialize builder with query. + + Args: + query: ElementQuery, MultiQuery, or _MultiTextPredicate + """ + self._query = query + + def to_exist(self) -> Predicate: + """ + Assert that at least one element matches the query. + + Returns: + Predicate function for use with runtime.assert_() + + Example: + runtime.assert_( + expect(E(role="button", text_contains="Save")).to_exist(), + label="save_button_exists" + ) + """ + query = self._query + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, + reason="no snapshot available", + details={"query": _query_to_dict(query)}, + ) + + if isinstance(query, ElementQuery): + matches = query.find_all(snap) + ok = len(matches) > 0 + return AssertOutcome( + passed=ok, + reason="" if ok else f"no elements matched query: {_query_to_dict(query)}", + details={"query": _query_to_dict(query), "matched": len(matches)}, + ) + else: + return AssertOutcome( + passed=False, + reason="to_exist() requires ElementQuery", + details={}, + ) + + return _pred + + def not_to_exist(self) -> Predicate: + """ + Assert that NO elements match the query. + + Useful for asserting absence of error messages, loading indicators, etc. + + Returns: + Predicate function for use with runtime.assert_() + + Example: + runtime.assert_( + expect(E(text_contains="Error")).not_to_exist(), + label="no_error_message" + ) + """ + query = self._query + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, + reason="no snapshot available", + details={"query": _query_to_dict(query)}, + ) + + if isinstance(query, ElementQuery): + matches = query.find_all(snap) + ok = len(matches) == 0 + return AssertOutcome( + passed=ok, + reason=( + "" + if ok + else f"found {len(matches)} elements matching: {_query_to_dict(query)}" + ), + details={"query": _query_to_dict(query), "matched": len(matches)}, + ) + else: + return AssertOutcome( + passed=False, + reason="not_to_exist() requires ElementQuery", + details={}, + ) + + return _pred + + def to_be_visible(self) -> Predicate: + """ + Assert that element exists AND is visible (in_viewport=True, occluded=False). + + Returns: + Predicate function for use with runtime.assert_() + + Example: + runtime.assert_( + expect(E(text_contains="Checkout")).to_be_visible(), + label="checkout_button_visible" + ) + """ + query = self._query + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, + reason="no snapshot available", + details={"query": _query_to_dict(query)}, + ) + + if isinstance(query, ElementQuery): + matches = query.find_all(snap) + if len(matches) == 0: + return AssertOutcome( + passed=False, + reason=f"no elements matched query: {_query_to_dict(query)}", + details={"query": _query_to_dict(query), "matched": 0}, + ) + + # Check visibility of first match + el = matches[0] + is_visible = el.in_viewport and not el.is_occluded + return AssertOutcome( + passed=is_visible, + reason=( + "" + if is_visible + else f"element found but not visible (in_viewport={el.in_viewport}, is_occluded={el.is_occluded})" + ), + details={ + "query": _query_to_dict(query), + "element_id": el.id, + "in_viewport": el.in_viewport, + "is_occluded": el.is_occluded, + }, + ) + else: + return AssertOutcome( + passed=False, + reason="to_be_visible() requires ElementQuery", + details={}, + ) + + return _pred + + def to_have_text_contains(self, text: str) -> Predicate: + """ + Assert that element's text contains the specified substring. + + Args: + text: Substring to search for (case-insensitive) + + Returns: + Predicate function for use with runtime.assert_() + + Example: + runtime.assert_( + expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN"), + label="first_item_is_show_hn" + ) + """ + query = self._query + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, + reason="no snapshot available", + details={"query": _query_to_dict(query), "expected_text": text}, + ) + + if isinstance(query, ElementQuery): + matches = query.find_all(snap) + if len(matches) == 0: + return AssertOutcome( + passed=False, + reason=f"no elements matched query: {_query_to_dict(query)}", + details={ + "query": _query_to_dict(query), + "matched": 0, + "expected_text": text, + }, + ) + + # Check text of first match + el = matches[0] + el_text = el.text or "" + ok = text.lower() in el_text.lower() + return AssertOutcome( + passed=ok, + reason=( + "" if ok else f"element text '{el_text[:100]}' does not contain '{text}'" + ), + details={ + "query": _query_to_dict(query), + "element_id": el.id, + "element_text": el_text[:200], + "expected_text": text, + }, + ) + elif isinstance(query, _MultiTextPredicate): + # This is from MultiQuery.any_text_contains() + # Already handled by that method + return AssertOutcome( + passed=False, + reason="use any_text_contains() for MultiQuery", + details={}, + ) + else: + return AssertOutcome( + passed=False, + reason="to_have_text_contains() requires ElementQuery", + details={}, + ) + + return _pred + + +class _ExpectFactory: + """ + Factory for creating ExpectBuilder instances and global assertions. + + This is the main entry point for the assertion DSL. + + Usage: + from sentience.asserts import expect, E + + # Element-based assertions + expect(E(role="button")).to_exist() + expect(E(text_contains="Error")).not_to_exist() + + # Global text assertions + expect.text_present("Welcome back") + expect.no_text("Error") + """ + + def __call__( + self, + query: ElementQuery | ListQuery | MultiQuery | _MultiTextPredicate, + ) -> ExpectBuilder: + """ + Create an expectation builder for the given query. + + Args: + query: ElementQuery, ListQuery.nth() result, MultiQuery, or _MultiTextPredicate + + Returns: + ExpectBuilder for chaining matchers + + Example: + expect(E(role="button")).to_exist() + expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN") + """ + if isinstance(query, (ElementQuery, MultiQuery, _MultiTextPredicate)): + return ExpectBuilder(query) + else: + raise TypeError( + f"expect() requires ElementQuery, MultiQuery, or _MultiTextPredicate, got {type(query)}" + ) + + def text_present(self, text: str) -> Predicate: + """ + Global assertion: check if text is present anywhere on the page. + + Searches across all element text_norm fields. + + Args: + text: Text to search for (case-insensitive substring) + + Returns: + Predicate function for use with runtime.assert_() + + Example: + runtime.assert_( + expect.text_present("Welcome back"), + label="user_logged_in" + ) + """ + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, + reason="no snapshot available", + details={"search_text": text}, + ) + + # Search all element texts + text_lower = text.lower() + for el in snap.elements: + el_text = el.text or "" + if text_lower in el_text.lower(): + return AssertOutcome( + passed=True, + reason="", + details={"search_text": text, "found_in_element": el.id}, + ) + + return AssertOutcome( + passed=False, + reason=f"text '{text}' not found on page", + details={"search_text": text, "elements_searched": len(snap.elements)}, + ) + + return _pred + + def no_text(self, text: str) -> Predicate: + """ + Global assertion: check that text is NOT present anywhere on the page. + + Searches across all element text_norm fields. + + Args: + text: Text that should not be present (case-insensitive substring) + + Returns: + Predicate function for use with runtime.assert_() + + Example: + runtime.assert_( + expect.no_text("Error"), + label="no_error_message" + ) + """ + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, + reason="no snapshot available", + details={"search_text": text}, + ) + + # Search all element texts + text_lower = text.lower() + for el in snap.elements: + el_text = el.text or "" + if text_lower in el_text.lower(): + return AssertOutcome( + passed=False, + reason=f"text '{text}' found in element id={el.id}", + details={ + "search_text": text, + "found_in_element": el.id, + "element_text": el_text[:200], + }, + ) + + return AssertOutcome( + passed=True, + reason="", + details={"search_text": text, "elements_searched": len(snap.elements)}, + ) + + return _pred + + +# Create the singleton factory +expect = _ExpectFactory() + + +def _query_to_dict(query: ElementQuery | MultiQuery | _MultiTextPredicate | Any) -> dict[str, Any]: + """Convert query to a serializable dict for debugging.""" + if isinstance(query, ElementQuery): + result = {} + if query.role: + result["role"] = query.role + if query.name: + result["name"] = query.name + if query.text: + result["text"] = query.text + if query.text_contains: + result["text_contains"] = query.text_contains + if query.href_contains: + result["href_contains"] = query.href_contains + if query.in_viewport is not None: + result["in_viewport"] = query.in_viewport + if query.occluded is not None: + result["occluded"] = query.occluded + if query.group: + result["group"] = query.group + if query.in_dominant_group is not None: + result["in_dominant_group"] = query.in_dominant_group + if query._group_index is not None: + result["group_index"] = query._group_index + if query._from_dominant_list: + result["from_dominant_list"] = True + return result + elif isinstance(query, MultiQuery): + return {"type": "multi", "limit": query.limit} + elif isinstance(query, _MultiTextPredicate): + return { + "type": "multi_text", + "text": query.text, + "check_type": query.check_type, + } + else: + return {"type": str(type(query))} + + +class EventuallyWrapper: + """ + Wrapper that adds retry logic to a predicate. + + Created by calling .eventually() on an ExpectBuilder method result. + This is a helper that executes retries by taking fresh snapshots. + + Note: .eventually() returns an async function that must be awaited. + """ + + def __init__( + self, + predicate: Predicate, + config: EventuallyConfig, + ): + """ + Initialize eventually wrapper. + + Args: + predicate: The predicate to retry + config: Retry configuration + """ + self._predicate = predicate + self._config = config + + async def evaluate(self, ctx: AssertContext, snapshot_fn) -> AssertOutcome: + """ + Evaluate predicate with retry logic. + + Args: + ctx: Initial assertion context + snapshot_fn: Async function to take fresh snapshots + + Returns: + AssertOutcome from successful evaluation or last failed attempt + """ + start_time = time.monotonic() + last_outcome: AssertOutcome | None = None + attempts = 0 + + while True: + # Check timeout (higher precedence than max_retries) + elapsed = time.monotonic() - start_time + if elapsed >= self._config.timeout: + if last_outcome: + last_outcome.reason = f"timeout after {elapsed:.1f}s: {last_outcome.reason}" + return last_outcome + return AssertOutcome( + passed=False, + reason=f"timeout after {elapsed:.1f}s", + details={"attempts": attempts}, + ) + + # Check max retries + if attempts >= self._config.max_retries: + if last_outcome: + last_outcome.reason = ( + f"max retries ({self._config.max_retries}) exceeded: {last_outcome.reason}" + ) + return last_outcome + return AssertOutcome( + passed=False, + reason=f"max retries ({self._config.max_retries}) exceeded", + details={"attempts": attempts}, + ) + + # Take fresh snapshot if not first attempt + if attempts > 0: + try: + fresh_snapshot = await snapshot_fn() + ctx = AssertContext( + snapshot=fresh_snapshot, + url=fresh_snapshot.url if fresh_snapshot else ctx.url, + step_id=ctx.step_id, + ) + except Exception as e: + last_outcome = AssertOutcome( + passed=False, + reason=f"failed to take snapshot: {e}", + details={"attempts": attempts, "error": str(e)}, + ) + attempts += 1 + await asyncio.sleep(self._config.poll) + continue + + # Evaluate predicate + outcome = self._predicate(ctx) + if outcome.passed: + outcome.details["attempts"] = attempts + 1 + return outcome + + last_outcome = outcome + attempts += 1 + + # Wait before next retry + if attempts < self._config.max_retries: + # Check if we'd exceed timeout with the poll delay + if (time.monotonic() - start_time + self._config.poll) < self._config.timeout: + await asyncio.sleep(self._config.poll) + else: + # No point waiting, we'll timeout anyway + last_outcome.reason = ( + f"timeout after {time.monotonic() - start_time:.1f}s: {last_outcome.reason}" + ) + return last_outcome + + return last_outcome or AssertOutcome(passed=False, reason="unexpected state") + + +def with_eventually( + predicate: Predicate, + timeout: float = DEFAULT_TIMEOUT, + poll: float = DEFAULT_POLL, + max_retries: int = DEFAULT_MAX_RETRIES, +) -> EventuallyWrapper: + """ + Wrap a predicate with retry logic. + + This is the Python API for .eventually(). Since Python predicates + are synchronous, this returns a wrapper that provides an async + evaluate() method for use with the runtime. + + Args: + predicate: Predicate to wrap + timeout: Max time to wait (seconds, default 10) + poll: Interval between retries (seconds, default 0.2) + max_retries: Max number of retry attempts (default 3) + + Returns: + EventuallyWrapper with async evaluate() method + + Example: + wrapper = with_eventually( + expect(E(role="button")).to_exist(), + timeout=5, + max_retries=10 + ) + result = await wrapper.evaluate(ctx, runtime.snapshot) + """ + config = EventuallyConfig( + timeout=timeout, + poll=poll, + max_retries=max_retries, + ) + return EventuallyWrapper(predicate, config) diff --git a/sentience/asserts/query.py b/sentience/asserts/query.py new file mode 100644 index 0000000..30b9cd1 --- /dev/null +++ b/sentience/asserts/query.py @@ -0,0 +1,383 @@ +""" +Element query builders for assertion DSL. + +This module provides the E() query builder and dominant-group list operations +for creating element queries that compile to existing Predicates. + +Key classes: +- ElementQuery: Pure data object for filtering elements (E()) +- ListQuery: Query over dominant-group elements (in_dominant_list()) +- MultiQuery: Represents multiple elements from ListQuery.top(n) + +All queries work with existing Snapshot fields only: + id, tag, role, text (text_norm), bbox, doc_y, group_key, group_index, + dominant_group_key, in_viewport, is_occluded, href +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..models import Element, Snapshot + + +@dataclass +class ElementQuery: + """ + Pure query object for filtering elements. + + This is the data representation of an E() call. It does not execute + anything - it just stores the filter criteria. + + Example: + E(role="button", text_contains="Save") + E(role="link", href_contains="/cart") + E(in_viewport=True, occluded=False) + """ + + role: str | None = None + name: str | None = None # Alias for text matching (best-effort) + text: str | None = None # Exact match against text + text_contains: str | None = None # Substring match + href_contains: str | None = None # Substring against href + in_viewport: bool | None = None + occluded: bool | None = None + group: str | None = None # Exact match against group_key + in_dominant_group: bool | None = None # True => in dominant group + + # Internal: for ordinal selection from ListQuery + _group_index: int | None = field(default=None, repr=False) + _from_dominant_list: bool = field(default=False, repr=False) + + def matches(self, element: Element, snapshot: Snapshot | None = None) -> bool: + """ + Check if element matches this query criteria. + + Args: + element: Element to check + snapshot: Snapshot (needed for dominant_group_key comparison) + + Returns: + True if element matches all criteria + """ + # Role filter + if self.role is not None: + if element.role != self.role: + return False + + # Text exact match (name is alias for text) + text_to_match = self.text or self.name + if text_to_match is not None: + element_text = element.text or "" + if element_text != text_to_match: + return False + + # Text contains (substring, case-insensitive) + if self.text_contains is not None: + element_text = element.text or "" + if self.text_contains.lower() not in element_text.lower(): + return False + + # Href contains (substring) + if self.href_contains is not None: + element_href = element.href or "" + if self.href_contains.lower() not in element_href.lower(): + return False + + # In viewport filter + if self.in_viewport is not None: + if element.in_viewport != self.in_viewport: + return False + + # Occluded filter + if self.occluded is not None: + if element.is_occluded != self.occluded: + return False + + # Group key exact match + if self.group is not None: + if element.group_key != self.group: + return False + + # In dominant group check + if self.in_dominant_group is not None: + if self.in_dominant_group: + # Element must be in dominant group + if snapshot is None: + return False + if element.group_key != snapshot.dominant_group_key: + return False + else: + # Element must NOT be in dominant group + if snapshot is not None and element.group_key == snapshot.dominant_group_key: + return False + + # Group index filter (from ListQuery.nth()) + if self._group_index is not None: + if element.group_index != self._group_index: + return False + + # Dominant list filter (from in_dominant_list()) + if self._from_dominant_list: + if snapshot is None: + return False + if element.group_key != snapshot.dominant_group_key: + return False + + return True + + def find_all(self, snapshot: Snapshot) -> list[Element]: + """ + Find all elements matching this query in the snapshot. + + Args: + snapshot: Snapshot to search + + Returns: + List of matching elements, sorted by doc_y (top to bottom) + """ + matches = [el for el in snapshot.elements if self.matches(el, snapshot)] + # Sort by doc_y for consistent ordering (top to bottom) + matches.sort(key=lambda el: el.doc_y if el.doc_y is not None else el.bbox.y) + return matches + + def find_first(self, snapshot: Snapshot) -> Element | None: + """ + Find first matching element. + + Args: + snapshot: Snapshot to search + + Returns: + First matching element or None + """ + matches = self.find_all(snapshot) + return matches[0] if matches else None + + +def E( + role: str | None = None, + name: str | None = None, + text: str | None = None, + text_contains: str | None = None, + href_contains: str | None = None, + in_viewport: bool | None = None, + occluded: bool | None = None, + group: str | None = None, + in_dominant_group: bool | None = None, +) -> ElementQuery: + """ + Create an element query. + + This is the main entry point for building element queries. + It returns a pure data object that can be used with expect(). + + Args: + role: ARIA role to match (e.g., "button", "textbox", "link") + name: Text to match exactly (alias for text, best-effort) + text: Exact text match against text_norm + text_contains: Substring match against text_norm (case-insensitive) + href_contains: Substring match against href (case-insensitive) + in_viewport: Filter by viewport visibility + occluded: Filter by occlusion state + group: Exact match against group_key + in_dominant_group: True = must be in dominant group + + Returns: + ElementQuery object + + Example: + E(role="button", text_contains="Save") + E(role="link", href_contains="/checkout") + E(in_viewport=True, occluded=False) + """ + return ElementQuery( + role=role, + name=name, + text=text, + text_contains=text_contains, + href_contains=href_contains, + in_viewport=in_viewport, + occluded=occluded, + group=group, + in_dominant_group=in_dominant_group, + ) + + +# Convenience factory methods on E +class _EFactory: + """Factory class providing convenience methods for common queries.""" + + def __call__( + self, + role: str | None = None, + name: str | None = None, + text: str | None = None, + text_contains: str | None = None, + href_contains: str | None = None, + in_viewport: bool | None = None, + occluded: bool | None = None, + group: str | None = None, + in_dominant_group: bool | None = None, + ) -> ElementQuery: + """Create an element query.""" + return E( + role=role, + name=name, + text=text, + text_contains=text_contains, + href_contains=href_contains, + in_viewport=in_viewport, + occluded=occluded, + group=group, + in_dominant_group=in_dominant_group, + ) + + def submit(self) -> ElementQuery: + """ + Query for submit-like buttons. + + Matches buttons with text like "Submit", "Save", "Continue", etc. + """ + # This is a heuristic query - matches common submit button patterns + return ElementQuery(role="button", text_contains="submit") + + def search_box(self) -> ElementQuery: + """ + Query for search input boxes. + + Matches textbox/combobox with search-related names. + """ + return ElementQuery(role="textbox", name="search") + + def link(self, text_contains: str | None = None) -> ElementQuery: + """ + Query for links with optional text filter. + + Args: + text_contains: Optional text substring to match + """ + return ElementQuery(role="link", text_contains=text_contains) + + +@dataclass +class MultiQuery: + """ + Represents multiple elements from a dominant list query. + + Created by ListQuery.top(n) to represent the first n elements + in a dominant group. + + Example: + in_dominant_list().top(5) # First 5 items in dominant group + """ + + limit: int + _parent_list_query: ListQuery | None = field(default=None, repr=False) + + def any_text_contains(self, text: str) -> _MultiTextPredicate: + """ + Create a predicate that checks if any element's text contains the substring. + + Args: + text: Substring to search for + + Returns: + Predicate that can be used with expect() + """ + return _MultiTextPredicate( + multi_query=self, + text=text, + check_type="any_contains", + ) + + +@dataclass +class _MultiTextPredicate: + """ + Internal predicate for MultiQuery text checks. + + Used by expect() to evaluate multi-element text assertions. + """ + + multi_query: MultiQuery + text: str + check_type: str # "any_contains", etc. + + +@dataclass +class ListQuery: + """ + Query over elements in the dominant group. + + Provides ordinal access to dominant-group elements via .nth(k) + and range access via .top(n). + + Created by in_dominant_list(). + + Example: + in_dominant_list().nth(0) # First item in dominant group + in_dominant_list().top(5) # First 5 items + """ + + def nth(self, index: int) -> ElementQuery: + """ + Select element at specific index in the dominant group. + + Args: + index: 0-based index in the dominant group + + Returns: + ElementQuery targeting the element at that position + + Example: + in_dominant_list().nth(0) # First item + in_dominant_list().nth(2) # Third item + """ + query = ElementQuery() + query._group_index = index + query._from_dominant_list = True + return query + + def top(self, n: int) -> MultiQuery: + """ + Select the first n elements in the dominant group. + + Args: + n: Number of elements to select + + Returns: + MultiQuery representing the first n elements + + Example: + in_dominant_list().top(5) # First 5 items + """ + return MultiQuery(limit=n, _parent_list_query=self) + + +def in_dominant_list() -> ListQuery: + """ + Create a query over elements in the dominant group. + + The dominant group is the most common group_key in the snapshot, + typically representing the main content list (search results, + news feed items, product listings, etc.). + + Returns: + ListQuery for chaining .nth(k) or .top(n) + + Example: + in_dominant_list().nth(0) # First item in dominant group + in_dominant_list().top(5) # First 5 items + + # With expect(): + expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN") + """ + return ListQuery() + + +# Export the factory as E for the Playwright-like API +# Users can do: from sentience.asserts import E +# And use: E(role="button"), E.submit(), E.link(text_contains="...") diff --git a/sentience/backends/__init__.py b/sentience/backends/__init__.py index 3b4f1a7..daf2495 100644 --- a/sentience/backends/__init__.py +++ b/sentience/backends/__init__.py @@ -96,13 +96,13 @@ SnapshotError, ) from .playwright_backend import PlaywrightBackend -from .protocol_v0 import BrowserBackendV0, LayoutMetrics, ViewportInfo +from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo from .sentience_context import SentienceContext, SentienceContextState, TopElementSelector from .snapshot import CachedSnapshot, snapshot __all__ = [ # Protocol - "BrowserBackendV0", + "BrowserBackend", # Models "ViewportInfo", "LayoutMetrics", diff --git a/sentience/backends/actions.py b/sentience/backends/actions.py index 67ec479..7e48b1f 100644 --- a/sentience/backends/actions.py +++ b/sentience/backends/actions.py @@ -1,7 +1,7 @@ """ Backend-agnostic actions for browser-use integration. -These actions work with any BrowserBackendV0 implementation, +These actions work with any BrowserBackend implementation, enabling Sentience grounding with browser-use or other frameworks. Usage with browser-use: @@ -24,11 +24,11 @@ from ..models import ActionResult, BBox, Snapshot if TYPE_CHECKING: - from .protocol_v0 import BrowserBackendV0 + from .protocol import BrowserBackend async def click( - backend: "BrowserBackendV0", + backend: "BrowserBackend", target: BBox | dict[str, float] | tuple[float, float], button: Literal["left", "right", "middle"] = "left", click_count: int = 1, @@ -38,7 +38,7 @@ async def click( Click at coordinates using the backend. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation target: Click target - BBox (clicks center), dict with x/y, or (x, y) tuple button: Mouse button to click click_count: Number of clicks (1=single, 2=double) @@ -88,7 +88,7 @@ async def click( async def type_text( - backend: "BrowserBackendV0", + backend: "BrowserBackend", text: str, target: BBox | dict[str, float] | tuple[float, float] | None = None, clear_first: bool = False, @@ -97,7 +97,7 @@ async def type_text( Type text, optionally clicking a target first. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation text: Text to type target: Optional click target before typing (BBox, dict, or tuple) clear_first: If True, select all and delete before typing @@ -150,7 +150,7 @@ async def type_text( async def scroll( - backend: "BrowserBackendV0", + backend: "BrowserBackend", delta_y: float = 300, target: BBox | dict[str, float] | tuple[float, float] | None = None, ) -> ActionResult: @@ -158,7 +158,7 @@ async def scroll( Scroll the page or element. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation delta_y: Scroll amount (positive=down, negative=up) target: Optional position for scroll (defaults to viewport center) @@ -206,7 +206,7 @@ async def scroll( async def scroll_to_element( - backend: "BrowserBackendV0", + backend: "BrowserBackend", element_id: int, behavior: Literal["smooth", "instant", "auto"] = "instant", block: Literal["start", "center", "end", "nearest"] = "center", @@ -215,7 +215,7 @@ async def scroll_to_element( Scroll element into view using JavaScript scrollIntoView. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation element_id: Element ID from snapshot (requires sentience_registry) behavior: Scroll behavior block: Vertical alignment @@ -273,7 +273,7 @@ async def scroll_to_element( async def wait_for_stable( - backend: "BrowserBackendV0", + backend: "BrowserBackend", state: Literal["interactive", "complete"] = "complete", timeout_ms: int = 10000, ) -> ActionResult: @@ -281,7 +281,7 @@ async def wait_for_stable( Wait for page to reach stable state. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation state: Target document.readyState timeout_ms: Maximum wait time diff --git a/sentience/backends/cdp_backend.py b/sentience/backends/cdp_backend.py index 4d9d7ba..939b427 100644 --- a/sentience/backends/cdp_backend.py +++ b/sentience/backends/cdp_backend.py @@ -1,7 +1,7 @@ """ CDP Backend implementation for browser-use integration. -This module provides CDPBackendV0, which implements BrowserBackendV0 protocol +This module provides CDPBackendV0, which implements BrowserBackend protocol using Chrome DevTools Protocol (CDP) commands. Usage with browser-use: @@ -25,7 +25,7 @@ import time from typing import Any, Literal, Protocol, runtime_checkable -from .protocol_v0 import BrowserBackendV0, LayoutMetrics, ViewportInfo +from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo @runtime_checkable @@ -53,7 +53,7 @@ async def send(self, method: str, params: dict | None = None) -> dict: class CDPBackendV0: """ - CDP-based implementation of BrowserBackendV0. + CDP-based implementation of BrowserBackend. This backend uses CDP commands to interact with the browser, making it compatible with browser-use's CDP client. diff --git a/sentience/backends/playwright_backend.py b/sentience/backends/playwright_backend.py index e589c93..c491f97 100644 --- a/sentience/backends/playwright_backend.py +++ b/sentience/backends/playwright_backend.py @@ -1,5 +1,5 @@ """ -Playwright backend implementation for BrowserBackendV0 protocol. +Playwright backend implementation for BrowserBackend protocol. This wraps existing SentienceBrowser/AsyncSentienceBrowser to provide a unified interface, enabling code that works with both browser-use @@ -26,7 +26,7 @@ import time from typing import TYPE_CHECKING, Any, Literal -from .protocol_v0 import BrowserBackendV0, LayoutMetrics, ViewportInfo +from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo if TYPE_CHECKING: from playwright.async_api import Page as AsyncPage @@ -34,7 +34,7 @@ class PlaywrightBackend: """ - Playwright-based implementation of BrowserBackendV0. + Playwright-based implementation of BrowserBackend. Wraps a Playwright async Page to provide the standard backend interface. This enables using backend-agnostic actions with existing SentienceBrowser code. @@ -191,4 +191,4 @@ async def get_url(self) -> str: # Verify protocol compliance at import time -assert isinstance(PlaywrightBackend.__new__(PlaywrightBackend), BrowserBackendV0) +assert isinstance(PlaywrightBackend.__new__(PlaywrightBackend), BrowserBackend) diff --git a/sentience/backends/protocol_v0.py b/sentience/backends/protocol.py similarity index 99% rename from sentience/backends/protocol_v0.py rename to sentience/backends/protocol.py index 763647d..32e4a3b 100644 --- a/sentience/backends/protocol_v0.py +++ b/sentience/backends/protocol.py @@ -46,7 +46,7 @@ class LayoutMetrics(BaseModel): @runtime_checkable -class BrowserBackendV0(Protocol): +class BrowserBackend(Protocol): """ Minimal backend protocol for v0 proof-of-concept. diff --git a/sentience/backends/snapshot.py b/sentience/backends/snapshot.py index 10f4bac..18036fe 100644 --- a/sentience/backends/snapshot.py +++ b/sentience/backends/snapshot.py @@ -1,7 +1,7 @@ """ Backend-agnostic snapshot for browser-use integration. -Takes Sentience snapshots using BrowserBackendV0 protocol, +Takes Sentience snapshots using BrowserBackend protocol, enabling element grounding with browser-use or other frameworks. Usage with browser-use: @@ -34,7 +34,7 @@ from .exceptions import ExtensionDiagnostics, ExtensionNotLoadedError, SnapshotError if TYPE_CHECKING: - from .protocol_v0 import BrowserBackendV0 + from .protocol import BrowserBackend class CachedSnapshot: @@ -63,7 +63,7 @@ class CachedSnapshot: def __init__( self, - backend: "BrowserBackendV0", + backend: "BrowserBackend", max_age_ms: int = 2000, options: SnapshotOptions | None = None, ) -> None: @@ -71,7 +71,7 @@ def __init__( Initialize cached snapshot. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation max_age_ms: Maximum cache age in milliseconds (default: 2000) options: Default snapshot options """ @@ -145,7 +145,7 @@ def age_ms(self) -> float: async def snapshot( - backend: "BrowserBackendV0", + backend: "BrowserBackend", options: SnapshotOptions | None = None, ) -> Snapshot: """ @@ -160,7 +160,7 @@ async def snapshot( - Extension injected window.sentience API Args: - backend: BrowserBackendV0 implementation (CDPBackendV0, PlaywrightBackend, etc.) + backend: BrowserBackend implementation (CDPBackendV0, PlaywrightBackend, etc.) options: Snapshot options (limit, filter, screenshot, use_api, sentience_api_key, etc.) Returns: @@ -208,14 +208,14 @@ async def snapshot( async def _wait_for_extension( - backend: "BrowserBackendV0", + backend: "BrowserBackend", timeout_ms: int = 5000, ) -> None: """ Wait for Sentience extension to inject window.sentience API. Args: - backend: BrowserBackendV0 implementation + backend: BrowserBackend implementation timeout_ms: Maximum wait time Raises: @@ -278,7 +278,7 @@ async def _wait_for_extension( async def _snapshot_via_extension( - backend: "BrowserBackendV0", + backend: "BrowserBackend", options: SnapshotOptions, ) -> Snapshot: """Take snapshot using local extension (Free tier)""" @@ -325,7 +325,7 @@ async def _snapshot_via_extension( async def _snapshot_via_api( - backend: "BrowserBackendV0", + backend: "BrowserBackend", options: SnapshotOptions, ) -> Snapshot: """Take snapshot using server-side API (Pro/Enterprise tier)""" diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index d708c84..48811be 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -2,7 +2,7 @@ Tests for AgentRuntime. These tests verify the AgentRuntime works correctly with the new -BrowserBackendV0-based architecture. +BrowserBackend-based architecture. """ from unittest.mock import AsyncMock, MagicMock, patch @@ -15,7 +15,7 @@ class MockBackend: - """Mock BrowserBackendV0 implementation for testing.""" + """Mock BrowserBackend implementation for testing.""" def __init__(self) -> None: self._url = "https://example.com" diff --git a/tests/test_asserts.py b/tests/test_asserts.py new file mode 100644 index 0000000..e909001 --- /dev/null +++ b/tests/test_asserts.py @@ -0,0 +1,537 @@ +""" +Tests for assertion DSL module (sentience.asserts). + +Tests the E() query builder, expect() fluent API, and in_dominant_list() operations. +""" + +import pytest + +from sentience.asserts import ( + E, + ElementQuery, + EventuallyConfig, + EventuallyWrapper, + ListQuery, + MultiQuery, + expect, + in_dominant_list, + with_eventually, +) +from sentience.models import BBox, Element, Snapshot, Viewport, VisualCues +from sentience.verification import AssertContext + + +def make_element( + id: int, + role: str = "button", + text: str | None = None, + importance: int = 100, + in_viewport: bool = True, + is_occluded: bool = False, + group_key: str | None = None, + group_index: int | None = None, + href: str | None = None, + doc_y: float | None = None, +) -> Element: + """Helper to create test elements.""" + return Element( + id=id, + role=role, + text=text, + importance=importance, + bbox=BBox(x=0, y=doc_y or 0, width=100, height=50), + visual_cues=VisualCues(is_primary=False, is_clickable=True, background_color_name=None), + in_viewport=in_viewport, + is_occluded=is_occluded, + group_key=group_key, + group_index=group_index, + href=href, + doc_y=doc_y, + ) + + +def make_snapshot( + elements: list[Element], + url: str = "https://example.com", + dominant_group_key: str | None = None, +) -> Snapshot: + """Helper to create test snapshots.""" + return Snapshot( + status="success", + url=url, + elements=elements, + viewport=Viewport(width=1920, height=1080), + dominant_group_key=dominant_group_key, + ) + + +class TestElementQuery: + """Tests for E() query builder.""" + + def test_create_basic_query(self): + """E() creates ElementQuery with specified fields.""" + q = E(role="button", text_contains="Save") + assert q.role == "button" + assert q.text_contains == "Save" + assert q.name is None + assert q.in_viewport is None + + def test_create_with_all_fields(self): + """E() accepts all documented fields.""" + q = E( + role="link", + name="Home", + text="Home Page", + text_contains="Home", + href_contains="/home", + in_viewport=True, + occluded=False, + group="nav", + in_dominant_group=True, + ) + assert q.role == "link" + assert q.name == "Home" + assert q.text == "Home Page" + assert q.text_contains == "Home" + assert q.href_contains == "/home" + assert q.in_viewport is True + assert q.occluded is False + assert q.group == "nav" + assert q.in_dominant_group is True + + def test_matches_role(self): + """ElementQuery.matches() filters by role.""" + el = make_element(1, role="button", text="Click") + q = E(role="button") + assert q.matches(el) is True + + q2 = E(role="link") + assert q2.matches(el) is False + + def test_matches_text_exact(self): + """ElementQuery.matches() filters by exact text.""" + el = make_element(1, text="Save") + q = E(text="Save") + assert q.matches(el) is True + + q2 = E(text="Save Changes") + assert q2.matches(el) is False + + def test_matches_text_contains(self): + """ElementQuery.matches() filters by text substring (case-insensitive).""" + el = make_element(1, text="Save Changes Now") + q = E(text_contains="changes") + assert q.matches(el) is True + + q2 = E(text_contains="delete") + assert q2.matches(el) is False + + def test_matches_href_contains(self): + """ElementQuery.matches() filters by href substring.""" + el = make_element(1, role="link", href="https://example.com/cart/checkout") + q = E(href_contains="/cart") + assert q.matches(el) is True + + q2 = E(href_contains="/orders") + assert q2.matches(el) is False + + def test_matches_in_viewport(self): + """ElementQuery.matches() filters by viewport visibility.""" + el_visible = make_element(1, in_viewport=True) + el_hidden = make_element(2, in_viewport=False) + + q = E(in_viewport=True) + assert q.matches(el_visible) is True + assert q.matches(el_hidden) is False + + def test_matches_occluded(self): + """ElementQuery.matches() filters by occlusion state.""" + el_clear = make_element(1, is_occluded=False) + el_occluded = make_element(2, is_occluded=True) + + q = E(occluded=False) + assert q.matches(el_clear) is True + assert q.matches(el_occluded) is False + + def test_matches_group_key(self): + """ElementQuery.matches() filters by exact group_key.""" + el = make_element(1, group_key="main-list") + q = E(group="main-list") + assert q.matches(el) is True + + q2 = E(group="sidebar") + assert q2.matches(el) is False + + def test_matches_in_dominant_group(self): + """ElementQuery.matches() filters by dominant group membership.""" + el_in_dg = make_element(1, group_key="main-list") + el_not_in_dg = make_element(2, group_key="sidebar") + snap = make_snapshot([el_in_dg, el_not_in_dg], dominant_group_key="main-list") + + q = E(in_dominant_group=True) + assert q.matches(el_in_dg, snap) is True + assert q.matches(el_not_in_dg, snap) is False + + def test_find_all_returns_matching(self): + """ElementQuery.find_all() returns all matching elements.""" + elements = [ + make_element(1, role="button", text="Save"), + make_element(2, role="button", text="Cancel"), + make_element(3, role="link", text="Help"), + ] + snap = make_snapshot(elements) + + q = E(role="button") + matches = q.find_all(snap) + assert len(matches) == 2 + assert all(el.role == "button" for el in matches) + + def test_find_first_returns_first_match(self): + """ElementQuery.find_first() returns first matching element.""" + elements = [ + make_element(1, role="button", text="First", doc_y=100), + make_element(2, role="button", text="Second", doc_y=200), + ] + snap = make_snapshot(elements) + + q = E(role="button") + match = q.find_first(snap) + assert match is not None + assert match.text == "First" + + def test_find_first_returns_none_when_no_match(self): + """ElementQuery.find_first() returns None when no match.""" + elements = [make_element(1, role="button")] + snap = make_snapshot(elements) + + q = E(role="link") + match = q.find_first(snap) + assert match is None + + +class TestListQuery: + """Tests for in_dominant_list() and ListQuery.""" + + def test_in_dominant_list_returns_list_query(self): + """in_dominant_list() returns ListQuery.""" + lq = in_dominant_list() + assert isinstance(lq, ListQuery) + + def test_nth_returns_element_query(self): + """ListQuery.nth() returns ElementQuery with group_index set.""" + lq = in_dominant_list() + eq = lq.nth(2) + assert isinstance(eq, ElementQuery) + assert eq._group_index == 2 + assert eq._from_dominant_list is True + + def test_top_returns_multi_query(self): + """ListQuery.top() returns MultiQuery.""" + lq = in_dominant_list() + mq = lq.top(5) + assert isinstance(mq, MultiQuery) + assert mq.limit == 5 + + def test_nth_matches_by_group_index(self): + """ElementQuery from .nth() matches elements by group_index.""" + elements = [ + make_element(1, text="First", group_key="main", group_index=0), + make_element(2, text="Second", group_key="main", group_index=1), + make_element(3, text="Third", group_key="main", group_index=2), + ] + snap = make_snapshot(elements, dominant_group_key="main") + + # .nth(1) should match element with group_index=1 + q = in_dominant_list().nth(1) + matches = q.find_all(snap) + assert len(matches) == 1 + assert matches[0].text == "Second" + + def test_nth_only_matches_dominant_group(self): + """ElementQuery from .nth() only matches elements in dominant group.""" + elements = [ + make_element(1, text="Main Item", group_key="main", group_index=0), + make_element(2, text="Side Item", group_key="sidebar", group_index=0), + ] + snap = make_snapshot(elements, dominant_group_key="main") + + q = in_dominant_list().nth(0) + matches = q.find_all(snap) + assert len(matches) == 1 + assert matches[0].text == "Main Item" + + +class TestExpectBuilder: + """Tests for expect() builder and matchers.""" + + def test_to_exist_passes_when_element_found(self): + """expect(...).to_exist() passes when element matches.""" + elements = [make_element(1, role="button", text="Save")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(role="button")).to_exist() + outcome = pred(ctx) + assert outcome.passed is True + assert outcome.details["matched"] == 1 + + def test_to_exist_fails_when_element_not_found(self): + """expect(...).to_exist() fails when no element matches.""" + elements = [make_element(1, role="button")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(role="link")).to_exist() + outcome = pred(ctx) + assert outcome.passed is False + assert "no elements matched" in outcome.reason + + def test_to_exist_fails_without_snapshot(self): + """expect(...).to_exist() fails when no snapshot available.""" + ctx = AssertContext(snapshot=None) + + pred = expect(E(role="button")).to_exist() + outcome = pred(ctx) + assert outcome.passed is False + assert "no snapshot available" in outcome.reason + + def test_not_to_exist_passes_when_element_absent(self): + """expect(...).not_to_exist() passes when no element matches.""" + elements = [make_element(1, role="button")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(role="link")).not_to_exist() + outcome = pred(ctx) + assert outcome.passed is True + + def test_not_to_exist_fails_when_element_found(self): + """expect(...).not_to_exist() fails when element matches.""" + elements = [make_element(1, role="button", text="Error")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(text_contains="Error")).not_to_exist() + outcome = pred(ctx) + assert outcome.passed is False + assert "found 1 elements" in outcome.reason + + def test_to_be_visible_passes_when_visible(self): + """expect(...).to_be_visible() passes when element is in_viewport and not occluded.""" + elements = [make_element(1, role="button", in_viewport=True, is_occluded=False)] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(role="button")).to_be_visible() + outcome = pred(ctx) + assert outcome.passed is True + + def test_to_be_visible_fails_when_out_of_viewport(self): + """expect(...).to_be_visible() fails when element is not in viewport.""" + elements = [make_element(1, role="button", in_viewport=False, is_occluded=False)] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(role="button")).to_be_visible() + outcome = pred(ctx) + assert outcome.passed is False + assert "not visible" in outcome.reason + + def test_to_be_visible_fails_when_occluded(self): + """expect(...).to_be_visible() fails when element is occluded.""" + elements = [make_element(1, role="button", in_viewport=True, is_occluded=True)] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E(role="button")).to_be_visible() + outcome = pred(ctx) + assert outcome.passed is False + assert "not visible" in outcome.reason + + def test_to_have_text_contains_passes(self): + """expect(...).to_have_text_contains() passes when text matches.""" + elements = [make_element(1, text="Welcome to our site")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E()).to_have_text_contains("Welcome") + outcome = pred(ctx) + assert outcome.passed is True + + def test_to_have_text_contains_fails(self): + """expect(...).to_have_text_contains() fails when text doesn't match.""" + elements = [make_element(1, text="Hello World")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect(E()).to_have_text_contains("Goodbye") + outcome = pred(ctx) + assert outcome.passed is False + assert "does not contain" in outcome.reason + + +class TestExpectGlobalAssertions: + """Tests for expect.text_present() and expect.no_text().""" + + def test_text_present_passes_when_found(self): + """expect.text_present() passes when text found anywhere on page.""" + elements = [ + make_element(1, text="Header"), + make_element(2, text="Welcome back, user!"), + make_element(3, text="Footer"), + ] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect.text_present("Welcome back") + outcome = pred(ctx) + assert outcome.passed is True + + def test_text_present_fails_when_not_found(self): + """expect.text_present() fails when text not found.""" + elements = [make_element(1, text="Hello World")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect.text_present("Goodbye") + outcome = pred(ctx) + assert outcome.passed is False + assert "not found on page" in outcome.reason + + def test_text_present_case_insensitive(self): + """expect.text_present() is case-insensitive.""" + elements = [make_element(1, text="WELCOME")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect.text_present("welcome") + outcome = pred(ctx) + assert outcome.passed is True + + def test_no_text_passes_when_absent(self): + """expect.no_text() passes when text not found.""" + elements = [make_element(1, text="Success")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect.no_text("Error") + outcome = pred(ctx) + assert outcome.passed is True + + def test_no_text_fails_when_found(self): + """expect.no_text() fails when text found.""" + elements = [make_element(1, text="Error: Something went wrong")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + pred = expect.no_text("Error") + outcome = pred(ctx) + assert outcome.passed is False + assert "text 'Error' found" in outcome.reason + + +class TestWithEventually: + """Tests for with_eventually() wrapper.""" + + def test_config_defaults(self): + """EventuallyConfig has correct defaults.""" + config = EventuallyConfig() + assert config.timeout == 10 + assert config.poll == 0.2 + assert config.max_retries == 3 + + def test_with_eventually_creates_wrapper(self): + """with_eventually() creates EventuallyWrapper.""" + pred = expect(E(role="button")).to_exist() + wrapper = with_eventually(pred, timeout=5, max_retries=2) + assert isinstance(wrapper, EventuallyWrapper) + + def test_with_eventually_custom_config(self): + """with_eventually() accepts custom config values.""" + pred = expect(E(role="button")).to_exist() + wrapper = with_eventually(pred, timeout=30, poll=1.0, max_retries=10) + assert wrapper._config.timeout == 30 + assert wrapper._config.poll == 1.0 + assert wrapper._config.max_retries == 10 + + +class TestDominantListOrdinalAssertions: + """Tests for ordinal assertions on dominant group.""" + + def test_nth_with_to_have_text_contains(self): + """in_dominant_list().nth(k).to_have_text_contains() works.""" + elements = [ + make_element(1, text="Show HN: Cool Project", group_key="feed", group_index=0), + make_element(2, text="Ask HN: Best IDE?", group_key="feed", group_index=1), + make_element(3, text="Regular news story", group_key="feed", group_index=2), + ] + snap = make_snapshot(elements, dominant_group_key="feed") + ctx = AssertContext(snapshot=snap, url=snap.url) + + # First item should contain "Show HN" + pred = expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN") + outcome = pred(ctx) + assert outcome.passed is True + + # Second item should contain "Ask HN" + pred2 = expect(in_dominant_list().nth(1)).to_have_text_contains("Ask HN") + outcome2 = pred2(ctx) + assert outcome2.passed is True + + # First item should NOT contain "Ask HN" + pred3 = expect(in_dominant_list().nth(0)).to_have_text_contains("Ask HN") + outcome3 = pred3(ctx) + assert outcome3.passed is False + + +class TestIntegrationWithAgentRuntime: + """Tests verifying DSL integrates with AgentRuntime.assert_().""" + + def test_predicate_callable_with_context(self): + """DSL predicates are callable with AssertContext.""" + elements = [make_element(1, role="button", text="Submit")] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + # All these should be valid predicates + preds = [ + expect(E(role="button")).to_exist(), + expect(E(role="link")).not_to_exist(), + expect(E(role="button")).to_be_visible(), + expect(E()).to_have_text_contains("Submit"), + expect.text_present("Submit"), + expect.no_text("Error"), + ] + + for pred in preds: + # Should be callable + assert callable(pred) + # Should return AssertOutcome when called with context + outcome = pred(ctx) + assert hasattr(outcome, "passed") + assert hasattr(outcome, "reason") + assert hasattr(outcome, "details") + + def test_complex_query_combinations(self): + """Complex queries work correctly.""" + elements = [ + make_element(1, role="button", text="Save", in_viewport=True, is_occluded=False), + make_element(2, role="button", text="Cancel", in_viewport=True, is_occluded=True), + make_element(3, role="link", text="Help", href="/help", in_viewport=False), + ] + snap = make_snapshot(elements) + ctx = AssertContext(snapshot=snap, url=snap.url) + + # Visible button with text "Save" + pred = expect( + E(role="button", text_contains="Save", in_viewport=True, occluded=False) + ).to_exist() + assert pred(ctx).passed is True + + # Visible button with text "Cancel" - fails because it's occluded + pred2 = expect(E(role="button", text_contains="Cancel", occluded=False)).to_exist() + assert pred2(ctx).passed is False + + # Link to help page + pred3 = expect(E(role="link", href_contains="/help")).to_exist() + assert pred3(ctx).passed is True diff --git a/tests/test_backends.py b/tests/test_backends.py index ef725e3..a9b7d85 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -13,7 +13,7 @@ import pytest from sentience.backends import ( - BrowserBackendV0, + BrowserBackend, BrowserUseAdapter, BrowserUseCDPTransport, CachedSnapshot, @@ -367,13 +367,13 @@ async def test_get_url_empty(self, backend: CDPBackendV0, transport: MockCDPTran class TestCDPBackendProtocol: - """Test that CDPBackendV0 implements BrowserBackendV0 protocol.""" + """Test that CDPBackendV0 implements BrowserBackend protocol.""" def test_implements_protocol(self) -> None: - """Verify CDPBackendV0 is recognized as BrowserBackendV0.""" + """Verify CDPBackendV0 is recognized as BrowserBackend.""" transport = MockCDPTransport() backend = CDPBackendV0(transport) - assert isinstance(backend, BrowserBackendV0) + assert isinstance(backend, BrowserBackend) class TestBrowserUseCDPTransport: @@ -680,10 +680,10 @@ class TestPlaywrightBackend: """Tests for PlaywrightBackend wrapper.""" def test_implements_protocol(self) -> None: - """Verify PlaywrightBackend implements BrowserBackendV0.""" + """Verify PlaywrightBackend implements BrowserBackend.""" mock_page = MagicMock() backend = PlaywrightBackend(mock_page) - assert isinstance(backend, BrowserBackendV0) + assert isinstance(backend, BrowserBackend) def test_page_property(self) -> None: """Test page property returns underlying page.""" From c7a43a95c1f1d7c1e2642cd02a19bb303ef58de0 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 16:43:05 -0800 Subject: [PATCH 3/7] fix assert_ --- sentience/agent_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentience/agent_runtime.py b/sentience/agent_runtime.py index ae1c437..714e4f4 100644 --- a/sentience/agent_runtime.py +++ b/sentience/agent_runtime.py @@ -342,7 +342,7 @@ def assert_done( Returns: True if task is complete (assertion passed), False otherwise """ - ok = self.assertTrue(predicate, label=label, required=True) + ok = self.assert_(predicate, label=label, required=True) if ok: self._task_done = True From 59fd7700f9fc293964553d7b0f40ef66dda3614f Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 20:09:20 -0800 Subject: [PATCH 4/7] Add debug verification step to CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --no-cache-dir to pip install to avoid cache issues - Add verification step to print agent_runtime.py lines 340-350 - Verify assert_ method exists and assertTrue doesn't exist - This helps debug why CI sees assertTrue when code shows assert_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3ccdbdf..52c9c05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,9 +31,18 @@ jobs: - name: Install dependencies run: | - pip install -e ".[dev]" + pip install --no-cache-dir -e ".[dev]" pip install pre-commit mypy types-requests + - name: Verify source code + run: | + echo "=== Checking agent_runtime.py line 345 ===" + sed -n '340,350p' sentience/agent_runtime.py + echo "=== Verifying assert_ method exists ===" + grep -n "def assert_" sentience/agent_runtime.py + echo "=== Checking for assertTrue (should NOT exist) ===" + grep -n "assertTrue" sentience/agent_runtime.py || echo "Good: no assertTrue found" + - name: Lint with pre-commit continue-on-error: true run: | From b9c3bc1a5084702ec9feea01107993e799258b44 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 20:20:12 -0800 Subject: [PATCH 5/7] Fix: grep exit code in verify source code step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous || pattern didn't properly handle grep's exit code. Using if/else ensures correct exit behavior: - Exit 0 when assertTrue is NOT found (correct) - Exit 1 when assertTrue IS found (error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52c9c05..f5fe740 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,12 @@ jobs: echo "=== Verifying assert_ method exists ===" grep -n "def assert_" sentience/agent_runtime.py echo "=== Checking for assertTrue (should NOT exist) ===" - grep -n "assertTrue" sentience/agent_runtime.py || echo "Good: no assertTrue found" + if grep -n "assertTrue" sentience/agent_runtime.py; then + echo "ERROR: Found assertTrue - this should have been removed!" + exit 1 + else + echo "Good: no assertTrue found" + fi - name: Lint with pre-commit continue-on-error: true From 6f00a001cced7a82116ebcf9aa2899e83b197b00 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 20:30:17 -0800 Subject: [PATCH 6/7] Fix: Add shell: bash for Windows compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Verify source code step uses Bash syntax (if/then/else) which doesn't work with PowerShell on Windows runners. Adding shell: bash ensures Bash is used on all platforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5fe740..78419c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: pip install pre-commit mypy types-requests - name: Verify source code + shell: bash run: | echo "=== Checking agent_runtime.py line 345 ===" sed -n '340,350p' sentience/agent_runtime.py From 38d4913528c5625afcbbfe9a0122c165c660879a Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sun, 11 Jan 2026 21:02:33 -0800 Subject: [PATCH 7/7] Add verification of installed package in CI --- .github/workflows/test.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78419c2..3e948ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,24 @@ jobs: pip install --no-cache-dir -e ".[dev]" pip install pre-commit mypy types-requests + - name: Verify installed package + shell: bash + run: | + echo "=== Installed sentience location ===" + python -c "import sentience; print(sentience.__file__)" + echo "=== Check assert_done in installed package ===" + python -c " +import inspect +from sentience.agent_runtime import AgentRuntime +source = inspect.getsource(AgentRuntime.assert_done) +print(source) +if 'assertTrue' in source: + print('ERROR: assertTrue found in installed package!') + exit(1) +else: + print('Good: assert_ is correctly used') +" + - name: Verify source code shell: bash run: |