diff --git a/.gitignore b/.gitignore index 0b567b9..0a7be9b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ Thumbs.db # Runtime traces and artifacts traces/ +*.jsonl artifacts/ tmp/ temp/ diff --git a/src/predicate_secure/__init__.py b/src/predicate_secure/__init__.py index 0c81e6a..086118d 100644 --- a/src/predicate_secure/__init__.py +++ b/src/predicate_secure/__init__.py @@ -24,13 +24,18 @@ from pathlib import Path from typing import Any -from .config import SecureAgentConfig, WrappedAgent -from .detection import ( - DetectionResult, - Framework, - FrameworkDetector, - UnsupportedFrameworkError, +from .adapters import ( + AdapterError, + AdapterResult, + create_adapter, + create_browser_use_adapter, + create_browser_use_runtime, + create_langchain_adapter, + create_playwright_adapter, + create_pydantic_ai_adapter, ) +from .config import SecureAgentConfig, WrappedAgent +from .detection import DetectionResult, Framework, FrameworkDetector, UnsupportedFrameworkError __version__ = "0.1.0" @@ -43,6 +48,15 @@ "Framework", "FrameworkDetector", "DetectionResult", + # Adapters + "AdapterResult", + "AdapterError", + "create_adapter", + "create_browser_use_adapter", + "create_browser_use_runtime", + "create_playwright_adapter", + "create_langchain_adapter", + "create_pydantic_ai_adapter", # Modes "MODE_STRICT", "MODE_PERMISSIVE", @@ -318,9 +332,7 @@ def run(self, task: str | None = None) -> Any: if self._wrapped.framework == Framework.PYDANTIC_AI.value: return self._run_pydantic_ai(task) - raise NotImplementedError( - f"run() not implemented for framework: {self._wrapped.framework}" - ) + raise NotImplementedError(f"run() not implemented for framework: {self._wrapped.framework}") def _run_browser_use(self, task: str | None) -> Any: """Run browser-use agent with authorization.""" @@ -331,8 +343,8 @@ def _run_browser_use(self, task: str | None) -> Any: agent = self._wrapped.original # Override task if provided - if task is not None: - agent.task = task + if task is not None and hasattr(agent, "task"): + setattr(agent, "task", task) # Check if agent has a run method if hasattr(agent, "run"): @@ -374,9 +386,7 @@ def _run_langchain(self, task: str | None) -> Any: def _run_pydantic_ai(self, task: str | None) -> Any: """Run PydanticAI agent with authorization.""" - raise NotImplementedError( - "PydanticAI integration not yet implemented." - ) + raise NotImplementedError("PydanticAI integration not yet implemented.") @classmethod def attach(cls, agent: Any, **kwargs: Any) -> SecureAgent: @@ -414,6 +424,195 @@ def get_pre_action_authorizer(self) -> Any: """ return self._create_pre_action_authorizer() + def get_adapter( + self, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, + **kwargs: Any, + ) -> AdapterResult: + """ + Get an adapter for the wrapped agent. + + This creates the appropriate adapter based on the detected framework, + wiring together BrowserUseAdapter, PredicateBrowserUsePlugin, + SentienceLangChainCore, or AgentRuntime.from_playwright_page(). + + Args: + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + **kwargs: Additional framework-specific options + + Returns: + AdapterResult with initialized components + + Raises: + AdapterError: If adapter initialization fails + + Example: + secure = SecureAgent(agent=my_browser_use_agent, policy="policy.yaml") + adapter = secure.get_adapter() + + # Use plugin lifecycle hooks + result = await agent.run( + on_step_start=adapter.plugin.on_step_start, + on_step_end=adapter.plugin.on_step_end, + ) + """ + return create_adapter( + agent=self._wrapped.original, + framework=self.framework, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + **kwargs, + ) + + async def get_runtime_async( + self, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, + ) -> Any: + """ + Get an initialized AgentRuntime for the wrapped agent (async). + + This is useful for browser-use agents where runtime initialization + requires async operations. + + Args: + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + + Returns: + AgentRuntime instance + + Raises: + AdapterError: If runtime initialization fails + + Example: + secure = SecureAgent(agent=my_browser_use_agent, policy="policy.yaml") + runtime = await secure.get_runtime_async() + + # Use with RuntimeAgent + from predicate.runtime_agent import RuntimeAgent + runtime_agent = RuntimeAgent( + runtime=runtime, + executor=my_llm, + pre_action_authorizer=secure.get_pre_action_authorizer(), + ) + """ + if self.framework == Framework.BROWSER_USE: + result = await create_browser_use_runtime( + agent=self._wrapped.original, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + ) + # Cache the runtime + self._wrapped.agent_runtime = result.agent_runtime + return result.agent_runtime + + if self.framework == Framework.PLAYWRIGHT: + adapter = create_playwright_adapter( + page=self._wrapped.original, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + ) + self._wrapped.agent_runtime = adapter.agent_runtime + return adapter.agent_runtime + + raise AdapterError( + f"get_runtime_async() not supported for framework: {self.framework.value}", + self.framework, + ) + + def get_browser_use_plugin( + self, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, + ) -> Any: + """ + Get a PredicateBrowserUsePlugin for browser-use lifecycle hooks. + + This is the recommended way to integrate with browser-use agents. + + Args: + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + + Returns: + PredicateBrowserUsePlugin instance + + Raises: + AdapterError: If framework is not browser-use + + Example: + secure = SecureAgent(agent=my_agent, policy="policy.yaml") + plugin = secure.get_browser_use_plugin() + + # Run with lifecycle hooks + result = await agent.run( + on_step_start=plugin.on_step_start, + on_step_end=plugin.on_step_end, + ) + """ + if self.framework != Framework.BROWSER_USE: + raise AdapterError( + "get_browser_use_plugin() only available for browser-use agents", + self.framework, + ) + + adapter = create_browser_use_adapter( + agent=self._wrapped.original, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + ) + return adapter.plugin + + def get_langchain_core( + self, + browser: Any | None = None, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, + ) -> Any: + """ + Get a SentienceLangChainCore for LangChain tool interception. + + Args: + browser: Optional browser instance for browser tools + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + + Returns: + SentienceLangChainCore instance + + Raises: + AdapterError: If framework is not LangChain + """ + if self.framework != Framework.LANGCHAIN: + raise AdapterError( + "get_langchain_core() only available for LangChain agents", + self.framework, + ) + + adapter = create_langchain_adapter( + agent=self._wrapped.original, + browser=browser, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + ) + return adapter.plugin + def __repr__(self) -> str: return ( f"SecureAgent(" diff --git a/src/predicate_secure/adapters.py b/src/predicate_secure/adapters.py new file mode 100644 index 0000000..e25b100 --- /dev/null +++ b/src/predicate_secure/adapters.py @@ -0,0 +1,392 @@ +"""Framework adapters for predicate-secure. + +This module wires together existing adapters from sdk-python +to provide seamless integration with various agent frameworks. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from .detection import Framework + +if TYPE_CHECKING: + pass + + +@dataclass +class AdapterResult: + """Result of adapter initialization.""" + + agent_runtime: Any | None + backend: Any | None + tracer: Any | None + plugin: Any | None + executor: Any | None + metadata: dict + + +class AdapterError(Exception): + """Raised when adapter initialization fails.""" + + def __init__(self, message: str, framework: Framework): + super().__init__(message) + self.framework = framework + + +def create_browser_use_adapter( + agent: Any, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, +) -> AdapterResult: + """ + Create adapter for browser-use Agent. + + This wires together: + - BrowserUseAdapter for CDP backend + - PredicateBrowserUsePlugin for lifecycle hooks + - AgentRuntime from the session + + Args: + agent: browser-use Agent instance + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + + Returns: + AdapterResult with initialized components + + Raises: + AdapterError: If browser-use or predicate packages not available + """ + try: + from predicate.backends.browser_use_adapter import BrowserUseAdapter + from predicate.integrations.browser_use.plugin import ( + PredicateBrowserUsePlugin, + PredicateBrowserUsePluginConfig, + ) + except ImportError as e: + raise AdapterError( + "browser-use adapter requires predicate[browser-use]. " + f"Install with: pip install predicate-secure[browser-use]. Error: {e}", + Framework.BROWSER_USE, + ) from e + + # Extract session from agent + session = getattr(agent, "browser", None) or getattr(agent, "session", None) + if session is None: + raise AdapterError( + "Could not find browser session on agent. " + "Ensure agent has .browser or .session attribute.", + Framework.BROWSER_USE, + ) + + # Create adapter + adapter = BrowserUseAdapter(session) + + # Create plugin config + plugin_config = PredicateBrowserUsePluginConfig( + predicate_api_key=predicate_api_key, + tracer=tracer, + snapshot_options=snapshot_options, + ) + plugin = PredicateBrowserUsePlugin(config=plugin_config) + + # Extract executor (LLM) + executor = getattr(agent, "llm", None) + + return AdapterResult( + agent_runtime=None, # Created lazily by plugin + backend=adapter, + tracer=tracer, + plugin=plugin, + executor=executor, + metadata={ + "framework": "browser_use", + "has_session": session is not None, + "has_executor": executor is not None, + }, + ) + + +async def create_browser_use_runtime( + agent: Any, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, +) -> AdapterResult: + """ + Create AgentRuntime for browser-use Agent (async). + + This creates a full AgentRuntime that can be used with RuntimeAgent. + + Args: + agent: browser-use Agent instance + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + + Returns: + AdapterResult with initialized AgentRuntime + """ + try: + from predicate.agent_runtime import AgentRuntime + from predicate.backends.browser_use_adapter import BrowserUseAdapter + from predicate.tracing import JsonlTraceSink, Tracer as PredicateTracer + except ImportError as e: + raise AdapterError( + f"browser-use adapter requires predicate. Error: {e}", + Framework.BROWSER_USE, + ) from e + + # Extract session from agent + session = getattr(agent, "browser", None) or getattr(agent, "session", None) + if session is None: + raise AdapterError( + "Could not find browser session on agent.", + Framework.BROWSER_USE, + ) + + # Create adapter and backend + adapter = BrowserUseAdapter(session) + backend = await adapter.create_backend() + + # Create tracer if not provided + if tracer is None: + import uuid + + run_id = f"secure-{uuid.uuid4().hex[:8]}" + sink = JsonlTraceSink(f"trace-{run_id}.jsonl") + tracer = PredicateTracer(run_id=run_id, sink=sink) + + # Create runtime + runtime = AgentRuntime( + backend=backend, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + ) + + return AdapterResult( + agent_runtime=runtime, + backend=backend, + tracer=tracer, + plugin=None, + executor=getattr(agent, "llm", None), + metadata={"framework": "browser_use", "has_runtime": True}, + ) + + +def create_playwright_adapter( + page: Any, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, +) -> AdapterResult: + """ + Create adapter for Playwright Page. + + Uses AgentRuntime.from_playwright_page() factory. + + Args: + page: Playwright Page instance (sync or async) + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key for Predicate API + + Returns: + AdapterResult with initialized AgentRuntime + """ + try: + from predicate.agent_runtime import AgentRuntime + from predicate.tracing import JsonlTraceSink, Tracer as PredicateTracer + except ImportError as e: + raise AdapterError( + f"Playwright adapter requires predicate. Error: {e}", + Framework.PLAYWRIGHT, + ) from e + + # Create tracer if not provided + if tracer is None: + import uuid + + run_id = f"secure-{uuid.uuid4().hex[:8]}" + sink = JsonlTraceSink(f"trace-{run_id}.jsonl") + tracer = PredicateTracer(run_id=run_id, sink=sink) + + # Create runtime from Playwright page + runtime = AgentRuntime.from_playwright_page( + page=page, + tracer=tracer, + snapshot_options=snapshot_options, + predicate_api_key=predicate_api_key, + ) + + return AdapterResult( + agent_runtime=runtime, + backend=runtime.backend, + tracer=tracer, + plugin=None, + executor=None, + metadata={"framework": "playwright", "has_runtime": True}, + ) + + +def create_langchain_adapter( + agent: Any, + browser: Any | None = None, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, +) -> AdapterResult: + """ + Create adapter for LangChain agent. + + Uses SentienceLangChainCore for tool interception. + + Args: + agent: LangChain AgentExecutor or similar + browser: Optional browser instance for browser tools + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions (currently unused) + predicate_api_key: Optional API key for Predicate API (currently unused) + + Returns: + AdapterResult with initialized components + """ + try: + from predicate.integrations.langchain.context import SentienceLangChainContext + from predicate.integrations.langchain.core import SentienceLangChainCore + except ImportError as e: + raise AdapterError( + f"LangChain adapter requires predicate[langchain]. Error: {e}", + Framework.LANGCHAIN, + ) from e + + # LangChain context requires a browser instance + # If not provided, we can still create the adapter but core won't work fully + if browser is None: + # Return adapter without core - just for detection/metadata + executor = getattr(agent, "llm", None) or getattr(agent, "agent", None) + return AdapterResult( + agent_runtime=None, + backend=None, + tracer=tracer, + plugin=None, # No core without browser + executor=executor, + metadata={ + "framework": "langchain", + "has_browser": False, + "agent_type": type(agent).__name__, + "note": "Provide browser parameter to enable SentienceLangChainCore", + }, + ) + + # Create context with browser + ctx = SentienceLangChainContext( + browser=browser, + tracer=tracer, + ) + + # Create core wrapper + core = SentienceLangChainCore(ctx) + + # Extract executor/LLM + executor = getattr(agent, "llm", None) or getattr(agent, "agent", None) + + return AdapterResult( + agent_runtime=None, # LangChain uses different pattern + backend=None, + tracer=tracer, + plugin=core, # Core acts as plugin for tool interception + executor=executor, + metadata={ + "framework": "langchain", + "has_browser": True, + "agent_type": type(agent).__name__, + }, + ) + + +def create_pydantic_ai_adapter( + agent: Any, + tracer: Any | None = None, +) -> AdapterResult: + """ + Create adapter for PydanticAI agent. + + Args: + agent: PydanticAI Agent instance + tracer: Optional Tracer for event emission + + Returns: + AdapterResult with initialized components + """ + # PydanticAI integration is simpler - extract model + executor = getattr(agent, "model", None) + + return AdapterResult( + agent_runtime=None, + backend=None, + tracer=tracer, + plugin=None, + executor=executor, + metadata={ + "framework": "pydantic_ai", + "model": str(executor) if executor else None, + }, + ) + + +def create_adapter( + agent: Any, + framework: Framework, + tracer: Any | None = None, + snapshot_options: Any | None = None, + predicate_api_key: str | None = None, + **kwargs: Any, +) -> AdapterResult: + """ + Create adapter for the given framework. + + This is the main entry point for adapter creation. + + Args: + agent: The agent or page to wrap + framework: Detected framework + tracer: Optional Tracer for event emission + snapshot_options: Optional SnapshotOptions + predicate_api_key: Optional API key + **kwargs: Additional framework-specific options + + Returns: + AdapterResult with initialized components + + Raises: + AdapterError: If framework is not supported + """ + if framework == Framework.BROWSER_USE: + return create_browser_use_adapter( + agent, tracer, snapshot_options, predicate_api_key + ) + + if framework == Framework.PLAYWRIGHT: + return create_playwright_adapter( + agent, tracer, snapshot_options, predicate_api_key + ) + + if framework == Framework.LANGCHAIN: + browser = kwargs.get("browser") + return create_langchain_adapter( + agent, browser, tracer, snapshot_options, predicate_api_key + ) + + if framework == Framework.PYDANTIC_AI: + return create_pydantic_ai_adapter(agent, tracer) + + raise AdapterError( + f"No adapter available for framework: {framework.value}", + framework, + ) diff --git a/src/predicate_secure/config.py b/src/predicate_secure/config.py index a366a15..e6a9ee4 100644 --- a/src/predicate_secure/config.py +++ b/src/predicate_secure/config.py @@ -45,7 +45,9 @@ def fail_closed(self) -> bool: @property def effective_principal_id(self) -> str: """Get principal ID, falling back to environment variable.""" - return self.principal_id or os.getenv("PREDICATE_PRINCIPAL_ID", "agent:default") + if self.principal_id: + return self.principal_id + return os.getenv("PREDICATE_PRINCIPAL_ID") or "agent:default" @property def effective_signing_key(self) -> str: diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..8abec6b --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,147 @@ +"""Tests for framework adapters.""" + +import pytest + +from predicate_secure import ( + AdapterError, + AdapterResult, + Framework, + SecureAgent, + create_adapter, +) + + +class TestAdapterResult: + """Tests for AdapterResult dataclass.""" + + def test_adapter_result_fields(self): + """AdapterResult has expected fields.""" + result = AdapterResult( + agent_runtime=None, + backend=None, + tracer=None, + plugin=None, + executor="mock_executor", + metadata={"framework": "test"}, + ) + assert result.executor == "mock_executor" + assert result.metadata["framework"] == "test" + + +class TestAdapterError: + """Tests for AdapterError exception.""" + + def test_adapter_error_has_framework(self): + """AdapterError includes framework info.""" + exc = AdapterError("Test error", Framework.BROWSER_USE) + assert exc.framework == Framework.BROWSER_USE + assert "Test error" in str(exc) + + +class TestCreateAdapter: + """Tests for create_adapter function.""" + + def test_create_adapter_unknown_framework(self): + """create_adapter raises for unknown framework.""" + with pytest.raises(AdapterError, match="No adapter available"): + create_adapter(object(), Framework.UNKNOWN) + + def test_create_adapter_pydantic_ai(self): + """create_adapter works for PydanticAI (no dependencies).""" + + class MockPydanticAgent: + model = "gpt-4" + + result = create_adapter(MockPydanticAgent(), Framework.PYDANTIC_AI) + assert result.metadata["framework"] == "pydantic_ai" + assert result.executor == "gpt-4" + + +class TestSecureAgentAdapters: + """Tests for SecureAgent adapter methods.""" + + def test_get_adapter_unknown_raises(self): + """get_adapter raises for unknown frameworks.""" + mock_agent = object() + secure = SecureAgent(agent=mock_agent) + with pytest.raises(AdapterError, match="No adapter available"): + secure.get_adapter() + + def test_get_browser_use_plugin_wrong_framework(self): + """get_browser_use_plugin raises for non-browser-use agents.""" + mock_agent = object() + secure = SecureAgent(agent=mock_agent) + with pytest.raises(AdapterError, match="only available for browser-use"): + secure.get_browser_use_plugin() + + def test_get_langchain_core_wrong_framework(self): + """get_langchain_core raises for non-LangChain agents.""" + mock_agent = object() + secure = SecureAgent(agent=mock_agent) + with pytest.raises(AdapterError, match="only available for LangChain"): + secure.get_langchain_core() + + +class TestBrowserUseAdapterMock: + """Tests for browser-use adapter with mocks.""" + + def test_browser_use_adapter_missing_session(self): + """browser-use adapter raises if no session found.""" + + class MockBrowserUseAgent: + __module__ = "browser_use.agent" + # No browser or session attribute + + secure = SecureAgent(agent=MockBrowserUseAgent()) + + # Should raise because no session found + # The exact error depends on whether predicate is installed + with pytest.raises((AdapterError, Exception)): + secure.get_adapter() + + def test_browser_use_adapter_with_session(self): + """browser-use adapter detects session attribute.""" + + class MockSession: + pass + + class MockBrowserUseAgent: + __module__ = "browser_use.agent" + + def __init__(self): + self.browser = MockSession() + self.llm = "mock_llm" + + secure = SecureAgent(agent=MockBrowserUseAgent()) + assert secure.framework == Framework.BROWSER_USE + assert secure.wrapped.executor == "mock_llm" + + +class TestPlaywrightAdapterMock: + """Tests for Playwright adapter with mocks.""" + + def test_playwright_adapter_detection(self): + """Playwright page is detected correctly.""" + + class MockPage: + __module__ = "playwright.async_api._generated" + + secure = SecureAgent(agent=MockPage()) + assert secure.framework == Framework.PLAYWRIGHT + + +class TestLangChainAdapterMock: + """Tests for LangChain adapter with mocks.""" + + def test_langchain_adapter_detection(self): + """LangChain agent is detected correctly.""" + + class MockAgentExecutor: + __module__ = "langchain.agents.executor" + + def __init__(self): + self.llm = "mock_llm" + + secure = SecureAgent(agent=MockAgentExecutor()) + assert secure.framework == Framework.LANGCHAIN + assert secure.wrapped.executor == "mock_llm" diff --git a/tests/test_adapters_integration.py b/tests/test_adapters_integration.py new file mode 100644 index 0000000..9eb21a0 --- /dev/null +++ b/tests/test_adapters_integration.py @@ -0,0 +1,384 @@ +"""Integration tests for framework adapters. + +These tests verify that adapters correctly wire sdk-python components +when the predicate package is available. +""" + +import pytest + +from predicate_secure import ( + AdapterError, + AdapterResult, + Framework, + SecureAgent, + create_adapter, + create_browser_use_adapter, + create_langchain_adapter, + create_playwright_adapter, + create_pydantic_ai_adapter, +) + + +# Check if predicate is available +try: + from predicate.integrations.browser_use.plugin import PredicateBrowserUsePlugin + + HAS_BROWSER_USE_ADAPTER = True +except ImportError: + HAS_BROWSER_USE_ADAPTER = False + PredicateBrowserUsePlugin = None # type: ignore[misc,assignment] + +try: + from predicate.agent_runtime import AgentRuntime # noqa: F401 + + HAS_AGENT_RUNTIME = True +except ImportError: + HAS_AGENT_RUNTIME = False + +try: + from predicate.integrations.langchain.core import SentienceLangChainCore + + HAS_LANGCHAIN_CORE = True +except ImportError: + HAS_LANGCHAIN_CORE = False + + +class TestBrowserUseAdapterIntegration: + """Integration tests for browser-use adapter with real predicate imports.""" + + @pytest.mark.skipif(not HAS_BROWSER_USE_ADAPTER, reason="predicate not installed") + def test_create_browser_use_adapter_returns_plugin(self): + """create_browser_use_adapter returns PredicateBrowserUsePlugin.""" + + class MockSession: + pass + + class MockBrowserUseAgent: + __module__ = "browser_use.agent" + + def __init__(self): + self.browser = MockSession() + self.llm = "mock_llm" + + result = create_browser_use_adapter(MockBrowserUseAgent()) + + assert isinstance(result, AdapterResult) + assert result.plugin is not None + assert isinstance(result.plugin, PredicateBrowserUsePlugin) + assert result.executor == "mock_llm" + assert result.metadata["framework"] == "browser_use" + assert result.metadata["has_session"] is True + + @pytest.mark.skipif(not HAS_BROWSER_USE_ADAPTER, reason="predicate not installed") + def test_create_browser_use_adapter_missing_session_raises(self): + """create_browser_use_adapter raises AdapterError if no session found.""" + + class MockBrowserUseAgent: + __module__ = "browser_use.agent" + # No browser or session attribute + + with pytest.raises(AdapterError, match="Could not find browser session"): + create_browser_use_adapter(MockBrowserUseAgent()) + + @pytest.mark.skipif(not HAS_BROWSER_USE_ADAPTER, reason="predicate not installed") + def test_secure_agent_get_browser_use_plugin(self): + """SecureAgent.get_browser_use_plugin() returns working plugin.""" + + class MockSession: + pass + + class MockBrowserUseAgent: + __module__ = "browser_use.agent" + + def __init__(self): + self.browser = MockSession() + self.llm = "mock_llm" + + secure = SecureAgent(agent=MockBrowserUseAgent()) + plugin = secure.get_browser_use_plugin() + + assert isinstance(plugin, PredicateBrowserUsePlugin) + # Plugin should have lifecycle hooks + assert hasattr(plugin, "on_step_start") + assert hasattr(plugin, "on_step_end") + + +class TestPlaywrightAdapterIntegration: + """Integration tests for Playwright adapter with real predicate imports.""" + + @pytest.mark.skipif(not HAS_AGENT_RUNTIME, reason="predicate not installed") + def test_create_playwright_adapter_returns_runtime(self): + """create_playwright_adapter returns AgentRuntime with mock page. + + Note: The adapter accepts any page-like object and creates a runtime. + Full integration testing requires a real Playwright browser session. + """ + + class MockPage: + __module__ = "playwright.async_api._generated" + + result = create_playwright_adapter(MockPage()) + + assert isinstance(result, AdapterResult) + assert result.agent_runtime is not None + assert result.metadata["framework"] == "playwright" + assert result.metadata["has_runtime"] is True + + def test_secure_agent_detects_playwright_framework(self): + """SecureAgent detects Playwright pages correctly.""" + + class MockPage: + __module__ = "playwright.sync_api._generated" + + secure = SecureAgent(agent=MockPage()) + assert secure.framework == Framework.PLAYWRIGHT + assert secure.wrapped.metadata.get("is_async") is False + + +class TestLangChainAdapterIntegration: + """Integration tests for LangChain adapter with real predicate imports.""" + + @pytest.mark.skipif(not HAS_LANGCHAIN_CORE, reason="predicate langchain not installed") + def test_create_langchain_adapter_without_browser(self): + """create_langchain_adapter works without browser (limited functionality).""" + + class MockAgentExecutor: + __module__ = "langchain.agents.executor" + + def __init__(self): + self.llm = "mock_llm" + + result = create_langchain_adapter(MockAgentExecutor()) + + assert isinstance(result, AdapterResult) + # Without browser, plugin is None + assert result.plugin is None + assert result.executor == "mock_llm" + assert result.metadata["framework"] == "langchain" + assert result.metadata["has_browser"] is False + + @pytest.mark.skipif(not HAS_LANGCHAIN_CORE, reason="predicate langchain not installed") + def test_create_langchain_adapter_with_browser(self): + """create_langchain_adapter returns SentienceLangChainCore with browser.""" + + class MockAgentExecutor: + __module__ = "langchain.agents.executor" + + def __init__(self): + self.llm = "mock_llm" + + class MockBrowser: + pass + + result = create_langchain_adapter(MockAgentExecutor(), browser=MockBrowser()) + + assert isinstance(result, AdapterResult) + assert result.plugin is not None + assert isinstance(result.plugin, SentienceLangChainCore) + assert result.executor == "mock_llm" + assert result.metadata["has_browser"] is True + + @pytest.mark.skipif(not HAS_LANGCHAIN_CORE, reason="predicate langchain not installed") + def test_secure_agent_get_langchain_core_without_browser(self): + """SecureAgent.get_langchain_core() returns None without browser.""" + + class MockAgentExecutor: + __module__ = "langchain.agents.executor" + + def __init__(self): + self.llm = "mock_llm" + + secure = SecureAgent(agent=MockAgentExecutor()) + core = secure.get_langchain_core() + + # Without browser, core is None + assert core is None + + +class TestPydanticAIAdapterIntegration: + """Integration tests for PydanticAI adapter.""" + + def test_create_pydantic_ai_adapter_extracts_model(self): + """create_pydantic_ai_adapter extracts model from agent.""" + + class MockPydanticAgent: + __module__ = "pydantic_ai.agent" + model = "gpt-4-turbo" + + result = create_pydantic_ai_adapter(MockPydanticAgent()) + + assert isinstance(result, AdapterResult) + assert result.executor == "gpt-4-turbo" + assert result.metadata["framework"] == "pydantic_ai" + assert result.metadata["model"] == "gpt-4-turbo" + + def test_create_pydantic_ai_adapter_no_model(self): + """create_pydantic_ai_adapter handles missing model gracefully.""" + + class MockPydanticAgent: + __module__ = "pydantic_ai.agent" + # No model attribute + + result = create_pydantic_ai_adapter(MockPydanticAgent()) + + assert result.executor is None + assert result.metadata["model"] is None + + +class TestCreateAdapterDispatch: + """Tests for create_adapter dispatch logic.""" + + def test_create_adapter_dispatches_to_pydantic_ai(self): + """create_adapter dispatches to correct adapter for PydanticAI.""" + + class MockPydanticAgent: + model = "claude-3" + + result = create_adapter(MockPydanticAgent(), Framework.PYDANTIC_AI) + + assert result.metadata["framework"] == "pydantic_ai" + assert result.executor == "claude-3" + + def test_create_adapter_unknown_raises_adapter_error(self): + """create_adapter raises AdapterError for unknown frameworks.""" + with pytest.raises(AdapterError) as exc_info: + create_adapter(object(), Framework.UNKNOWN) + + assert exc_info.value.framework == Framework.UNKNOWN + assert "No adapter available" in str(exc_info.value) + + @pytest.mark.skipif(not HAS_BROWSER_USE_ADAPTER, reason="predicate not installed") + def test_create_adapter_dispatches_to_browser_use(self): + """create_adapter dispatches to browser-use adapter.""" + + class MockSession: + pass + + class MockBrowserUseAgent: + def __init__(self): + self.browser = MockSession() + + result = create_adapter(MockBrowserUseAgent(), Framework.BROWSER_USE) + + assert result.metadata["framework"] == "browser_use" + assert result.plugin is not None + + @pytest.mark.skipif(not HAS_LANGCHAIN_CORE, reason="predicate langchain not installed") + def test_create_adapter_dispatches_to_langchain(self): + """create_adapter dispatches to LangChain adapter.""" + + class MockAgentExecutor: + pass + + result = create_adapter(MockAgentExecutor(), Framework.LANGCHAIN) + + assert result.metadata["framework"] == "langchain" + # Without browser, plugin is None + assert result.metadata["has_browser"] is False + + +class TestSecureAgentAdapterMethods: + """Tests for SecureAgent adapter convenience methods.""" + + def test_get_adapter_with_pydantic_ai(self): + """SecureAgent.get_adapter() works for PydanticAI.""" + + class MockPydanticAgent: + __module__ = "pydantic_ai.agent" + model = "gpt-4" + + secure = SecureAgent(agent=MockPydanticAgent()) + adapter = secure.get_adapter() + + assert adapter.metadata["framework"] == "pydantic_ai" + assert adapter.executor == "gpt-4" + + def test_get_browser_use_plugin_wrong_framework_raises(self): + """get_browser_use_plugin raises for non-browser-use agents.""" + + class MockPlaywrightPage: + __module__ = "playwright.async_api._generated" + + secure = SecureAgent(agent=MockPlaywrightPage()) + + with pytest.raises(AdapterError, match="only available for browser-use"): + secure.get_browser_use_plugin() + + def test_get_langchain_core_wrong_framework_raises(self): + """get_langchain_core raises for non-LangChain agents.""" + + class MockBrowserUseAgent: + __module__ = "browser_use.agent" + + secure = SecureAgent(agent=MockBrowserUseAgent()) + + with pytest.raises(AdapterError, match="only available for LangChain"): + secure.get_langchain_core() + + +class TestAdapterResultDataclass: + """Tests for AdapterResult dataclass behavior.""" + + def test_adapter_result_all_fields_none(self): + """AdapterResult can have all optional fields as None.""" + result = AdapterResult( + agent_runtime=None, + backend=None, + tracer=None, + plugin=None, + executor=None, + metadata={}, + ) + assert result.agent_runtime is None + assert result.backend is None + assert result.tracer is None + assert result.plugin is None + assert result.executor is None + + def test_adapter_result_with_values(self): + """AdapterResult stores all provided values.""" + mock_runtime = object() + mock_backend = object() + mock_tracer = object() + mock_plugin = object() + mock_executor = object() + + result = AdapterResult( + agent_runtime=mock_runtime, + backend=mock_backend, + tracer=mock_tracer, + plugin=mock_plugin, + executor=mock_executor, + metadata={"key": "value"}, + ) + + assert result.agent_runtime is mock_runtime + assert result.backend is mock_backend + assert result.tracer is mock_tracer + assert result.plugin is mock_plugin + assert result.executor is mock_executor + assert result.metadata == {"key": "value"} + + +class TestAdapterErrorException: + """Tests for AdapterError exception behavior.""" + + def test_adapter_error_message_and_framework(self): + """AdapterError includes both message and framework.""" + exc = AdapterError("Failed to create adapter", Framework.BROWSER_USE) + + assert str(exc) == "Failed to create adapter" + assert exc.framework == Framework.BROWSER_USE + + def test_adapter_error_inherits_from_exception(self): + """AdapterError is a proper Exception subclass.""" + exc = AdapterError("test", Framework.UNKNOWN) + assert isinstance(exc, Exception) + + def test_adapter_error_can_be_raised_and_caught(self): + """AdapterError can be raised and caught.""" + with pytest.raises(AdapterError) as exc_info: + raise AdapterError("Test error", Framework.PLAYWRIGHT) + + assert "Test error" in str(exc_info.value) + assert exc_info.value.framework == Framework.PLAYWRIGHT