Skip to content

Commit 3bc278c

Browse files
authored
Merge pull request #106 from SentienceAPI/hardening5
Phase 5: BrowserProtocol & PageProtocol for increasing test coverage
2 parents 5eb38cc + 43cfc23 commit 3bc278c

File tree

8 files changed

+826
-23
lines changed

8 files changed

+826
-23
lines changed

sentience/action_executor.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
"""
77

88
import re
9-
from typing import Any
9+
from typing import Any, Union
1010

1111
from .actions import click, click_async, press, press_async, type_text, type_text_async
1212
from .browser import AsyncSentienceBrowser, SentienceBrowser
1313
from .models import Snapshot
14+
from .protocols import AsyncBrowserProtocol, BrowserProtocol
1415

1516

1617
class ActionExecutor:
@@ -23,15 +24,38 @@ class ActionExecutor:
2324
- Handle action parsing errors consistently
2425
"""
2526

26-
def __init__(self, browser: SentienceBrowser | AsyncSentienceBrowser):
27+
def __init__(
28+
self,
29+
browser: SentienceBrowser | AsyncSentienceBrowser | BrowserProtocol | AsyncBrowserProtocol,
30+
):
2731
"""
2832
Initialize action executor.
2933
3034
Args:
31-
browser: SentienceBrowser or AsyncSentienceBrowser instance
35+
browser: SentienceBrowser, AsyncSentienceBrowser, or protocol-compatible instance
36+
(for testing, can use mock objects that implement BrowserProtocol)
3237
"""
3338
self.browser = browser
34-
self._is_async = isinstance(browser, AsyncSentienceBrowser)
39+
# Check if browser is async - support both concrete types and protocols
40+
# Check concrete types first (most reliable)
41+
if isinstance(browser, AsyncSentienceBrowser):
42+
self._is_async = True
43+
elif isinstance(browser, SentienceBrowser):
44+
self._is_async = False
45+
else:
46+
# For protocol-based browsers, check if methods are actually async
47+
# This is more reliable than isinstance checks which can match both protocols
48+
import inspect
49+
50+
start_method = getattr(browser, "start", None)
51+
if start_method and inspect.iscoroutinefunction(start_method):
52+
self._is_async = True
53+
elif isinstance(browser, BrowserProtocol):
54+
# If it implements BrowserProtocol and start is not async, it's sync
55+
self._is_async = False
56+
else:
57+
# Default to sync for unknown types
58+
self._is_async = False
3559

3660
def execute(self, action_str: str, snap: Snapshot) -> dict[str, Any]:
3761
"""

sentience/agent.py

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import asyncio
77
import hashlib
88
import time
9-
from typing import TYPE_CHECKING, Any, Optional
9+
from typing import TYPE_CHECKING, Any, Optional, Union
1010

1111
from .action_executor import ActionExecutor
1212
from .agent_config import AgentConfig
@@ -25,13 +25,45 @@
2525
SnapshotOptions,
2626
TokenStats,
2727
)
28+
from .protocols import AsyncBrowserProtocol, BrowserProtocol
2829
from .snapshot import snapshot, snapshot_async
2930
from .trace_event_builder import TraceEventBuilder
3031

3132
if TYPE_CHECKING:
3233
from .tracing import Tracer
3334

3435

36+
def _safe_tracer_call(
37+
tracer: Optional["Tracer"], method_name: str, verbose: bool, *args, **kwargs
38+
) -> None:
39+
"""
40+
Safely call tracer method, catching and logging errors without breaking execution.
41+
42+
Args:
43+
tracer: Tracer instance or None
44+
method_name: Name of tracer method to call (e.g., "emit", "emit_error")
45+
verbose: Whether to print error messages
46+
*args: Positional arguments for the tracer method
47+
**kwargs: Keyword arguments for the tracer method
48+
"""
49+
if not tracer:
50+
return
51+
try:
52+
method = getattr(tracer, method_name)
53+
if args and kwargs:
54+
method(*args, **kwargs)
55+
elif args:
56+
method(*args)
57+
elif kwargs:
58+
method(**kwargs)
59+
else:
60+
method()
61+
except Exception as tracer_error:
62+
# Tracer errors should not break agent execution
63+
if verbose:
64+
print(f"⚠️ Tracer error (non-fatal): {tracer_error}")
65+
66+
3567
class SentienceAgent(BaseAgent):
3668
"""
3769
High-level agent that combines Sentience SDK with any LLM provider.
@@ -58,7 +90,7 @@ class SentienceAgent(BaseAgent):
5890

