narada 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {narada-0.2.0 → narada-0.2.2}/PKG-INFO +2 -2
- {narada-0.2.0 → narada-0.2.2}/pyproject.toml +2 -2
- {narada-0.2.0 → narada-0.2.2}/src/narada/__init__.py +6 -1
- {narada-0.2.0 → narada-0.2.2}/src/narada/agent.py +42 -1
- {narada-0.2.0 → narada-0.2.2}/src/narada/environment.py +53 -5
- narada-0.2.2/tests/test_client.py +186 -0
- {narada-0.2.0 → narada-0.2.2}/tests/test_cloud_browser.py +218 -1
- {narada-0.2.0 → narada-0.2.2}/tests/test_window_human_interaction.py +32 -0
- {narada-0.2.0 → narada-0.2.2}/.gitignore +0 -0
- {narada-0.2.0 → narada-0.2.2}/README.md +0 -0
- {narada-0.2.0 → narada-0.2.2}/src/narada/config.py +0 -0
- {narada-0.2.0 → narada-0.2.2}/src/narada/py.typed +0 -0
- {narada-0.2.0 → narada-0.2.2}/src/narada/utils.py +0 -0
- {narada-0.2.0 → narada-0.2.2}/src/narada/version.py +0 -0
- {narada-0.2.0 → narada-0.2.2}/tests/test_agent.py +0 -0
- {narada-0.2.0 → narada-0.2.2}/tests/test_browser_environment_login.py +0 -0
- {narada-0.2.0 → narada-0.2.2}/tests/test_input_variables.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: narada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python client SDK for Narada
|
|
5
5
|
Project-URL: Homepage, https://github.com/NaradaAI/narada-python-sdk/narada
|
|
6
6
|
Project-URL: Repository, https://github.com/NaradaAI/narada-python-sdk
|
|
@@ -9,7 +9,7 @@ Author-email: Narada <support@narada.ai>
|
|
|
9
9
|
License-Expression: Apache-2.0
|
|
10
10
|
Requires-Python: >=3.12
|
|
11
11
|
Requires-Dist: aiohttp>=3.12.13
|
|
12
|
-
Requires-Dist: narada-core==0.1.
|
|
12
|
+
Requires-Dist: narada-core==0.1.1
|
|
13
13
|
Requires-Dist: packaging==24.2
|
|
14
14
|
Requires-Dist: playwright>=1.53.0
|
|
15
15
|
Requires-Dist: rich>=14.0.0
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "narada"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Python client SDK for Narada"
|
|
5
5
|
license = "Apache-2.0"
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
authors = [{ name = "Narada", email = "support@narada.ai" }]
|
|
8
8
|
requires-python = ">=3.12"
|
|
9
9
|
dependencies = [
|
|
10
|
-
"narada-core==0.1.
|
|
10
|
+
"narada-core==0.1.1",
|
|
11
11
|
"aiohttp>=3.12.13",
|
|
12
12
|
"playwright>=1.53.0",
|
|
13
13
|
"rich>=14.0.0",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
from narada_core.actions.models import
|
|
1
|
+
from narada_core.actions.models import (
|
|
2
|
+
ActiveInputRequest,
|
|
3
|
+
CriticResult,
|
|
4
|
+
PressKeyEventItem,
|
|
5
|
+
)
|
|
2
6
|
from narada_core.errors import (
|
|
3
7
|
NaradaError,
|
|
4
8
|
NaradaExtensionMissingError,
|
|
@@ -52,6 +56,7 @@ __all__ = [
|
|
|
52
56
|
"NaradaInitializationError",
|
|
53
57
|
"NaradaTimeoutError",
|
|
54
58
|
"NaradaUnsupportedBrowserError",
|
|
59
|
+
"PressKeyEventItem",
|
|
55
60
|
"ProxyConfig",
|
|
56
61
|
"ReasoningEffort",
|
|
57
62
|
"RemoteBrowserEnvironment",
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import IO, Any, Generic, Literal, Mapping, TypeVar, overload
|
|
4
4
|
|
|
5
|
-
from narada_core.actions.critic import run_critic
|
|
5
|
+
from narada_core.actions.critic import merge_critic_workflow_trace, run_critic
|
|
6
6
|
from narada_core.actions.models import (
|
|
7
7
|
DEFAULT_HITL_TIMEOUT_SECONDS,
|
|
8
8
|
AgenticMatchingSelectorsFinderRequest,
|
|
@@ -16,6 +16,8 @@ from narada_core.actions.models import (
|
|
|
16
16
|
AgentResponse,
|
|
17
17
|
AgentUsage,
|
|
18
18
|
CriticResult,
|
|
19
|
+
ExecuteJavaScriptOnPageRequest,
|
|
20
|
+
ExecuteJavaScriptOnPageResponse,
|
|
19
21
|
GetFullHtmlRequest,
|
|
20
22
|
GetFullHtmlResponse,
|
|
21
23
|
GetScreenshotRequest,
|
|
@@ -25,7 +27,10 @@ from narada_core.actions.models import (
|
|
|
25
27
|
GetUrlRequest,
|
|
26
28
|
GetUrlResponse,
|
|
27
29
|
GoToUrlRequest,
|
|
30
|
+
JsonValue,
|
|
28
31
|
PrintMessageRequest,
|
|
32
|
+
PressKeyEventItem,
|
|
33
|
+
PressKeyRequest,
|
|
29
34
|
PromptForUserInputRequest,
|
|
30
35
|
PromptForUserInputResponse,
|
|
31
36
|
PromptForUserInputVariable,
|
|
@@ -195,6 +200,10 @@ class Agent(Generic[_StructuredOutput]):
|
|
|
195
200
|
time_zone=time_zone,
|
|
196
201
|
timeout=timeout,
|
|
197
202
|
)
|
|
203
|
+
workflow_trace = merge_critic_workflow_trace(
|
|
204
|
+
workflow_trace=workflow_trace,
|
|
205
|
+
critic_result=critic_result,
|
|
206
|
+
)
|
|
198
207
|
|
|
199
208
|
return AgentResponse(
|
|
200
209
|
request_id=remote_dispatch_response["requestId"],
|
|
@@ -352,6 +361,27 @@ class Agent(Generic[_StructuredOutput]):
|
|
|
352
361
|
)
|
|
353
362
|
return result.selectors
|
|
354
363
|
|
|
364
|
+
async def press_key(
|
|
365
|
+
self,
|
|
366
|
+
*,
|
|
367
|
+
events: list[PressKeyEventItem | Mapping[str, Any]],
|
|
368
|
+
timeout: int | None = 60,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Dispatch keyboard events on the active tab through the extension."""
|
|
371
|
+
if not events:
|
|
372
|
+
raise ValueError("press_key requires a non-empty events= list")
|
|
373
|
+
|
|
374
|
+
normalized_events = [
|
|
375
|
+
event
|
|
376
|
+
if isinstance(event, PressKeyEventItem)
|
|
377
|
+
else PressKeyEventItem.model_validate(event)
|
|
378
|
+
for event in events
|
|
379
|
+
]
|
|
380
|
+
return await self._browser_environment()._run_extension_action(
|
|
381
|
+
PressKeyRequest(events=normalized_events),
|
|
382
|
+
timeout=timeout,
|
|
383
|
+
)
|
|
384
|
+
|
|
355
385
|
async def agentic_mouse_action(
|
|
356
386
|
self,
|
|
357
387
|
*,
|
|
@@ -410,6 +440,17 @@ class Agent(Generic[_StructuredOutput]):
|
|
|
410
440
|
timeout=timeout,
|
|
411
441
|
)
|
|
412
442
|
|
|
443
|
+
async def execute_javascript_on_page(
|
|
444
|
+
self, *, code: str, timeout: int | None = None
|
|
445
|
+
) -> JsonValue:
|
|
446
|
+
"""Executes JavaScript on the current active page and returns its JSON result."""
|
|
447
|
+
result = await self._browser_environment()._run_extension_action(
|
|
448
|
+
ExecuteJavaScriptOnPageRequest(code=code),
|
|
449
|
+
ExecuteJavaScriptOnPageResponse,
|
|
450
|
+
timeout=timeout,
|
|
451
|
+
)
|
|
452
|
+
return result.result
|
|
453
|
+
|
|
413
454
|
async def print_message(self, *, message: str, timeout: int | None = None) -> None:
|
|
414
455
|
"""Prints a message in the Narada extension side panel chat."""
|
|
415
456
|
return await self._browser_environment()._run_extension_action(
|
|
@@ -100,6 +100,16 @@ _INITIALIZATION_ERROR_INDICATOR_SELECTOR = "#narada-initialization-error"
|
|
|
100
100
|
type InputRequiredCallback = Callable[[ActiveInputRequest], Awaitable[None] | None]
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _load_execution_trace_context_from_env() -> dict[str, Any] | None:
|
|
104
|
+
raw = os.environ.get("NARADA_EXECUTION_TRACE_CONTEXT")
|
|
105
|
+
if not raw:
|
|
106
|
+
return None
|
|
107
|
+
value = json.loads(raw)
|
|
108
|
+
if not isinstance(value, dict):
|
|
109
|
+
raise ValueError("NARADA_EXECUTION_TRACE_CONTEXT must be a JSON object")
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
103
113
|
async def _notify_input_required_callback(
|
|
104
114
|
callback: InputRequiredCallback | None,
|
|
105
115
|
response: _RemoteDispatchPollResponse,
|
|
@@ -710,6 +720,9 @@ class Environment(ABC):
|
|
|
710
720
|
browser_window_id = self._dispatch_browser_window_id
|
|
711
721
|
if browser_window_id is not None:
|
|
712
722
|
body["browserWindowId"] = browser_window_id
|
|
723
|
+
execution_trace_context = _load_execution_trace_context_from_env()
|
|
724
|
+
if execution_trace_context is not None:
|
|
725
|
+
body["executionTraceContext"] = execution_trace_context
|
|
713
726
|
cloud_browser_session_id = self.cloud_browser_session_id
|
|
714
727
|
if cloud_browser_session_id is not None:
|
|
715
728
|
body["cloudBrowserSessionId"] = cloud_browser_session_id
|
|
@@ -843,8 +856,10 @@ class Environment(ABC):
|
|
|
843
856
|
raise NaradaError(
|
|
844
857
|
f"{type(self).__name__} does not support browser extension actions"
|
|
845
858
|
)
|
|
859
|
+
action_execution_id = f"action_{uuid4().hex}"
|
|
846
860
|
body = {
|
|
847
861
|
"action": request.model_dump(),
|
|
862
|
+
"actionExecutionId": action_execution_id,
|
|
848
863
|
"browserWindowId": browser_window_id,
|
|
849
864
|
}
|
|
850
865
|
remote_dispatch_request_id = os.environ.get(_REMOTE_DISPATCH_REQUEST_ID_ENV_VAR)
|
|
@@ -955,8 +970,7 @@ class BrowserEnvironment(BaseBrowserEnvironment):
|
|
|
955
970
|
)
|
|
956
971
|
|
|
957
972
|
async def _initialize(self) -> None:
|
|
958
|
-
self.
|
|
959
|
-
self._playwright = await self._playwright_context_manager.__aenter__()
|
|
973
|
+
await self._start_playwright()
|
|
960
974
|
if self._attach_to_existing:
|
|
961
975
|
await self._initialize_in_existing_browser_window()
|
|
962
976
|
else:
|
|
@@ -982,6 +996,10 @@ class BrowserEnvironment(BaseBrowserEnvironment):
|
|
|
982
996
|
finally:
|
|
983
997
|
await self._stop_playwright()
|
|
984
998
|
|
|
999
|
+
async def _start_playwright(self) -> None:
|
|
1000
|
+
self._playwright_context_manager = async_playwright()
|
|
1001
|
+
self._playwright = await self._playwright_context_manager.__aenter__()
|
|
1002
|
+
|
|
985
1003
|
async def _stop_playwright(self) -> None:
|
|
986
1004
|
if self._playwright_context_manager is None:
|
|
987
1005
|
return
|
|
@@ -1465,8 +1483,7 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
|
|
|
1465
1483
|
``#narada-browser-window-id`` (extension install retries apply). ``config`` controls
|
|
1466
1484
|
interactive prompts and related behavior.
|
|
1467
1485
|
"""
|
|
1468
|
-
self.
|
|
1469
|
-
self._playwright = await self._playwright_context_manager.__aenter__()
|
|
1486
|
+
await self._start_playwright()
|
|
1470
1487
|
|
|
1471
1488
|
request_body = {
|
|
1472
1489
|
"require_extension": True,
|
|
@@ -1542,6 +1559,10 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
|
|
|
1542
1559
|
# Re-raise the original connection error
|
|
1543
1560
|
raise
|
|
1544
1561
|
|
|
1562
|
+
async def _start_playwright(self) -> None:
|
|
1563
|
+
self._playwright_context_manager = async_playwright()
|
|
1564
|
+
self._playwright = await self._playwright_context_manager.__aenter__()
|
|
1565
|
+
|
|
1545
1566
|
async def _stop_playwright(self) -> None:
|
|
1546
1567
|
if self._playwright_context_manager is None:
|
|
1547
1568
|
return
|
|
@@ -1582,6 +1603,7 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
|
|
|
1582
1603
|
session_id: str,
|
|
1583
1604
|
login_url: str,
|
|
1584
1605
|
cdp_auth_headers: dict[str, str],
|
|
1606
|
+
expected_browser_window_id: str | None = None,
|
|
1585
1607
|
) -> None:
|
|
1586
1608
|
assert self._playwright is not None
|
|
1587
1609
|
|
|
@@ -1593,6 +1615,23 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
|
|
|
1593
1615
|
# Navigate to login URL (provided by backend with custom token)
|
|
1594
1616
|
context = browser.contexts[0]
|
|
1595
1617
|
initialization_page = context.pages[0]
|
|
1618
|
+
if expected_browser_window_id is not None:
|
|
1619
|
+
# Put the backend-owned browser ID into sessionStorage before hydration
|
|
1620
|
+
# so AgentCore sessions use the right Firestore route when needed.
|
|
1621
|
+
expected_browser_window_id_json = json.dumps(expected_browser_window_id)
|
|
1622
|
+
await context.add_init_script(
|
|
1623
|
+
script=f"""
|
|
1624
|
+
(() => {{
|
|
1625
|
+
const expectedBrowserWindowId = {expected_browser_window_id_json};
|
|
1626
|
+
try {{
|
|
1627
|
+
sessionStorage.setItem(
|
|
1628
|
+
"naradaBrowserWindowId",
|
|
1629
|
+
expectedBrowserWindowId
|
|
1630
|
+
);
|
|
1631
|
+
}} catch (_error) {{}}
|
|
1632
|
+
}})();
|
|
1633
|
+
"""
|
|
1634
|
+
)
|
|
1596
1635
|
await initialization_page.goto(
|
|
1597
1636
|
login_url, timeout=15_000, wait_until="domcontentloaded"
|
|
1598
1637
|
)
|
|
@@ -1613,7 +1652,7 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
|
|
|
1613
1652
|
raise
|
|
1614
1653
|
logging.info("Waiting for Narada extension to be installed...")
|
|
1615
1654
|
await asyncio.sleep(1)
|
|
1616
|
-
except NaradaTimeoutError:
|
|
1655
|
+
except (NaradaTimeoutError, NaradaExtensionUnauthenticatedError):
|
|
1617
1656
|
if attempt == max_attempts - 1:
|
|
1618
1657
|
raise
|
|
1619
1658
|
# If browser window ID is not found, reload the page and try again
|
|
@@ -1622,6 +1661,15 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
|
|
|
1622
1661
|
login_url, timeout=15_000, wait_until="domcontentloaded"
|
|
1623
1662
|
)
|
|
1624
1663
|
|
|
1664
|
+
if (
|
|
1665
|
+
expected_browser_window_id is not None
|
|
1666
|
+
and browser_window_id != expected_browser_window_id
|
|
1667
|
+
):
|
|
1668
|
+
raise RuntimeError(
|
|
1669
|
+
"Initialized cloud session reported browserWindowId "
|
|
1670
|
+
f"{browser_window_id!r}, expected {expected_browser_window_id!r}."
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1625
1673
|
self._browser_window_id = browser_window_id
|
|
1626
1674
|
self._session_id = session_id
|
|
1627
1675
|
self._context = context
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import narada.environment as environment_module
|
|
4
|
+
import pytest
|
|
5
|
+
from narada import CloudBrowserEnvironment, RemoteBrowserEnvironment
|
|
6
|
+
from narada.config import BrowserConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _FakePage:
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.goto_calls: list[dict[str, object]] = []
|
|
12
|
+
self.url = "about:blank"
|
|
13
|
+
|
|
14
|
+
async def goto(self, url: str, **kwargs: object) -> None:
|
|
15
|
+
self.url = url
|
|
16
|
+
self.goto_calls.append({"url": url, **kwargs})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FakeContext:
|
|
20
|
+
def __init__(self, page: _FakePage) -> None:
|
|
21
|
+
self.pages = [page]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FakeBrowser:
|
|
25
|
+
def __init__(self, page: _FakePage) -> None:
|
|
26
|
+
self.contexts = [_FakeContext(page)]
|
|
27
|
+
self.close_calls = 0
|
|
28
|
+
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
self.close_calls += 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _FakeChromium:
|
|
34
|
+
def __init__(self, page: _FakePage) -> None:
|
|
35
|
+
self.browser = _FakeBrowser(page)
|
|
36
|
+
|
|
37
|
+
async def connect_over_cdp(
|
|
38
|
+
self, cdp_websocket_url: str, *, headers: dict[str, str]
|
|
39
|
+
) -> _FakeBrowser:
|
|
40
|
+
return self.browser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _FakePlaywright:
|
|
44
|
+
def __init__(self, page: _FakePage) -> None:
|
|
45
|
+
self.chromium = _FakeChromium(page)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _FakeRemoteDispatchPostResponse:
|
|
49
|
+
ok = True
|
|
50
|
+
status = 200
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> _FakeRemoteDispatchPostResponse:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def raise_for_status(self) -> None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
async def json(self) -> dict[str, object]:
|
|
62
|
+
return {"requestId": "request-123"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _FakeRemoteDispatchGetResponse:
|
|
66
|
+
ok = True
|
|
67
|
+
status = 200
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> _FakeRemoteDispatchGetResponse:
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def raise_for_status(self) -> None:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
async def json(self) -> dict[str, object]:
|
|
79
|
+
return {
|
|
80
|
+
"status": "success",
|
|
81
|
+
"response": {
|
|
82
|
+
"text": "done",
|
|
83
|
+
"output": None,
|
|
84
|
+
"executionTraceContext": {
|
|
85
|
+
"type": "executionTraceContext",
|
|
86
|
+
"executionTraceS3Key": "user-test/execution-trace/index.json",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
"createdAt": "2026-06-03T00:00:00Z",
|
|
90
|
+
"completedAt": "2026-06-03T00:00:01Z",
|
|
91
|
+
"usage": {"actions": 1, "credits": 0.1},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _FakeRemoteDispatchSession:
|
|
96
|
+
post_calls: list[dict[str, object]] = []
|
|
97
|
+
get_calls: list[dict[str, object]] = []
|
|
98
|
+
|
|
99
|
+
async def __aenter__(self) -> _FakeRemoteDispatchSession:
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def post(self, url: str, **kwargs: object) -> _FakeRemoteDispatchPostResponse:
|
|
106
|
+
self.post_calls.append({"url": url, **kwargs})
|
|
107
|
+
return _FakeRemoteDispatchPostResponse()
|
|
108
|
+
|
|
109
|
+
def get(self, url: str, **kwargs: object) -> _FakeRemoteDispatchGetResponse:
|
|
110
|
+
self.get_calls.append({"url": url, **kwargs})
|
|
111
|
+
return _FakeRemoteDispatchGetResponse()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_cloud_browser_initialization_uses_domcontentloaded_navigation(
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
117
|
+
) -> None:
|
|
118
|
+
page = _FakePage()
|
|
119
|
+
env = CloudBrowserEnvironment(
|
|
120
|
+
config=BrowserConfig(interactive=False),
|
|
121
|
+
auth_headers={"x-test": "true"},
|
|
122
|
+
)
|
|
123
|
+
env._playwright = _FakePlaywright(page)
|
|
124
|
+
|
|
125
|
+
async def wait_for_browser_window_id(*args: object, **kwargs: object) -> str:
|
|
126
|
+
return "window-123"
|
|
127
|
+
|
|
128
|
+
monkeypatch.setattr(
|
|
129
|
+
env, "_wait_for_cloud_browser_window_id", wait_for_browser_window_id
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
await env._initialize_cloud_browser_window(
|
|
133
|
+
cdp_websocket_url="wss://example.test/cdp",
|
|
134
|
+
session_id="session-123",
|
|
135
|
+
login_url="https://example.test/initialize",
|
|
136
|
+
cdp_auth_headers={"authorization": "Bearer test"},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert env.browser_window_id == "window-123"
|
|
140
|
+
assert env.cloud_browser_session_id == "session-123"
|
|
141
|
+
assert page.goto_calls == [
|
|
142
|
+
{
|
|
143
|
+
"url": "https://example.test/initialize",
|
|
144
|
+
"timeout": 15_000,
|
|
145
|
+
"wait_until": "domcontentloaded",
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_remote_dispatch_forwards_managed_cloud_browser_request(
|
|
152
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
153
|
+
) -> None:
|
|
154
|
+
_FakeRemoteDispatchSession.post_calls = []
|
|
155
|
+
_FakeRemoteDispatchSession.get_calls = []
|
|
156
|
+
monkeypatch.setenv("NARADA_API_BASE_URL", "https://api.example.test/fast/v2")
|
|
157
|
+
monkeypatch.setattr(
|
|
158
|
+
environment_module.aiohttp, "ClientSession", _FakeRemoteDispatchSession
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
env = RemoteBrowserEnvironment(
|
|
162
|
+
browser_window_id="sdk-managed-cloud-browser",
|
|
163
|
+
cloud_browser_session_id="cloud-session-123",
|
|
164
|
+
auth_headers={"x-test": "true"},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
response = await env._dispatch_request(
|
|
168
|
+
prompt="Fill the RPA challenge form.",
|
|
169
|
+
timeout=30,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert response["response"] is not None
|
|
173
|
+
assert response["response"]["executionTraceContext"] == {
|
|
174
|
+
"type": "executionTraceContext",
|
|
175
|
+
"executionTraceS3Key": "user-test/execution-trace/index.json",
|
|
176
|
+
}
|
|
177
|
+
assert len(_FakeRemoteDispatchSession.post_calls) == 1
|
|
178
|
+
post_call = _FakeRemoteDispatchSession.post_calls[0]
|
|
179
|
+
assert post_call["url"] == "https://api.example.test/fast/v2/remote-dispatch"
|
|
180
|
+
assert post_call["headers"] == {"x-test": "true"}
|
|
181
|
+
assert post_call["json"] == {
|
|
182
|
+
"prompt": "/Operator Fill the RPA challenge form.",
|
|
183
|
+
"browserWindowId": "sdk-managed-cloud-browser",
|
|
184
|
+
"timeZone": "America/Los_Angeles",
|
|
185
|
+
"cloudBrowserSessionId": "cloud-session-123",
|
|
186
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from types import SimpleNamespace
|
|
2
3
|
from unittest.mock import AsyncMock, call
|
|
3
4
|
|
|
@@ -79,7 +80,9 @@ def _build_cloud_environment_with_page(page: AsyncMock) -> CloudBrowserEnvironme
|
|
|
79
80
|
auth_headers={"x-api-key": "test-key"},
|
|
80
81
|
config=BrowserConfig(interactive=False),
|
|
81
82
|
)
|
|
82
|
-
browser = SimpleNamespace(
|
|
83
|
+
browser = SimpleNamespace(
|
|
84
|
+
contexts=[SimpleNamespace(pages=[page], add_init_script=AsyncMock())]
|
|
85
|
+
)
|
|
83
86
|
env._playwright = SimpleNamespace(
|
|
84
87
|
chromium=SimpleNamespace(connect_over_cdp=AsyncMock(return_value=browser))
|
|
85
88
|
)
|
|
@@ -179,6 +182,91 @@ async def test_dispatch_request_calls_input_required_callback_once_per_input_id(
|
|
|
179
182
|
assert sleep.await_count == 3
|
|
180
183
|
|
|
181
184
|
|
|
185
|
+
@pytest.mark.asyncio
|
|
186
|
+
async def test_dispatch_request_includes_execution_trace_context(
|
|
187
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
188
|
+
) -> None:
|
|
189
|
+
import narada.environment as environment_module
|
|
190
|
+
|
|
191
|
+
trace_context = {
|
|
192
|
+
"type": "executionTraceInheritanceContext",
|
|
193
|
+
"schemaVersion": 1,
|
|
194
|
+
"traceId": "trace-parent",
|
|
195
|
+
"parentSegmentId": "segment-local",
|
|
196
|
+
}
|
|
197
|
+
monkeypatch.setenv("NARADA_EXECUTION_TRACE_CONTEXT", json.dumps(trace_context))
|
|
198
|
+
fake_session = _RemoteDispatchFakeClientSession(
|
|
199
|
+
[
|
|
200
|
+
{
|
|
201
|
+
"status": "success",
|
|
202
|
+
"response": {"text": "ok"},
|
|
203
|
+
"usage": {"actions": 1, "credits": 1},
|
|
204
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
205
|
+
"completedAt": "2026-01-01T00:00:01Z",
|
|
206
|
+
"activeInputRequest": None,
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
monkeypatch.setattr(
|
|
211
|
+
environment_module.aiohttp, "ClientSession", lambda: fake_session
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
env = RemoteBrowserEnvironment(browser_window_id="bw-1", api_key="test-key")
|
|
215
|
+
response = await env._dispatch_request(prompt="Summarize", timeout=5)
|
|
216
|
+
|
|
217
|
+
assert response["status"] == "success"
|
|
218
|
+
assert fake_session.dispatched_body is not None
|
|
219
|
+
assert fake_session.dispatched_body["executionTraceContext"] == trace_context
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_extension_action_request_includes_action_execution_id(
|
|
224
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
225
|
+
) -> None:
|
|
226
|
+
import narada.environment as environment_module
|
|
227
|
+
|
|
228
|
+
fake_session = _FakeClientSession({"status": "success", "data": None})
|
|
229
|
+
monkeypatch.setattr(
|
|
230
|
+
environment_module.aiohttp, "ClientSession", lambda: fake_session
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
env = RemoteBrowserEnvironment(browser_window_id="bw-1", api_key="test-key")
|
|
234
|
+
await env.close()
|
|
235
|
+
|
|
236
|
+
assert fake_session.posts
|
|
237
|
+
action_execution_id = fake_session.posts[0]["json"]["actionExecutionId"]
|
|
238
|
+
assert action_execution_id.startswith("action_")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.asyncio
|
|
242
|
+
async def test_remote_browser_environment_with_cloud_session_stops_session_by_default(
|
|
243
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
244
|
+
) -> None:
|
|
245
|
+
import narada.environment as environment_module
|
|
246
|
+
|
|
247
|
+
stop_cloud_browser_session = AsyncMock()
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
environment_module,
|
|
250
|
+
"_stop_cloud_browser_session",
|
|
251
|
+
stop_cloud_browser_session,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
env = RemoteBrowserEnvironment(
|
|
255
|
+
browser_window_id="browser-window-123",
|
|
256
|
+
cloud_browser_session_id="session-123",
|
|
257
|
+
auth_headers={"x-api-key": "test-key"},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
await env.close()
|
|
261
|
+
|
|
262
|
+
stop_cloud_browser_session.assert_awaited_once_with(
|
|
263
|
+
base_url=env._base_url,
|
|
264
|
+
auth_headers={"x-api-key": "test-key"},
|
|
265
|
+
session_id="session-123",
|
|
266
|
+
timeout=None,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
182
270
|
@pytest.mark.asyncio
|
|
183
271
|
async def test_lambda_environment_uses_backend_initialization(
|
|
184
272
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -255,6 +343,7 @@ async def test_cloud_browser_environment_uses_domcontentloaded_for_login_navigat
|
|
|
255
343
|
) -> None:
|
|
256
344
|
page = AsyncMock()
|
|
257
345
|
env = _build_cloud_environment_with_page(page)
|
|
346
|
+
context = env._playwright.chromium.connect_over_cdp.return_value.contexts[0]
|
|
258
347
|
|
|
259
348
|
wait_for_browser_window_id = AsyncMock(return_value="browser-window-123")
|
|
260
349
|
monkeypatch.setattr(
|
|
@@ -278,10 +367,69 @@ async def test_cloud_browser_environment_uses_domcontentloaded_for_login_navigat
|
|
|
278
367
|
BrowserConfig(interactive=False),
|
|
279
368
|
timeout=30_000,
|
|
280
369
|
)
|
|
370
|
+
context.add_init_script.assert_not_awaited()
|
|
281
371
|
assert env.browser_window_id == "browser-window-123"
|
|
282
372
|
assert env.cloud_browser_session_id == "session-123"
|
|
283
373
|
|
|
284
374
|
|
|
375
|
+
@pytest.mark.asyncio
|
|
376
|
+
async def test_cloud_browser_environment_seeds_expected_browser_window_id_before_navigation(
|
|
377
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
378
|
+
) -> None:
|
|
379
|
+
page = AsyncMock()
|
|
380
|
+
env = _build_cloud_environment_with_page(page)
|
|
381
|
+
context = env._playwright.chromium.connect_over_cdp.return_value.contexts[0]
|
|
382
|
+
events: list[str] = []
|
|
383
|
+
|
|
384
|
+
async def add_init_script(*args, **kwargs) -> None:
|
|
385
|
+
events.append("seed")
|
|
386
|
+
|
|
387
|
+
async def goto(*args, **kwargs) -> None:
|
|
388
|
+
events.append("goto")
|
|
389
|
+
|
|
390
|
+
context.add_init_script.side_effect = add_init_script
|
|
391
|
+
page.goto.side_effect = goto
|
|
392
|
+
wait_for_browser_window_id = AsyncMock(return_value="backend-window-123")
|
|
393
|
+
monkeypatch.setattr(
|
|
394
|
+
env, "_wait_for_cloud_browser_window_id", wait_for_browser_window_id
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
await env._initialize_cloud_browser_window(
|
|
398
|
+
cdp_websocket_url="wss://agentcore.example.test/session-123",
|
|
399
|
+
session_id="session-123",
|
|
400
|
+
login_url="https://app.narada.ai/chat?customToken=test-token",
|
|
401
|
+
cdp_auth_headers={"Authorization": "signed-cdp"},
|
|
402
|
+
expected_browser_window_id="backend-window-123",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
assert events[:2] == ["seed", "goto"]
|
|
406
|
+
script = context.add_init_script.await_args.kwargs["script"]
|
|
407
|
+
assert "naradaBrowserWindowId" in script
|
|
408
|
+
assert "backend-window-123" in script
|
|
409
|
+
assert env.browser_window_id == "backend-window-123"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@pytest.mark.asyncio
|
|
413
|
+
async def test_cloud_browser_environment_rejects_unexpected_seeded_browser_window_id(
|
|
414
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
415
|
+
) -> None:
|
|
416
|
+
page = AsyncMock()
|
|
417
|
+
env = _build_cloud_environment_with_page(page)
|
|
418
|
+
wait_for_browser_window_id = AsyncMock(return_value="frontend-window-123")
|
|
419
|
+
monkeypatch.setattr(
|
|
420
|
+
env, "_wait_for_cloud_browser_window_id", wait_for_browser_window_id
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
with pytest.raises(RuntimeError, match="expected 'backend-window-123'"):
|
|
424
|
+
await env._initialize_cloud_browser_window(
|
|
425
|
+
cdp_websocket_url="wss://agentcore.example.test/session-123",
|
|
426
|
+
session_id="session-123",
|
|
427
|
+
login_url="https://app.narada.ai/chat?customToken=test-token",
|
|
428
|
+
cdp_auth_headers={"Authorization": "signed-cdp"},
|
|
429
|
+
expected_browser_window_id="backend-window-123",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
285
433
|
@pytest.mark.asyncio
|
|
286
434
|
async def test_cloud_browser_environment_uses_domcontentloaded_for_retry_navigation(
|
|
287
435
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -355,3 +503,72 @@ async def test_agent_run_exposes_workflow_trace_alias(
|
|
|
355
503
|
|
|
356
504
|
assert response.workflow_trace == workflow_trace
|
|
357
505
|
assert response.model_dump(by_alias=True)["workflowTrace"] == workflow_trace
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@pytest.mark.asyncio
|
|
509
|
+
async def test_agent_run_appends_critic_workflow_trace(
|
|
510
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
511
|
+
) -> None:
|
|
512
|
+
workflow_trace = {
|
|
513
|
+
"workflowId": "main-workflow",
|
|
514
|
+
"workflowName": "Main Workflow",
|
|
515
|
+
"runtime": "gui",
|
|
516
|
+
"status": "success",
|
|
517
|
+
"startTs": 100,
|
|
518
|
+
"children": [],
|
|
519
|
+
}
|
|
520
|
+
critic_workflow_trace = {
|
|
521
|
+
"workflowId": "critic-workflow",
|
|
522
|
+
"workflowName": "Critic Workflow",
|
|
523
|
+
"runtime": "gui",
|
|
524
|
+
"status": "success",
|
|
525
|
+
"startTs": 200,
|
|
526
|
+
"children": [],
|
|
527
|
+
}
|
|
528
|
+
env = CloudBrowserEnvironment(
|
|
529
|
+
auth_headers={"x-api-key": "test-key"},
|
|
530
|
+
)
|
|
531
|
+
agent = Agent(environment=env)
|
|
532
|
+
monkeypatch.setattr(
|
|
533
|
+
agent,
|
|
534
|
+
"_dispatch_request",
|
|
535
|
+
AsyncMock(
|
|
536
|
+
side_effect=[
|
|
537
|
+
{
|
|
538
|
+
"requestId": "request-123",
|
|
539
|
+
"status": "success",
|
|
540
|
+
"response": {
|
|
541
|
+
"text": "done",
|
|
542
|
+
"output": {"type": "text", "content": "done"},
|
|
543
|
+
"workflowTrace": workflow_trace,
|
|
544
|
+
},
|
|
545
|
+
"usage": {"actions": 0, "credits": 0},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
"requestId": "critic-request-123",
|
|
549
|
+
"status": "success",
|
|
550
|
+
"response": {
|
|
551
|
+
"text": '{"narada_validation_passed":true}',
|
|
552
|
+
"output": {
|
|
553
|
+
"type": "structured",
|
|
554
|
+
"content": {"narada_validation_passed": True},
|
|
555
|
+
},
|
|
556
|
+
"structuredOutput": SimpleNamespace(
|
|
557
|
+
narada_validation_passed=True
|
|
558
|
+
),
|
|
559
|
+
"workflowTrace": critic_workflow_trace,
|
|
560
|
+
},
|
|
561
|
+
"usage": {"actions": 0, "credits": 0},
|
|
562
|
+
},
|
|
563
|
+
]
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
response = await agent.run("return a trace", critic={})
|
|
568
|
+
|
|
569
|
+
assert response.critic_result is not None
|
|
570
|
+
assert response.critic_result.workflow_trace == critic_workflow_trace
|
|
571
|
+
assert response.workflow_trace == {
|
|
572
|
+
**workflow_trace,
|
|
573
|
+
"children": [{"kind": "sub_workflow", "trace": critic_workflow_trace}],
|
|
574
|
+
}
|
|
@@ -108,3 +108,35 @@ async def test_user_approval_respects_explicit_timeout(
|
|
|
108
108
|
|
|
109
109
|
assert approved is True
|
|
110
110
|
assert fake_session.post_bodies[0]["timeout"] == 600
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
async def test_execute_javascript_on_page_dispatches_extension_action(
|
|
115
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
116
|
+
) -> None:
|
|
117
|
+
fake_session = _FakeSession(
|
|
118
|
+
[
|
|
119
|
+
{
|
|
120
|
+
"status": "success",
|
|
121
|
+
"data": '{"result":{"title":"Example Domain","count":3}}',
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
monkeypatch.setattr(
|
|
126
|
+
"narada.environment.aiohttp.ClientSession", lambda: fake_session
|
|
127
|
+
)
|
|
128
|
+
agent = Agent(
|
|
129
|
+
environment=RemoteBrowserEnvironment(
|
|
130
|
+
browser_window_id="bw-1", api_key="test-key"
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
result = await agent.execute_javascript_on_page(
|
|
135
|
+
code="(() => ({ title: document.title, count: 3 }))()",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
assert result == {"title": "Example Domain", "count": 3}
|
|
139
|
+
assert fake_session.post_bodies[0]["action"] == {
|
|
140
|
+
"name": "execute_javascript_on_page",
|
|
141
|
+
"code": "(() => ({ title: document.title, count: 3 }))()",
|
|
142
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|