browserrig 0.1.0__py3-none-any.whl

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.
Files changed (167) hide show
  1. browserrig/__init__.py +158 -0
  2. browserrig/activity/__init__.py +5 -0
  3. browserrig/activity/models.py +93 -0
  4. browserrig/activity/plugin.py +247 -0
  5. browserrig/activity/state.py +16 -0
  6. browserrig/agent.py +55 -0
  7. browserrig/browser/__init__.py +33 -0
  8. browserrig/browser/base.py +70 -0
  9. browserrig/browser/chrome.py +242 -0
  10. browserrig/browser/config.py +119 -0
  11. browserrig/browser/downloads.py +128 -0
  12. browserrig/browser/favicon.py +119 -0
  13. browserrig/browser/frame.py +227 -0
  14. browserrig/browser/frames.py +116 -0
  15. browserrig/browser/images.py +23 -0
  16. browserrig/browser/input.py +147 -0
  17. browserrig/browser/models.py +8 -0
  18. browserrig/browser/plugin.py +44 -0
  19. browserrig/browser/process.py +144 -0
  20. browserrig/browser/setup.py +12 -0
  21. browserrig/browser/tab.py +311 -0
  22. browserrig/browser/tabs.py +366 -0
  23. browserrig/browser/worlds.py +64 -0
  24. browserrig/choreography/__init__.py +31 -0
  25. browserrig/choreography/events.py +133 -0
  26. browserrig/choreography/plugin.py +75 -0
  27. browserrig/choreography/scenes.py +167 -0
  28. browserrig/choreography/stage.py +53 -0
  29. browserrig/choreography/tabstrip.py +112 -0
  30. browserrig/completion.py +184 -0
  31. browserrig/composition.py +108 -0
  32. browserrig/core/__init__.py +61 -0
  33. browserrig/core/errors.py +18 -0
  34. browserrig/core/geometry.py +149 -0
  35. browserrig/core/ids.py +10 -0
  36. browserrig/core/kinds.py +180 -0
  37. browserrig/core/options.py +7 -0
  38. browserrig/core/tools.py +32 -0
  39. browserrig/events.py +665 -0
  40. browserrig/files/__init__.py +25 -0
  41. browserrig/files/config.py +13 -0
  42. browserrig/files/containers.py +200 -0
  43. browserrig/files/content.py +65 -0
  44. browserrig/files/data.py +80 -0
  45. browserrig/files/documents.py +248 -0
  46. browserrig/files/formats.py +84 -0
  47. browserrig/files/imaging.py +138 -0
  48. browserrig/files/kinds.py +78 -0
  49. browserrig/files/models.py +71 -0
  50. browserrig/files/reading.py +382 -0
  51. browserrig/files/spreadsheets.py +417 -0
  52. browserrig/files/system.py +204 -0
  53. browserrig/files/text.py +64 -0
  54. browserrig/files/xlsx.py +53 -0
  55. browserrig/handoff/__init__.py +4 -0
  56. browserrig/handoff/changes.py +48 -0
  57. browserrig/handoff/models.py +31 -0
  58. browserrig/handoff/plugin.py +308 -0
  59. browserrig/handoff/state.py +21 -0
  60. browserrig/input/__init__.py +35 -0
  61. browserrig/input/controller.py +69 -0
  62. browserrig/input/keyboard.py +267 -0
  63. browserrig/input/motor.py +118 -0
  64. browserrig/input/mouse.py +346 -0
  65. browserrig/input/pointer.py +84 -0
  66. browserrig/input/presenter.py +49 -0
  67. browserrig/input/target.py +67 -0
  68. browserrig/input/wheel.py +40 -0
  69. browserrig/kinds.py +33 -0
  70. browserrig/nudges/__init__.py +3 -0
  71. browserrig/nudges/plugin.py +295 -0
  72. browserrig/nudges/state.py +23 -0
  73. browserrig/observation/__init__.py +53 -0
  74. browserrig/observation/capture.py +374 -0
  75. browserrig/observation/changes.py +63 -0
  76. browserrig/observation/config.py +12 -0
  77. browserrig/observation/content.py +176 -0
  78. browserrig/observation/delta.py +69 -0
  79. browserrig/observation/dom.py +97 -0
  80. browserrig/observation/extraction/__init__.py +335 -0
  81. browserrig/observation/extraction/accessibility.py +62 -0
  82. browserrig/observation/extraction/document.py +471 -0
  83. browserrig/observation/extraction/elements.py +676 -0
  84. browserrig/observation/extraction/semantics.py +351 -0
  85. browserrig/observation/extraction/styles.py +30 -0
  86. browserrig/observation/extraction/visibility.py +108 -0
  87. browserrig/observation/find.py +152 -0
  88. browserrig/observation/handle.py +170 -0
  89. browserrig/observation/images.py +243 -0
  90. browserrig/observation/marks.py +157 -0
  91. browserrig/observation/models.py +230 -0
  92. browserrig/observation/plugin.py +413 -0
  93. browserrig/observation/redirects.py +40 -0
  94. browserrig/observation/references.py +47 -0
  95. browserrig/observation/render.py +291 -0
  96. browserrig/observation/renderer.py +76 -0
  97. browserrig/observation/state.py +37 -0
  98. browserrig/pacing/__init__.py +5 -0
  99. browserrig/pacing/plugin.py +22 -0
  100. browserrig/py.typed +0 -0
  101. browserrig/readiness/__init__.py +25 -0
  102. browserrig/readiness/config.py +12 -0
  103. browserrig/readiness/dom.py +83 -0
  104. browserrig/readiness/downloads.py +30 -0
  105. browserrig/readiness/hooks.py +27 -0
  106. browserrig/readiness/models.py +13 -0
  107. browserrig/readiness/plugin.py +170 -0
  108. browserrig/readiness/policies.py +43 -0
  109. browserrig/readiness/state.py +20 -0
  110. browserrig/readiness/strategy.py +30 -0
  111. browserrig/readiness/wait.py +238 -0
  112. browserrig/scripts/README.md +39 -0
  113. browserrig/scripts/__init__.py +186 -0
  114. browserrig/scripts/content.js +526 -0
  115. browserrig/scripts/cursor.js +57 -0
  116. browserrig/scripts/element.js +82 -0
  117. browserrig/scripts/find.js +377 -0
  118. browserrig/scripts/focused.js +15 -0
  119. browserrig/scripts/lib/composed.js +21 -0
  120. browserrig/scripts/lib/elements.js +9 -0
  121. browserrig/scripts/lib/geometry.js +13 -0
  122. browserrig/scripts/lib/hit.js +84 -0
  123. browserrig/scripts/lib/markdown.js +32 -0
  124. browserrig/scripts/lib/scroll.js +226 -0
  125. browserrig/scripts/lib/secret.js +74 -0
  126. browserrig/scripts/lib/visibility.js +42 -0
  127. browserrig/scripts/models.py +153 -0
  128. browserrig/scripts/owner_hits.js +4 -0
  129. browserrig/scripts/owner_scroll.js +7 -0
  130. browserrig/scripts/owner_size.js +11 -0
  131. browserrig/scripts/page_facts.js +9 -0
  132. browserrig/scripts/reset.js +4 -0
  133. browserrig/scripts/scroll.js +5 -0
  134. browserrig/scripts/secret_field.js +7 -0
  135. browserrig/scripts/secret_focus.js +31 -0
  136. browserrig/scripts/seed.js +13 -0
  137. browserrig/scripts/stability.js +140 -0
  138. browserrig/scripts/tab_strip.js +27 -0
  139. browserrig/scripts/viewport.js +3 -0
  140. browserrig/secrets/__init__.py +5 -0
  141. browserrig/secrets/models.py +90 -0
  142. browserrig/secrets/origin.py +95 -0
  143. browserrig/secrets/plugin.py +397 -0
  144. browserrig/secrets/redaction.py +107 -0
  145. browserrig/secrets/state.py +6 -0
  146. browserrig/secrets/totp.py +62 -0
  147. browserrig/terminal.py +120 -0
  148. browserrig/tools/__init__.py +51 -0
  149. browserrig/tools/chains.py +55 -0
  150. browserrig/tools/dialogs.py +23 -0
  151. browserrig/tools/effects.py +8 -0
  152. browserrig/tools/execution.py +333 -0
  153. browserrig/tools/files.py +411 -0
  154. browserrig/tools/find.py +125 -0
  155. browserrig/tools/forms.py +76 -0
  156. browserrig/tools/intents.py +82 -0
  157. browserrig/tools/javascript.py +37 -0
  158. browserrig/tools/keyboard.py +126 -0
  159. browserrig/tools/mouse.py +224 -0
  160. browserrig/tools/navigation.py +189 -0
  161. browserrig/tools/scrolling.py +39 -0
  162. browserrig/tools/tabs.py +81 -0
  163. browserrig/tools/targeting.py +59 -0
  164. browserrig-0.1.0.dist-info/METADATA +174 -0
  165. browserrig-0.1.0.dist-info/RECORD +167 -0
  166. browserrig-0.1.0.dist-info/WHEEL +4 -0
  167. browserrig-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,37 @@