5991
def __init__(
6092
self,
61-
browser: SentienceBrowser,
93+
browser: SentienceBrowser | BrowserProtocol,
6294
llm: LLMProvider,
6395
default_snapshot_limit: int = 50,
6496
verbose: bool = True,
@@ -69,7 +101,8 @@ def __init__(
69101
Initialize Sentience Agent
70102
71103
Args:
72-
browser: SentienceBrowser instance
104+
browser: SentienceBrowser instance or BrowserProtocol-compatible object
105+
(for testing, can use mock objects that implement BrowserProtocol)
73106
llm: LLM provider (OpenAIProvider, AnthropicProvider, etc.)
74107
default_snapshot_limit: Default maximum elements to include in context (default: 50)
75108
verbose: Print execution logs (default: True)
@@ -157,7 +190,10 @@ def act( # noqa: C901
157190
# Emit step_start trace event if tracer is enabled
158191
if self.tracer:
159192
pre_url = self.browser.page.url if self.browser.page else None
160-
self.tracer.emit_step_start(
193+
_safe_tracer_call(
194+
self.tracer,
195+
"emit_step_start",
196+
self.verbose,
161197
step_id=step_id,
162198
step_index=self._step_count,
163199
goal=goal,
@@ -226,7 +262,10 @@ def act( # noqa: C901
226262
if snap.screenshot_format:
227263
snapshot_data["screenshot_format"] = snap.screenshot_format
228264

229-
self.tracer.emit(
265+
_safe_tracer_call(
266+
self.tracer,
267+
"emit",
268+
self.verbose,
230269
"snapshot",
231270
snapshot_data,
232271
step_id=step_id,
@@ -252,7 +291,10 @@ def act( # noqa: C901
252291

253292
# Emit LLM query trace event if tracer is enabled
254293
if self.tracer:
255-
self.tracer.emit(
294+
_safe_tracer_call(
295+
self.tracer,
296+
"emit",
297+
self.verbose,
256298
"llm_query",
257299
{
258300
"prompt_tokens": llm_response.prompt_tokens,
@@ -313,7 +355,10 @@ def act( # noqa: C901
313355
for el in filtered_snap.elements[:50]
314356
]
315357

316-
self.tracer.emit(
358+
_safe_tracer_call(
359+
self.tracer,
360+
"emit",
361+
self.verbose,
317362
"action",
318363
{
319364
"action": result.action,
@@ -433,14 +478,28 @@ def act( # noqa: C901
433478
verify_data=verify_data,
434479
)
435480

436-
self.tracer.emit("step_end", step_end_data, step_id=step_id)
481+
_safe_tracer_call(
482+
self.tracer,
483+
"emit",
484+
self.verbose,
485+
"step_end",
486+
step_end_data,
487+
step_id=step_id,
488+
)
437489

438490
return result
439491

440492
except Exception as e:
441493
# Emit error trace event if tracer is enabled
442494
if self.tracer:
443-
self.tracer.emit_error(step_id=step_id, error=str(e), attempt=attempt)
495+
_safe_tracer_call(
496+
self.tracer,
497+
"emit_error",
498+
self.verbose,
499+
step_id=step_id,
500+
error=str(e),
501+
attempt=attempt,
502+
)
444503

445504
if attempt < max_retries:
446505
if self.verbose:
@@ -666,7 +725,10 @@ async def act( # noqa: C901
666725
# Emit step_start trace event if tracer is enabled
667726
if self.tracer:
668727
pre_url = self.browser.page.url if self.browser.page else None
669-
self.tracer.emit_step_start(
728+
_safe_tracer_call(
729+
self.tracer,
730+
"emit_step_start",
731+
self.verbose,
670732
step_id=step_id,
671733
step_index=self._step_count,
672734
goal=goal,
@@ -738,7 +800,10 @@ async def act( # noqa: C901
738800
if snap.screenshot_format:
739801
snapshot_data["screenshot_format"] = snap.screenshot_format
740802

741-
self.tracer.emit(
803+
_safe_tracer_call(
804+
self.tracer,
805+
"emit",
806+
self.verbose,
742807
"snapshot",
743808
snapshot_data,
744809
step_id=step_id,
@@ -764,7 +829,10 @@ async def act( # noqa: C901
764829

765830
# Emit LLM query trace event if tracer is enabled
766831
if self.tracer:
767-
self.tracer.emit(
832+
_safe_tracer_call(
833+
self.tracer,
834+
"emit",
835+
self.verbose,
768836
"llm_query",
769837
{
770838
"prompt_tokens": llm_response.prompt_tokens,
@@ -825,7 +893,10 @@ async def act( # noqa: C901
825893
for el in filtered_snap.elements[:50]
826894
]
827895

828-
self.tracer.emit(
896+
_safe_tracer_call(
897+
self.tracer,
898+
"emit",
899+
self.verbose,
829900
"action",
830901
{
831902
"action": result.action,
@@ -945,14 +1016,28 @@ async def act( # noqa: C901
9451016
verify_data=verify_data,
9461017
)
9471018

948-
self.tracer.emit("step_end", step_end_data, step_id=step_id)
1019+
_safe_tracer_call(
1020+
self.tracer,
1021+
"emit",
1022+
self.verbose,
1023+
"step_end",
1024+
step_end_data,
1025+
step_id=step_id,
1026+
)
9491027

9501028
return result
9511029

9521030
except Exception as e:
9531031
# Emit error trace event if tracer is enabled
9541032
if self.tracer:
955-
self.tracer.emit_error(step_id=step_id, error=str(e), attempt=attempt)
1033+
_safe_tracer_call(
1034+
self.tracer,
1035+
"emit_error",
1036+
self.verbose,
1037+
step_id=step_id,
1038+
error=str(e),
1039+
attempt=attempt,
1040+
)
9561041

9571042
if attempt < max_retries:
9581043
if self.verbose:

sentience/conversational_agent.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55

66
import json
77
import time
8-
from typing import Any
8+
from typing import Any, Union
99

1010
from .agent import SentienceAgent
1111
from .browser import SentienceBrowser
1212
from .llm_provider import LLMProvider
1313
from .models import ExtractionResult, Snapshot, SnapshotOptions, StepExecutionResult
14+
from .protocols import BrowserProtocol
1415
from .snapshot import snapshot
1516

1617

@@ -29,12 +30,18 @@ class ConversationalAgent:
2930
The top result is from amazon.com selling the Apple Magic Mouse 2 for $79."
3031
"""
3132

32-
def __init__(self, browser: SentienceBrowser, llm: LLMProvider, verbose: bool = True):
33+
def __init__(
34+
self,
35+
browser: SentienceBrowser | BrowserProtocol,
36+
llm: LLMProvider,
37+
verbose: bool = True,
38+
):
3339
"""
3440
Initialize conversational agent
3541
3642
Args:
37-
browser: SentienceBrowser instance
43+
browser: SentienceBrowser instance or BrowserProtocol-compatible object
44+
(for testing, can use mock objects that implement BrowserProtocol)
3845
llm: LLM provider (OpenAI, Anthropic, LocalLLM, etc.)
3946
verbose: Print step-by-step execution logs (default: True)
4047
"""

sentience/element_filter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def filter_by_importance(
6565
def filter_by_goal(
6666
snapshot: Snapshot,
6767
goal: str | None,
68-
max_elements: int = 50,
68+
max_elements: int = 100,
6969
) -> list[Element]:
7070
"""
7171
Filter elements from snapshot based on goal context.

0 commit comments

Comments
 (0)