stirrup 0.1.3__py3-none-any.whl → 0.1.4__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.
- stirrup/clients/__init__.py +5 -0
- stirrup/clients/open_responses_client.py +434 -0
- stirrup/core/agent.py +18 -2
- stirrup/core/models.py +4 -2
- stirrup/tools/__init__.py +1 -0
- stirrup/tools/browser_use.py +591 -0
- stirrup/tools/code_backends/base.py +17 -0
- stirrup/tools/code_backends/docker.py +19 -0
- stirrup/tools/code_backends/e2b.py +18 -0
- stirrup/tools/code_backends/local.py +17 -0
- stirrup/tools/finish.py +27 -1
- stirrup/utils/logging.py +8 -7
- {stirrup-0.1.3.dist-info → stirrup-0.1.4.dist-info}/METADATA +16 -13
- {stirrup-0.1.3.dist-info → stirrup-0.1.4.dist-info}/RECORD +15 -13
- {stirrup-0.1.3.dist-info → stirrup-0.1.4.dist-info}/WHEEL +2 -2
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Browser automation tool provider using browser-use library.
|
|
2
|
+
|
|
3
|
+
This module provides BrowserUseToolProvider, a ToolProvider that manages a browser
|
|
4
|
+
session and exposes browser automation actions as individual Tool objects.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
from stirrup.clients.chat_completions_client import ChatCompletionsClient
|
|
8
|
+
from stirrup.tools.browser_use import BrowserUseToolProvider
|
|
9
|
+
|
|
10
|
+
client = ChatCompletionsClient(model="gpt-5")
|
|
11
|
+
agent = Agent(
|
|
12
|
+
client=client,
|
|
13
|
+
name="browser_agent",
|
|
14
|
+
tools=[*DEFAULT_TOOLS, BrowserUseToolProvider()],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
async with agent.session() as session:
|
|
18
|
+
await session.run("Go to google.com and search for 'AI agents'")
|
|
19
|
+
|
|
20
|
+
Requires browser-use dependency (`uv add 'stirrup[browser]'`).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import urllib.parse
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
from typing import Annotated, Any, Literal
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
|
|
31
|
+
from stirrup.core.models import EmptyParams, ImageContentBlock, Tool, ToolProvider, ToolResult, ToolUseCountMetadata
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from browser_use import BrowserSession
|
|
35
|
+
from browser_use.browser.events import (
|
|
36
|
+
ClickElementEvent,
|
|
37
|
+
GoBackEvent,
|
|
38
|
+
NavigateToUrlEvent,
|
|
39
|
+
ScrollEvent,
|
|
40
|
+
ScrollToTextEvent,
|
|
41
|
+
SendKeysEvent,
|
|
42
|
+
SwitchTabEvent,
|
|
43
|
+
TypeTextEvent,
|
|
44
|
+
)
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"Requires installation of the browser extra. Install with (for example): "
|
|
48
|
+
"`uv pip install stirrup[browser]` or `uv add stirrup[browser]`",
|
|
49
|
+
) from e
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"BrowserUseToolProvider",
|
|
54
|
+
"InputTextMetadata",
|
|
55
|
+
"NavigateMetadata",
|
|
56
|
+
"SearchMetadata",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# =============================================================================
|
|
61
|
+
# Parameter Models
|
|
62
|
+
# =============================================================================
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SearchParams(BaseModel):
|
|
66
|
+
"""Parameters for web search."""
|
|
67
|
+
|
|
68
|
+
query: Annotated[str, Field(description="Search query string")]
|
|
69
|
+
engine: Annotated[
|
|
70
|
+
Literal["google", "duckduckgo", "bing"],
|
|
71
|
+
Field(default="google", description="Search engine to use"),
|
|
72
|
+
] = "google"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NavigateParams(BaseModel):
|
|
76
|
+
"""Parameters for URL navigation."""
|
|
77
|
+
|
|
78
|
+
url: Annotated[str, Field(description="URL to navigate to")]
|
|
79
|
+
new_tab: Annotated[bool, Field(default=False, description="Open in new tab")] = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ClickParams(BaseModel):
|
|
83
|
+
"""Parameters for clicking an element."""
|
|
84
|
+
|
|
85
|
+
index: Annotated[int, Field(description="Element index from the page snapshot")]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class InputTextParams(BaseModel):
|
|
89
|
+
"""Parameters for typing text into an element."""
|
|
90
|
+
|
|
91
|
+
index: Annotated[int, Field(description="Element index from the page snapshot")]
|
|
92
|
+
text: Annotated[str, Field(description="Text to input")]
|
|
93
|
+
clear_first: Annotated[bool, Field(default=True, description="Clear existing text before typing")] = True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ScrollParams(BaseModel):
|
|
97
|
+
"""Parameters for scrolling the page."""
|
|
98
|
+
|
|
99
|
+
direction: Annotated[Literal["up", "down"], Field(description="Scroll direction")]
|
|
100
|
+
amount: Annotated[int, Field(default=500, description="Pixels to scroll")] = 500
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FindTextParams(BaseModel):
|
|
104
|
+
"""Parameters for finding and scrolling to text."""
|
|
105
|
+
|
|
106
|
+
text: Annotated[str, Field(description="Text to find on the page")]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SendKeysParams(BaseModel):
|
|
110
|
+
"""Parameters for sending keyboard keys."""
|
|
111
|
+
|
|
112
|
+
keys: Annotated[str, Field(description="Keys to send (e.g., 'Enter', 'Escape', 'Tab', 'ArrowDown')")]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class EvaluateJsParams(BaseModel):
|
|
116
|
+
"""Parameters for executing JavaScript."""
|
|
117
|
+
|
|
118
|
+
script: Annotated[str, Field(description="JavaScript code to execute")]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SwitchTabParams(BaseModel):
|
|
122
|
+
"""Parameters for switching browser tabs."""
|
|
123
|
+
|
|
124
|
+
index: Annotated[int, Field(description="Tab index to switch to (0-based)")]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class WaitParams(BaseModel):
|
|
128
|
+
"""Parameters for waiting."""
|
|
129
|
+
|
|
130
|
+
seconds: Annotated[int, Field(default=3, description="Seconds to wait (max 30)")] = 3
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# =============================================================================
|
|
134
|
+
# Metadata
|
|
135
|
+
# =============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class NavigateMetadata(ToolUseCountMetadata):
|
|
139
|
+
"""Metadata tracking URLs visited."""
|
|
140
|
+
|
|
141
|
+
urls: list[str] = Field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
def __add__(self, other: "NavigateMetadata") -> "NavigateMetadata": # type: ignore[override]
|
|
144
|
+
return NavigateMetadata(
|
|
145
|
+
num_uses=self.num_uses + other.num_uses,
|
|
146
|
+
urls=self.urls + other.urls,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SearchMetadata(ToolUseCountMetadata):
|
|
151
|
+
"""Metadata tracking search queries."""
|
|
152
|
+
|
|
153
|
+
queries: list[str] = Field(default_factory=list)
|
|
154
|
+
|
|
155
|
+
def __add__(self, other: "SearchMetadata") -> "SearchMetadata": # type: ignore[override]
|
|
156
|
+
return SearchMetadata(
|
|
157
|
+
num_uses=self.num_uses + other.num_uses,
|
|
158
|
+
queries=self.queries + other.queries,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class InputTextMetadata(ToolUseCountMetadata):
|
|
163
|
+
"""Metadata tracking text inputs."""
|
|
164
|
+
|
|
165
|
+
texts: list[str] = Field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
def __add__(self, other: "InputTextMetadata") -> "InputTextMetadata": # type: ignore[override]
|
|
168
|
+
return InputTextMetadata(
|
|
169
|
+
num_uses=self.num_uses + other.num_uses,
|
|
170
|
+
texts=self.texts + other.texts,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# =============================================================================
|
|
175
|
+
# BrowserUseToolProvider
|
|
176
|
+
# =============================================================================
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class BrowserUseToolProvider(ToolProvider):
|
|
180
|
+
"""Browser automation tool provider using browser-use library.
|
|
181
|
+
|
|
182
|
+
Provides tools for:
|
|
183
|
+
- Navigation: search, navigate, go_back, wait
|
|
184
|
+
- Page Interaction: click, input_text, scroll, find_text, send_keys
|
|
185
|
+
- JavaScript: evaluate_js
|
|
186
|
+
- Tab Management: switch_tab
|
|
187
|
+
- Content Extraction: snapshot, screenshot, get_url
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
from stirrup.tools.browser_use import BrowserUseToolProvider
|
|
191
|
+
|
|
192
|
+
agent = Agent(
|
|
193
|
+
client=client,
|
|
194
|
+
name="browser_agent",
|
|
195
|
+
tools=[BrowserUseToolProvider(headless=False)],
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async with agent.session() as session:
|
|
199
|
+
await session.run("Navigate to example.com and click the first link")
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
headless: bool = True,
|
|
207
|
+
disable_security: bool = False,
|
|
208
|
+
executable_path: str | None = None,
|
|
209
|
+
cdp_url: str | None = None,
|
|
210
|
+
use_cloud: bool = False,
|
|
211
|
+
tool_prefix: str = "browser",
|
|
212
|
+
extra_args: list[str] | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Initialize BrowserUseToolProvider.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
headless: Run browser in headless mode (default: True)
|
|
218
|
+
disable_security: Disable browser security features (default: False)
|
|
219
|
+
executable_path: Path to Chrome/Chromium executable
|
|
220
|
+
cdp_url: Chrome DevTools Protocol URL for remote connection
|
|
221
|
+
use_cloud: Use Browser Use cloud browser (requires BROWSER_USE_API_KEY env var)
|
|
222
|
+
tool_prefix: Prefix for tool names (default: "browser")
|
|
223
|
+
extra_args: Additional Chromium command line arguments
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
self._headless = headless
|
|
227
|
+
self._disable_security = disable_security
|
|
228
|
+
self._executable_path = executable_path
|
|
229
|
+
self._cdp_url = cdp_url
|
|
230
|
+
self._use_cloud = use_cloud
|
|
231
|
+
self._tool_prefix = tool_prefix
|
|
232
|
+
self._extra_args = extra_args
|
|
233
|
+
|
|
234
|
+
self._session: BrowserSession | None = None
|
|
235
|
+
|
|
236
|
+
def _tool_name(self, name: str) -> str:
|
|
237
|
+
"""Generate prefixed tool name."""
|
|
238
|
+
return f"{self._tool_prefix}_{name}" if self._tool_prefix else name
|
|
239
|
+
|
|
240
|
+
async def __aenter__(self) -> list[Tool[Any, Any]]:
|
|
241
|
+
"""Enter async context: start browser and return tools."""
|
|
242
|
+
if self._use_cloud and not os.environ.get("BROWSER_USE_API_KEY"):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"BROWSER_USE_API_KEY environment variable is required when use_cloud=True. "
|
|
245
|
+
"Get your API key from https://cloud.browser-use.com"
|
|
246
|
+
)
|
|
247
|
+
self._session = BrowserSession( # type: ignore[call-overload]
|
|
248
|
+
headless=self._headless,
|
|
249
|
+
disable_security=self._disable_security,
|
|
250
|
+
executable_path=self._executable_path,
|
|
251
|
+
cdp_url=self._cdp_url,
|
|
252
|
+
use_cloud=self._use_cloud,
|
|
253
|
+
args=self._extra_args,
|
|
254
|
+
)
|
|
255
|
+
await self._session.start()
|
|
256
|
+
return self._build_tools()
|
|
257
|
+
|
|
258
|
+
async def __aexit__(
|
|
259
|
+
self,
|
|
260
|
+
exc_type: type[BaseException] | None,
|
|
261
|
+
exc_val: BaseException | None,
|
|
262
|
+
exc_tb: TracebackType | None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Exit async context: close browser."""
|
|
265
|
+
if self._session:
|
|
266
|
+
await self._session.stop()
|
|
267
|
+
self._session = None
|
|
268
|
+
|
|
269
|
+
def _build_tools(self) -> list[Tool[Any, Any]]:
|
|
270
|
+
"""Build all browser tools."""
|
|
271
|
+
session = self._session
|
|
272
|
+
if session is None:
|
|
273
|
+
raise RuntimeError("Browser session not initialized")
|
|
274
|
+
|
|
275
|
+
tools: list[Tool[Any, Any]] = []
|
|
276
|
+
|
|
277
|
+
# --- Navigation Tools ---
|
|
278
|
+
|
|
279
|
+
async def search_executor(params: SearchParams) -> ToolResult[SearchMetadata]:
|
|
280
|
+
"""Search the web using specified search engine."""
|
|
281
|
+
|
|
282
|
+
search_urls = {
|
|
283
|
+
"google": f"https://www.google.com/search?q={urllib.parse.quote_plus(params.query)}&udm=14",
|
|
284
|
+
"duckduckgo": f"https://duckduckgo.com/?q={urllib.parse.quote_plus(params.query)}",
|
|
285
|
+
"bing": f"https://www.bing.com/search?q={urllib.parse.quote_plus(params.query)}",
|
|
286
|
+
}
|
|
287
|
+
url = search_urls[params.engine]
|
|
288
|
+
event = session.event_bus.dispatch(NavigateToUrlEvent(url=url, new_tab=False))
|
|
289
|
+
await event
|
|
290
|
+
return ToolResult(
|
|
291
|
+
content=f"Searched {params.engine} for: {params.query}",
|
|
292
|
+
metadata=SearchMetadata(queries=[params.query]),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
tools.append(
|
|
296
|
+
Tool(
|
|
297
|
+
name=self._tool_name("search"),
|
|
298
|
+
description="Search the web using Google, DuckDuckGo, or Bing.",
|
|
299
|
+
parameters=SearchParams,
|
|
300
|
+
executor=search_executor,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
async def navigate_executor(params: NavigateParams) -> ToolResult[NavigateMetadata]:
|
|
305
|
+
"""Navigate to a URL."""
|
|
306
|
+
event = session.event_bus.dispatch(NavigateToUrlEvent(url=params.url, new_tab=params.new_tab))
|
|
307
|
+
await event
|
|
308
|
+
return ToolResult(
|
|
309
|
+
content=f"Navigated to: {params.url}" + (" (new tab)" if params.new_tab else ""),
|
|
310
|
+
metadata=NavigateMetadata(urls=[params.url]),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
tools.append(
|
|
314
|
+
Tool(
|
|
315
|
+
name=self._tool_name("navigate"),
|
|
316
|
+
description="Navigate to a URL. Optionally open in a new tab.",
|
|
317
|
+
parameters=NavigateParams,
|
|
318
|
+
executor=navigate_executor,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def go_back_executor(_: EmptyParams) -> ToolResult[ToolUseCountMetadata]:
|
|
323
|
+
"""Go back in browser history."""
|
|
324
|
+
event = session.event_bus.dispatch(GoBackEvent())
|
|
325
|
+
await event
|
|
326
|
+
return ToolResult(
|
|
327
|
+
content="Navigated back",
|
|
328
|
+
metadata=ToolUseCountMetadata(),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
tools.append(
|
|
332
|
+
Tool(
|
|
333
|
+
name=self._tool_name("go_back"),
|
|
334
|
+
description="Go back to the previous page in browser history.",
|
|
335
|
+
parameters=EmptyParams,
|
|
336
|
+
executor=go_back_executor,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
async def wait_executor(params: WaitParams) -> ToolResult[ToolUseCountMetadata]:
|
|
341
|
+
"""Wait for specified seconds."""
|
|
342
|
+
|
|
343
|
+
wait_time = min(max(params.seconds, 1), 30)
|
|
344
|
+
await asyncio.sleep(wait_time)
|
|
345
|
+
return ToolResult(
|
|
346
|
+
content=f"Waited for {wait_time} seconds",
|
|
347
|
+
metadata=ToolUseCountMetadata(),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
tools.append(
|
|
351
|
+
Tool(
|
|
352
|
+
name=self._tool_name("wait"),
|
|
353
|
+
description="Wait for a specified number of seconds (1-30).",
|
|
354
|
+
parameters=WaitParams,
|
|
355
|
+
executor=wait_executor,
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# --- Page Interaction Tools ---
|
|
360
|
+
|
|
361
|
+
async def click_executor(params: ClickParams) -> ToolResult[ToolUseCountMetadata]:
|
|
362
|
+
"""Click an element by index."""
|
|
363
|
+
node = await session.get_element_by_index(params.index)
|
|
364
|
+
if node is None:
|
|
365
|
+
return ToolResult(
|
|
366
|
+
content=f"Element with index {params.index} not found",
|
|
367
|
+
success=False,
|
|
368
|
+
metadata=ToolUseCountMetadata(),
|
|
369
|
+
)
|
|
370
|
+
event = session.event_bus.dispatch(ClickElementEvent(node=node))
|
|
371
|
+
await event
|
|
372
|
+
return ToolResult(
|
|
373
|
+
content=f"Clicked element at index {params.index}",
|
|
374
|
+
metadata=ToolUseCountMetadata(),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
tools.append(
|
|
378
|
+
Tool(
|
|
379
|
+
name=self._tool_name("click"),
|
|
380
|
+
description="Click an element by its index from the page snapshot.",
|
|
381
|
+
parameters=ClickParams,
|
|
382
|
+
executor=click_executor,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
async def input_text_executor(params: InputTextParams) -> ToolResult[InputTextMetadata]:
|
|
387
|
+
"""Input text into an element."""
|
|
388
|
+
node = await session.get_element_by_index(params.index)
|
|
389
|
+
if node is None:
|
|
390
|
+
return ToolResult(
|
|
391
|
+
content=f"Element with index {params.index} not found",
|
|
392
|
+
success=False,
|
|
393
|
+
metadata=InputTextMetadata(texts=[params.text]),
|
|
394
|
+
)
|
|
395
|
+
event = session.event_bus.dispatch(
|
|
396
|
+
TypeTextEvent(
|
|
397
|
+
node=node,
|
|
398
|
+
text=params.text,
|
|
399
|
+
clear=params.clear_first,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
await event
|
|
403
|
+
return ToolResult(
|
|
404
|
+
content=f"Typed text into element at index {params.index}",
|
|
405
|
+
metadata=InputTextMetadata(texts=[params.text]),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
tools.append(
|
|
409
|
+
Tool(
|
|
410
|
+
name=self._tool_name("input_text"),
|
|
411
|
+
description="Type text into a form field or input element.",
|
|
412
|
+
parameters=InputTextParams,
|
|
413
|
+
executor=input_text_executor,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
async def scroll_executor(params: ScrollParams) -> ToolResult[ToolUseCountMetadata]:
|
|
418
|
+
"""Scroll the page."""
|
|
419
|
+
event = session.event_bus.dispatch(
|
|
420
|
+
ScrollEvent(
|
|
421
|
+
direction=params.direction,
|
|
422
|
+
amount=params.amount,
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
await event
|
|
426
|
+
return ToolResult(
|
|
427
|
+
content=f"Scrolled {params.direction} by {params.amount} pixels",
|
|
428
|
+
metadata=ToolUseCountMetadata(),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
tools.append(
|
|
432
|
+
Tool(
|
|
433
|
+
name=self._tool_name("scroll"),
|
|
434
|
+
description="Scroll the page up or down by a specified amount.",
|
|
435
|
+
parameters=ScrollParams,
|
|
436
|
+
executor=scroll_executor,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
async def find_text_executor(params: FindTextParams) -> ToolResult[ToolUseCountMetadata]:
|
|
441
|
+
"""Find and scroll to text on the page."""
|
|
442
|
+
event = session.event_bus.dispatch(ScrollToTextEvent(text=params.text))
|
|
443
|
+
await event
|
|
444
|
+
return ToolResult(
|
|
445
|
+
content=f"Scrolled to text: {params.text}",
|
|
446
|
+
metadata=ToolUseCountMetadata(),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
tools.append(
|
|
450
|
+
Tool(
|
|
451
|
+
name=self._tool_name("find_text"),
|
|
452
|
+
description="Find specific text on the page and scroll to it.",
|
|
453
|
+
parameters=FindTextParams,
|
|
454
|
+
executor=find_text_executor,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
async def send_keys_executor(params: SendKeysParams) -> ToolResult[ToolUseCountMetadata]:
|
|
459
|
+
"""Send keyboard keys."""
|
|
460
|
+
event = session.event_bus.dispatch(SendKeysEvent(keys=params.keys))
|
|
461
|
+
await event
|
|
462
|
+
return ToolResult(
|
|
463
|
+
content=f"Sent keys: {params.keys}",
|
|
464
|
+
metadata=ToolUseCountMetadata(),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
tools.append(
|
|
468
|
+
Tool(
|
|
469
|
+
name=self._tool_name("send_keys"),
|
|
470
|
+
description="Send keyboard keys (e.g., 'Enter', 'Escape', 'Tab', 'ArrowDown').",
|
|
471
|
+
parameters=SendKeysParams,
|
|
472
|
+
executor=send_keys_executor,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# --- JavaScript Execution ---
|
|
477
|
+
|
|
478
|
+
async def evaluate_js_executor(params: EvaluateJsParams) -> ToolResult[ToolUseCountMetadata]:
|
|
479
|
+
"""Execute JavaScript on the page."""
|
|
480
|
+
page = await session.must_get_current_page()
|
|
481
|
+
script = params.script.strip()
|
|
482
|
+
# browser-use requires arrow function format - wrap if needed
|
|
483
|
+
if not script.startswith("("):
|
|
484
|
+
script = f"() => {script}"
|
|
485
|
+
try:
|
|
486
|
+
result = await page.evaluate(script)
|
|
487
|
+
return ToolResult(
|
|
488
|
+
content=f"JavaScript result: {result}",
|
|
489
|
+
metadata=ToolUseCountMetadata(),
|
|
490
|
+
)
|
|
491
|
+
except Exception as e:
|
|
492
|
+
return ToolResult(
|
|
493
|
+
content=f"JavaScript error: {e}",
|
|
494
|
+
success=False,
|
|
495
|
+
metadata=ToolUseCountMetadata(),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
tools.append(
|
|
499
|
+
Tool(
|
|
500
|
+
name=self._tool_name("evaluate_js"),
|
|
501
|
+
description="Execute custom JavaScript code on the page. Code is auto-wrapped in arrow function.",
|
|
502
|
+
parameters=EvaluateJsParams,
|
|
503
|
+
executor=evaluate_js_executor,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# --- Tab Management ---
|
|
508
|
+
|
|
509
|
+
async def switch_tab_executor(params: SwitchTabParams) -> ToolResult[ToolUseCountMetadata]:
|
|
510
|
+
"""Switch to a different tab."""
|
|
511
|
+
tabs = await session.get_tabs()
|
|
512
|
+
if params.index < 0 or params.index >= len(tabs):
|
|
513
|
+
return ToolResult(
|
|
514
|
+
content=f"Tab index {params.index} out of range (0-{len(tabs) - 1})",
|
|
515
|
+
success=False,
|
|
516
|
+
metadata=ToolUseCountMetadata(),
|
|
517
|
+
)
|
|
518
|
+
target_id = tabs[params.index].target_id
|
|
519
|
+
event = session.event_bus.dispatch(SwitchTabEvent(target_id=target_id))
|
|
520
|
+
await event
|
|
521
|
+
return ToolResult(
|
|
522
|
+
content=f"Switched to tab {params.index}",
|
|
523
|
+
metadata=ToolUseCountMetadata(),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
tools.append(
|
|
527
|
+
Tool(
|
|
528
|
+
name=self._tool_name("switch_tab"),
|
|
529
|
+
description="Switch to a different browser tab by index (0-based).",
|
|
530
|
+
parameters=SwitchTabParams,
|
|
531
|
+
executor=switch_tab_executor,
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# --- Content Extraction ---
|
|
536
|
+
|
|
537
|
+
async def snapshot_executor(_: EmptyParams) -> ToolResult[ToolUseCountMetadata]:
|
|
538
|
+
"""Get accessibility snapshot of the current page."""
|
|
539
|
+
state_text = await session.get_state_as_text()
|
|
540
|
+
return ToolResult(
|
|
541
|
+
content=f"<page_snapshot>\n{state_text}\n</page_snapshot>",
|
|
542
|
+
metadata=ToolUseCountMetadata(),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
tools.append(
|
|
546
|
+
Tool(
|
|
547
|
+
name=self._tool_name("snapshot"),
|
|
548
|
+
description="Get accessibility snapshot of current page showing interactive elements with indices.",
|
|
549
|
+
parameters=EmptyParams,
|
|
550
|
+
executor=snapshot_executor,
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
async def screenshot_executor(_: EmptyParams) -> ToolResult[ToolUseCountMetadata]:
|
|
555
|
+
"""Take a screenshot of the current page."""
|
|
556
|
+
screenshot_bytes = await session.take_screenshot()
|
|
557
|
+
return ToolResult(
|
|
558
|
+
content=[
|
|
559
|
+
"Screenshot captured:",
|
|
560
|
+
ImageContentBlock(data=screenshot_bytes),
|
|
561
|
+
],
|
|
562
|
+
metadata=ToolUseCountMetadata(),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
tools.append(
|
|
566
|
+
Tool(
|
|
567
|
+
name=self._tool_name("screenshot"),
|
|
568
|
+
description="Take a screenshot of the current page for visual inspection.",
|
|
569
|
+
parameters=EmptyParams,
|
|
570
|
+
executor=screenshot_executor,
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
async def get_url_executor(_: EmptyParams) -> ToolResult[ToolUseCountMetadata]:
|
|
575
|
+
"""Get the current page URL."""
|
|
576
|
+
url = await session.get_current_page_url()
|
|
577
|
+
return ToolResult(
|
|
578
|
+
content=f"Current URL: {url}",
|
|
579
|
+
metadata=ToolUseCountMetadata(),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
tools.append(
|
|
583
|
+
Tool(
|
|
584
|
+
name=self._tool_name("get_url"),
|
|
585
|
+
description="Get the current page URL.",
|
|
586
|
+
parameters=EmptyParams,
|
|
587
|
+
executor=get_url_executor,
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
return tools
|
|
@@ -228,6 +228,23 @@ class CodeExecToolProvider(ToolProvider, ABC):
|
|
|
228
228
|
"""
|
|
229
229
|
...
|
|
230
230
|
|
|
231
|
+
@abstractmethod
|
|
232
|
+
async def file_exists(self, path: str) -> bool:
|
|
233
|
+
"""Check if a file exists in this execution environment.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
path: File path within this execution environment (relative or absolute
|
|
237
|
+
within the env's working directory).
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if the file exists, False otherwise.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
RuntimeError: If execution environment not started.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
...
|
|
247
|
+
|
|
231
248
|
async def save_output_files(
|
|
232
249
|
self,
|
|
233
250
|
paths: list[str],
|
|
@@ -486,6 +486,25 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
|
|
|
486
486
|
host_path.parent.mkdir(parents=True, exist_ok=True)
|
|
487
487
|
host_path.write_bytes(content)
|
|
488
488
|
|
|
489
|
+
async def file_exists(self, path: str) -> bool:
|
|
490
|
+
"""Check if a file exists in the container.
|
|
491
|
+
|
|
492
|
+
Since files are volume-mounted, checks directly on the host temp directory.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
path: File path (relative or absolute container path).
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
True if the file exists, False otherwise.
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
RuntimeError: If environment not started.
|
|
502
|
+
ValueError: If path is outside mounted directory.
|
|
503
|
+
|
|
504
|
+
"""
|
|
505
|
+
host_path = self._container_path_to_host(path)
|
|
506
|
+
return host_path.exists() and host_path.is_file()
|
|
507
|
+
|
|
489
508
|
async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
|
|
490
509
|
"""Execute a shell command in the Docker container.
|
|
491
510
|
|
|
@@ -132,6 +132,24 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
|
|
|
132
132
|
|
|
133
133
|
await self._sbx.files.write(path, content, request_timeout=self._request_timeout)
|
|
134
134
|
|
|
135
|
+
async def file_exists(self, path: str) -> bool:
|
|
136
|
+
"""Check if a file exists in the E2B sandbox.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: File path within the sandbox.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if the file exists, False otherwise.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
RuntimeError: If environment not started.
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
if self._sbx is None:
|
|
149
|
+
raise RuntimeError("ExecutionEnvironment not started.")
|
|
150
|
+
|
|
151
|
+
return await self._sbx.files.exists(path)
|
|
152
|
+
|
|
135
153
|
async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
|
|
136
154
|
"""Execute command in E2B execution environment, returning raw CommandResult."""
|
|
137
155
|
if self._sbx is None:
|
|
@@ -205,6 +205,23 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
|
|
|
205
205
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
206
206
|
resolved.write_bytes(content)
|
|
207
207
|
|
|
208
|
+
async def file_exists(self, path: str) -> bool:
|
|
209
|
+
"""Check if a file exists in the temp directory.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
path: File path (relative or absolute within the temp dir).
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if the file exists, False otherwise.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
RuntimeError: If environment not started.
|
|
219
|
+
ValueError: If path is outside temp directory.
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
resolved = self._resolve_and_validate_path(path)
|
|
223
|
+
return resolved.exists() and resolved.is_file()
|
|
224
|
+
|
|
208
225
|
async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
|
|
209
226
|
"""Execute command in the temp directory.
|
|
210
227
|
|