1
+ from typing import Annotated
2
+
3
+ from harnessed import Inject, Plugin, ToolResult
4
+ from pydantic import Field
5
+
6
+ from browserrig.browser import Tab
7
+ from browserrig.core import ActionKind
8
+ from browserrig.core import browser_tool as tool
9
+
10
+ from .effects import Effect
11
+ from .execution import Actions
12
+
13
+
14
+ class JavaScriptTools(Plugin, needs=(Actions,)):
15
+ @tool(
16
+ kind=ActionKind.JAVASCRIPT,
17
+ description=(
18
+ "Execute JavaScript in the active tab's main page context via Runtime.evaluate. "
19
+ "Supports top-level await and retains variables until navigation. Use await for "
20
+ "Promise results. Returns the last "
21
+ "expression's value as JSON, or a JavaScript value such as undefined or NaN. "
22
+ "Return serializable data when inspecting objects. Errors are reported in the tool "
23
+ "result. timeout is in seconds."
24
+ ),
25
+ )
26
+ async def execute_javascript(
27
+ self,
28
+ code: str,
29
+ timeout: Annotated[float, Field(gt=0, le=30)] = 10,
30
+ *,
31
+ actions: Inject[Actions],
32
+ ) -> ToolResult:
33
+ async def execute(tab: Tab) -> Effect:
34
+ result = await tab.evaluate(code, timeout=timeout)
35
+ return Effect(f"JavaScript result:\n{result}")
36
+
37
+ return await actions.run(kind=ActionKind.JAVASCRIPT, operation=execute)
@@ -0,0 +1,126 @@
1
+ from harnessed import Inject, Plugin, ToolError, ToolResult
2
+ from harnessed.kernel import dependency
3
+
4
+ from browserrig.browser import Browser, Tab
5
+ from browserrig.core import ActionKind
6
+ from browserrig.core import browser_tool as tool
7
+ from browserrig.input import TypedText
8
+ from browserrig.observation import Observe
9
+ from browserrig.secrets import Secrets
10
+
11
+ from .effects import Effect
12
+ from .execution import Actions
13
+
14
+
15
+ class KeyboardTools(Plugin, needs=(Actions,)):
16
+ """Keyboard input to the current focus, plus typing into a DOM ref when refs exist."""
17
+
18
+ _browser = dependency(Browser)
19
+ _observe = dependency(Observe)
20
+
21
+ @tool(
22
+ kind=ActionKind.PRESS,
23
+ chainable=True,
24
+ description=(
25
+ "Send keyboard input to the current page focus. Provide exactly one of "
26
+ "text for literal text, or key for one character or a named key such as "
27
+ "Escape, Enter or Tab. Use hold_control, hold_shift, hold_alt and hold_meta "
28
+ "for shortcuts, e.g. key='a', hold_control=true selects all."
29
+ ),
30
+ )
31
+ async def send_keys(
32
+ self,
33
+ *,
34
+ text: str | None = None,
35
+ key: str | None = None,
36
+ hold_control: bool = False,
37
+ hold_shift: bool = False,
38
+ hold_alt: bool = False,
39
+ hold_meta: bool = False,
40
+ actions: Inject[Actions],
41
+ secrets: Inject[Secrets | None] = None,
42
+ ) -> ToolResult:
43
+ if (text is None) == (key is None):
44
+ raise ToolError("Provide exactly one of text or key")
45
+ if text is not None and any((hold_control, hold_shift, hold_alt, hold_meta)):
46
+ raise ToolError("Modifier flags require key")
47
+
48
+ async def send(_tab: Tab) -> Effect:
49
+ if key is not None:
50
+ await self._browser.keyboard.press_key(
51
+ key,
52
+ hold_control=hold_control,
53
+ hold_shift=hold_shift,
54
+ hold_alt=hold_alt,
55
+ hold_meta=hold_meta,
56
+ )
57
+ return Effect("Key dispatched.")
58
+ assert text is not None
59
+ if secrets is not None:
60
+ entry = await secrets.prepare(text, browser=self._browser)
61
+ if entry is not None:
62
+ return _secret_entered(
63
+ await secrets.enter(entry, self._browser.keyboard)
64
+ )
65
+ return _entered(await self._browser.keyboard.type_text(text))
66
+
67
+ return await actions.run(kind=ActionKind.PRESS, operation=send)
68
+
69
+ @tool(
70
+ kind=ActionKind.TYPE,
71
+ chainable=True,
72
+ description="Type exact text into a DOM ref; clear replaces its value by default.",
73
+ )
74
+ async def type_text(
75
+ self,
76
+ ref: str,
77
+ text: str,
78
+ clear: bool = True,
79
+ *,
80
+ actions: Inject[Actions],
81
+ secrets: Inject[Secrets | None] = None,
82
+ ) -> ToolResult:
83
+ async def check(tab: Tab) -> None:
84
+ await self._observe.dom_element(tab, ref)
85
+
86
+ async def type_text(tab: Tab) -> Effect:
87
+ point = await actions.element_point(tab=tab, ref=ref)
88
+ await self._browser.mouse.move_to(point)
89
+ # Hover may reveal an overlay over the field; hit-test before focusing it.
90
+ await actions.element_point(tab=tab, ref=ref, scroll=False)
91
+ field = await self._observe.dom_element(tab, ref)
92
+ # Check the secrets' domains before the field is touched.
93
+ entry = (
94
+ await secrets.prepare(text, browser=self._browser, field=field)
95
+ if secrets is not None
96
+ else None
97
+ )
98
+ await field.focus(select_all=clear)
99
+ if clear:
100
+ await self._browser.keyboard.press_key("Backspace")
101
+ if entry is not None and secrets is not None:
102
+ return _secret_entered(
103
+ await secrets.enter(entry, self._browser.keyboard)
104
+ )
105
+ return _entered(await self._browser.keyboard.type_text(text))
106
+
107
+ return await actions.run(kind=ActionKind.TYPE, operation=type_text, check=check)
108
+
109
+
110
+ def _entered(typed: TypedText) -> Effect:
111
+ if typed.exact:
112
+ return Effect("Text entered.")
113
+ return Effect(
114
+ f"Text entered with a slip: typed {typed.entered!r} instead of "
115
+ f"{typed.intended!r}. Check the field before continuing."
116
+ )
117
+
118
+
119
+ def _secret_entered(exact: bool) -> Effect:
120
+ if exact:
121
+ return Effect("Text entered.")
122
+ # Never echo text that surrounds a secret.
123
+ return Effect(
124
+ "Text entered with a slip in the text around the secret. "
125
+ "Check the field before continuing."
126
+ )
@@ -0,0 +1,224 @@
1
+ from typing import Annotated, Literal
2
+
3
+ from harnessed import Inject, Instructions, Plugin, SystemPrompt, ToolResult
4
+ from harnessed.kernel import dependency
5
+ from pydantic import Field
6
+
7
+ from browserrig.browser import Browser, Tab
8
+ from browserrig.core import ActionKind, BrowserError, Point
9
+ from browserrig.core import browser_tool as tool
10
+ from browserrig.observation import CapturedImage, Observe, Snapshot
11
+
12
+ from .effects import Effect
13
+ from .execution import Actions
14
+
15
+ type _ClickCount = Annotated[
16
+ Literal[1, 2],
17
+ Field(description="2 = native double click (open, edit, select word)"),
18
+ ]
19
+ type _ClickButton = Annotated[
20
+ Literal["left", "right"], Field(description="Mouse button to press.")
21
+ ]
22
+
23
+
24
+ _INSTRUCTIONS = Instructions(
25
+ title="Screenshot coordinates",
26
+ text=(
27
+ "Coordinates are pixels from the top-left of the newest shown image for this document; "
28
+ "region and canvas images are mapped back to the viewport automatically. "
29
+ "A red cursor marker shows the pointer position when the screenshot was captured; "
30
+ "it is omitted when outside the image. "
31
+ "Observe again if the tab or document changes before pointing."
32
+ ),
33
+ )
34
+
35
+
36
+ class PointerTools(Plugin, needs=(Actions,)):
37
+ """Mouse input by screenshot coordinates, plus by observed ref when refs exist.
38
+
39
+ Coordinates also reach unreferenced regions, so text-only agents keep them too.
40
+ """
41
+
42
+ _browser = dependency(Browser)
43
+ _observe = dependency(Observe)
44
+
45
+ def instructions(self) -> SystemPrompt:
46
+ return SystemPrompt(_INSTRUCTIONS)
47
+
48
+ @tool(
49
+ kind=ActionKind.MOVE_MOUSE,
50
+ description=(
51
+ "Move the cursor to image or viewport coordinates without clicking. "
52
+ "Use before scroll to target a particular scrollable area, or to hover."
53
+ ),
54
+ )
55
+ async def move_mouse(
56
+ self,
57
+ x: Annotated[float, Field(ge=0)],
58
+ y: Annotated[float, Field(ge=0)],
59
+ *,
60
+ actions: Inject[Actions],
61
+ ) -> ToolResult:
62
+ async def move(_tab: Tab) -> Effect:
63
+ point = _image_point(self._observe.coordinate_source(), Point(x=x, y=y))
64
+ await self._browser.mouse.move_to(point)
65
+ return Effect("Cursor moved without clicking.")
66
+
67
+ return await actions.run(kind=ActionKind.MOVE_MOUSE, operation=move)
68
+
69
+ @tool(
70
+ kind=ActionKind.CLICK_AT,
71
+ description=(
72
+ "Automatically move the cursor to image or viewport coordinates and click there; "
73
+ "count=2 performs a native double click; button=right performs a right click."
74
+ ),
75
+ )
76
+ async def click_at(
77
+ self,
78
+ x: Annotated[float, Field(ge=0)],
79
+ y: Annotated[float, Field(ge=0)],
80
+ count: _ClickCount = 1,
81
+ button: _ClickButton = "left",
82
+ *,
83
+ actions: Inject[Actions],
84
+ ) -> ToolResult:
85
+ async def click(_tab: Tab) -> Effect:
86
+ point = _image_point(self._observe.coordinate_source(), Point(x=x, y=y))
87
+ await self._browser.mouse.click(point, count=count, button=button)
88
+ return Effect(_click_message(count, button), break_chain=button == "right")
89
+
90
+ return await actions.run(
91
+ kind=ActionKind.CLICK_AT, operation=click, expects_change=True
92
+ )
93
+
94
+ @tool(
95
+ kind=ActionKind.SCROLL,
96
+ description=(
97
+ "Scroll vertically by CSS pixels at the current cursor position. "
98
+ "Use move_mouse first to target a particular scrollable area."
99
+ ),
100
+ )
101
+ async def scroll(
102
+ self,
103
+ delta_y: Annotated[float, Field(ge=-10000, le=10000)],
104
+ *,
105
+ actions: Inject[Actions],
106
+ ) -> ToolResult:
107
+ async def scroll(_tab: Tab) -> Effect:
108
+ await self._browser.mouse.scroll(delta_y=delta_y)
109
+ return Effect("Scroll dispatched.")
110
+
111
+ return await actions.run(
112
+ kind=ActionKind.SCROLL,
113
+ operation=scroll,
114
+ )
115
+
116
+ @tool(
117
+ kind=ActionKind.DRAG_AT,
118
+ description="Drag between screenshot points.",
119
+ )
120
+ async def drag_at(
121
+ self,
122
+ start: Point,
123
+ end: Point,
124
+ duration: Annotated[float, Field(ge=0.05, le=5)] = 0.5,
125
+ *,
126
+ actions: Inject[Actions],
127
+ ) -> ToolResult:
128
+ async def drag(_tab: Tab) -> Effect:
129
+ await self._browser.mouse.drag(
130
+ start=_image_point(self._observe.coordinate_source(), start),
131
+ end=_image_point(self._observe.coordinate_source(), end),
132
+ duration=duration,
133
+ )
134
+ return Effect("Drag dispatched; inspect the destination.")
135
+
136
+ return await actions.run(
137
+ kind=ActionKind.DRAG_AT, operation=drag, expects_change=True
138
+ )
139
+
140
+ @tool(
141
+ kind=ActionKind.CLICK,
142
+ chainable=True,
143
+ description=(
144
+ "Automatically move the cursor to a current observed ref and click it; "
145
+ "count=2 performs a native double click; "
146
+ "button=right performs a right click."
147
+ ),
148
+ )
149
+ async def click(
150
+ self,
151
+ ref: str,
152
+ count: _ClickCount = 1,
153
+ button: _ClickButton = "left",
154
+ *,
155
+ actions: Inject[Actions],
156
+ ) -> ToolResult:
157
+ async def click(tab: Tab) -> Effect:
158
+ point = await actions.element_point(tab=tab, ref=ref)
159
+ await self._browser.mouse.move_to(point)
160
+ if change := await actions.page_change(tab, after_input=False):
161
+ return Effect(
162
+ f"Hover changed the page: {change} The click was not dispatched. "
163
+ "Inspect the next observation before choosing a target.",
164
+ prepared_scroll=point.scrolled,
165
+ break_chain=True,
166
+ )
167
+ # Hover may reveal an overlay or move the target; hit-test again.
168
+ current = await actions.element_point(tab=tab, ref=ref, scroll=False)
169
+ await self._browser.mouse.click(current, count=count, button=button)
170
+ return Effect(
171
+ _click_message(count, button),
172
+ prepared_scroll=point.scrolled,
173
+ break_chain=button == "right",
174
+ )
175
+
176
+ return await actions.run(
177
+ kind=ActionKind.CLICK, operation=click, expects_change=True
178
+ )
179
+
180
+ @tool(
181
+ kind=ActionKind.DRAG,
182
+ description=(
183
+ "Drag between observed refs, scrolling them into view when supported. "
184
+ "Both endpoints must fit in the viewport together."
185
+ ),
186
+ )
187
+ async def drag(
188
+ self,
189
+ source_ref: str,
190
+ target_ref: str,
191
+ duration: Annotated[float, Field(ge=0.05, le=5)] = 0.5,
192
+ *,
193
+ actions: Inject[Actions],
194
+ ) -> ToolResult:
195
+ async def drag(tab: Tab) -> Effect:
196
+ prepared = await actions.element_point(tab=tab, ref=source_ref)
197
+ target = await actions.element_point(tab=tab, ref=target_ref)
198
+ start = await actions.element_point(tab=tab, ref=source_ref, scroll=False)
199
+ end = await actions.element_point(tab=tab, ref=target_ref, scroll=False)
200
+ await self._browser.mouse.drag(start=start, end=end, duration=duration)
201
+ return Effect(
202
+ "Drag dispatched; inspect the destination.",
203
+ prepared_scroll=prepared.scrolled or target.scrolled,
204
+ )
205
+
206
+ return await actions.run(
207
+ kind=ActionKind.DRAG, operation=drag, expects_change=True
208
+ )
209
+
210
+
211
+ def _click_message(count: Literal[1, 2], button: Literal["left", "right"]) -> str:
212
+ gesture = "right click" if button == "right" else "click"
213
+ if count == 2:
214
+ gesture = "double " + gesture
215
+ return gesture.capitalize() + " dispatched."
216
+
217
+
218
+ def _image_point(snapshot: Snapshot | CapturedImage, point: Point) -> Point:
219
+ if snapshot.geometry is not None:
220
+ x, y = snapshot.geometry.point(point.x, point.y)
221
+ return Point(x=x, y=y)
222
+ if point.x >= snapshot.width or point.y >= snapshot.height:
223
+ raise BrowserError("Point is outside the observed viewport")
224
+ return point
@@ -0,0 +1,189 @@
1
+ import asyncio
2
+ from typing import Annotated
3
+ from urllib.parse import quote_plus, urljoin, urlsplit
4
+
5
+ from harnessed import Inject, Plugin, ToolResult
6
+ from harnessed.kernel import dependency
7
+ from pydantic import Field
8
+
9
+ from browserrig.browser import Browser, BrowserEvents, Tab, validated_url
10
+ from browserrig.core import ActionKind, BrowserError, ReadinessPhase
11
+ from browserrig.core import browser_tool as tool
12
+ from browserrig.events import NavigationStartedEvent
13
+ from browserrig.readiness import Readiness
14
+
15
+ from .effects import Effect
16
+ from .execution import Actions
17
+ from .intents import NavigateIntent, WaitIntent
18
+
19
+ # Where a new tab lands when the agent gives no URL.
20
+ NEW_TAB_URL = "https://duckduckgo.com/"
21
+
22
+
23
+ class NavigationTools(Plugin, needs=(Actions,)):
24
+ _browser = dependency(Browser)
25
+ _events = dependency(BrowserEvents)
26
+ _readiness = dependency(Readiness)
27
+
28
+ @tool(
29
+ kind=ActionKind.NAVIGATE,
30
+ description=(
31
+ "Navigate to an HTTP(S) URL in the active tab, or in a new tab with new_tab=true. "
32
+ "A path starting with / opens on the active page's site."
33
+ ),
34
+ )
35
+ async def navigate(
36
+ self,
37
+ url: str,
38
+ new_tab: bool = False,
39
+ *,
40
+ actions: Inject[Actions],
41
+ ) -> ToolResult:
42
+ url = site_url(url, self._browser.active_tab.url)
43
+ return await self._navigate(
44
+ url, actions, ActionKind.NAVIGATE, typed=typed_address(url), new_tab=new_tab
45
+ )
46
+
47
+ @tool(
48
+ kind=ActionKind.SEARCH,
49
+ description="Search the web with DuckDuckGo in the active tab.",
50
+ )
51
+ async def search(
52
+ self,
53
+ query: str,
54
+ *,
55
+ actions: Inject[Actions],
56
+ ) -> ToolResult:
57
+ return await self._navigate(
58
+ f"https://duckduckgo.com/?q={quote_plus(query)}",
59
+ actions,
60
+ ActionKind.SEARCH,
61
+ typed=query,
62
+ )
63
+
64
+ @tool(
65
+ kind=ActionKind.GO_BACK, description="Go to the previous browser history entry."
66
+ )
67
+ async def go_back(self, *, actions: Inject[Actions]) -> ToolResult:
68
+ async def back(tab: Tab) -> Effect:
69
+ if not await tab.go_back():
70
+ raise BrowserError("No previous history entry")
71
+ return Effect("Navigated back.")
72
+
73
+ return await actions.run(
74
+ kind=ActionKind.GO_BACK,
75
+ operation=back,
76
+ )
77
+
78
+ @tool(
79
+ kind=ActionKind.GO_FORWARD,
80
+ description=(
81
+ "Go to the next browser history entry. Reports an error if forward navigation "
82
+ "is unavailable."
83
+ ),
84
+ )
85
+ async def go_forward(self, *, actions: Inject[Actions]) -> ToolResult:
86
+ async def check(tab: Tab) -> None:
87
+ if not await tab.can_go_forward():
88
+ raise BrowserError("Cannot go forward: no next history entry")
89
+
90
+ async def forward(tab: Tab) -> Effect:
91
+ if not await tab.go_forward():
92
+ raise BrowserError("Cannot go forward: no next history entry")
93
+ return Effect("Navigated forward.")
94
+
95
+ return await actions.run(
96
+ kind=ActionKind.GO_FORWARD,
97
+ operation=forward,
98
+ check=check,
99
+ )
100
+
101
+ @tool(kind=ActionKind.RELOAD, description="Reload the active tab.")
102
+ async def reload(self, *, actions: Inject[Actions]) -> ToolResult:
103
+ async def reload(tab: Tab) -> Effect:
104
+ await tab.reload()
105
+ return Effect("Reload executed.")
106
+
107
+ return await actions.run(
108
+ kind=ActionKind.RELOAD,
109
+ operation=reload,
110
+ )
111
+
112
+ @tool(
113
+ kind=ActionKind.WAIT,
114
+ readonly=True,
115
+ description=(
116
+ "Wait briefly for an expected asynchronous page update; the next observation shows it."
117
+ ),
118
+ )
119
+ async def wait(
120
+ self,
121
+ seconds: Annotated[float, Field(ge=0, le=10)] = 1,
122
+ *,
123
+ actions: Inject[Actions],
124
+ ) -> str:
125
+ async with self._browser.action_lock:
126
+
127
+ async def pause() -> None:
128
+ await asyncio.sleep(seconds)
129
+
130
+ await actions.stage(WaitIntent(seconds), self._browser.active_tab, pause)
131
+ return "Wait completed; the next observation shows the current state."
132
+
133
+ async def _navigate(
134
+ self,
135
+ url: str,
136
+ actions: Actions,
137
+ kind: ActionKind,
138
+ *,
139
+ typed: str,
140
+ new_tab: bool = False,
141
+ ) -> ToolResult:
142
+ validated_url(url)
143
+
144
+ async def navigate(tab: Tab) -> Effect:
145
+ async def load() -> None:
146
+ await self._events.emit(
147
+ NavigationStartedEvent(target_id=tab.id, url=url, typed=typed)
148
+ )
149
+ await tab.navigate(url)
150
+
151
+ await actions.stage(NavigateIntent(url, typed), tab, load)
152
+ return Effect("Navigation executed.")
153
+
154
+ async def navigate_new(_tab: Tab) -> Effect:
155
+ # An empty tab first, so the address can be entered in a fresh tab.
156
+ opened = await self._browser.tabs.open()
157
+
158
+ async def dispatch() -> None:
159
+ await navigate(opened)
160
+
161
+ await self._readiness.wait(
162
+ opened, phase=ReadinessPhase.NAVIGATION, dispatch=dispatch
163
+ )
164
+ return Effect(f"Opened tab {opened.id} and navigated.")
165
+
166
+ if new_tab:
167
+ return await actions.run(kind=kind, operation=navigate_new, settle=False)
168
+ return await actions.run(kind=kind, operation=navigate)
169
+
170
+
171
+ def site_url( # smelt: ignore[SMT429] -- pure, table-tested
172
+ url: str, current: str
173
+ ) -> str:
174
+ """Resolve a site path such as ``/wiki/Page`` against the current HTTP(S) page."""
175
+ site_path = url.startswith("/") and not url.startswith("//")
176
+ if site_path and urlsplit(current).scheme in {"http", "https"}:
177
+ return urljoin(current, url)
178
+ return url
179
+
180
+
181
+ def typed_address(url: str) -> str: # smelt: ignore[SMT429] -- pure, table-tested
182
+ """What a person types into the address bar for `url`: no scheme, no lone trailing slash."""
183
+ parts = urlsplit(url)
184
+ if parts.scheme not in {"http", "https"}:
185
+ return url
186
+ typed = url.split("://", 1)[1]
187
+ if parts.path == "/" and not parts.query and not parts.fragment:
188
+ typed = typed.removesuffix("/")
189
+ return typed
@@ -0,0 +1,39 @@
1
+ import asyncio
2
+
3
+ from browserrig.browser import Tab
4
+ from browserrig.core import BrowserError, StaleElement
5
+ from browserrig.input import Mouse
6
+ from browserrig.observation import ElementHandle, ScrollableElement
7
+
8
+
9
+ async def prepare_element(
10
+ tab: Tab, handle: ElementHandle, mouse: Mouse, *, document_id: str
11
+ ) -> bool:
12
+ """Execute a backend's wheel plan through normal mouse input, when supported."""
13
+ if not isinstance(handle, ScrollableElement):
14
+ return False
15
+ scrolled = False
16
+ previous = None
17
+ stalled = 0
18
+ async with asyncio.timeout(20):
19
+ for _ in range(32):
20
+ await tab.sync_document()
21
+ if tab.document_id != document_id:
22
+ raise StaleElement(
23
+ "Document changed while scrolling; use refs from the next observation"
24
+ )
25
+ step = await handle.scroll_step()
26
+ if step is None:
27
+ return scrolled
28
+ stalled = stalled + 1 if step.key == previous else 0
29
+ if stalled >= 2:
30
+ break
31
+ previous = step.key
32
+ await mouse.scroll(
33
+ point=step.point, delta_x=step.delta_x, delta_y=step.delta_y
34
+ )
35
+ scrolled = True
36
+ await asyncio.sleep(0.05)
37
+ raise BrowserError(
38
+ "Element could not be brought into view using wheel input; try another way to reach it"
39
+ )
@@ -0,0 +1,81 @@
1
+ from harnessed import Inject, Plugin, ToolResult
2
+ from harnessed.kernel import dependency
3
+
4
+ from browserrig.browser import Browser, Tab, TabInfo
5
+ from browserrig.core import ActionKind, ReadinessPhase
6
+ from browserrig.core import browser_tool as tool
7
+ from browserrig.readiness import Readiness
8
+
9
+ from .effects import Effect
10
+ from .execution import Actions
11
+ from .navigation import NEW_TAB_URL
12
+
13
+
14
+ class TabTools(Plugin, needs=(Actions,)):
15
+ _browser = dependency(Browser)
16
+ _readiness = dependency(Readiness)
17
+
18
+ @tool(
19
+ kind=ActionKind.LIST_TABS,
20
+ chainable=True,
21
+ readonly=True,
22
+ description="List controlled tabs and identify the active tab.",
23
+ )
24
+ async def list_tabs(self) -> tuple[TabInfo, ...]:
25
+ return await self._browser.tabs.list()
26
+
27
+ @tool(
28
+ kind=ActionKind.OPEN_TAB,
29
+ description="Open and activate a new tab; without a URL it starts on DuckDuckGo.",
30
+ )
31
+ async def open_tab(
32
+ self,
33
+ url: str = NEW_TAB_URL,
34
+ *,
35
+ actions: Inject[Actions],
36
+ ) -> ToolResult:
37
+ async def open_tab(_tab: Tab) -> Effect:
38
+ opened = await self._browser.tabs.open(url)
39
+ await self._readiness.wait(opened, phase=ReadinessPhase.NAVIGATION)
40
+ return Effect(f"Opened tab {opened.id}.")
41
+
42
+ return await actions.run(
43
+ kind=ActionKind.OPEN_TAB, operation=open_tab, settle=False
44
+ )
45
+
46
+ @tool(
47
+ kind=ActionKind.SWITCH_TAB,
48
+ description="Activate one listed tab by its stable target ID.",
49
+ )
50
+ async def switch_tab(
51
+ self,
52
+ target_id: str,
53
+ *,
54
+ actions: Inject[Actions],
55
+ ) -> ToolResult:
56
+ async def switch(_tab: Tab) -> Effect:
57
+ selected = await self._browser.tabs.activate(target_id)
58
+ await self._readiness.wait(selected)
59
+ return Effect(f"Activated tab {selected.id}.")
60
+
61
+ return await actions.run(
62
+ kind=ActionKind.SWITCH_TAB, operation=switch, settle=False
63
+ )
64
+
65
+ @tool(
66
+ kind=ActionKind.CLOSE_TAB,
67
+ description="Close a controlled tab. A remaining tab is explicitly selected if needed.",
68
+ )
69
+ async def close_tab(
70
+ self,
71
+ target_id: str,
72
+ *,
73
+ actions: Inject[Actions],
74
+ ) -> ToolResult:
75
+ async def close(_tab: Tab) -> Effect:
76
+ selected = await self._browser.tabs.close(target_id)
77
+ return Effect(f"Tab closed. Active tab: {selected.id}.")
78
+
79
+ return await actions.run(
80
+ kind=ActionKind.CLOSE_TAB, operation=close, settle=False
81
+ )