stirrup 0.1.2__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.
@@ -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
@@ -21,7 +21,7 @@ def calculator_executor(params: CalculatorParams) -> ToolResult[ToolUseCountMeta
21
21
  result = eval(params.expression, {"__builtins__": {}}, {})
22
22
  return ToolResult(content=f"Result: {result}", metadata=ToolUseCountMetadata())
23
23
  except Exception as e:
24
- return ToolResult(content=f"Error evaluating expression: {e!s}", metadata=ToolUseCountMetadata())
24
+ return ToolResult(content=f"Error evaluating expression: {e!s}", success=False, metadata=ToolUseCountMetadata())
25
25
 
26
26
 
27
27
  CALCULATOR_TOOL: Tool[CalculatorParams, ToolUseCountMetadata] = Tool[CalculatorParams, ToolUseCountMetadata](
@@ -160,6 +160,11 @@ class CodeExecToolProvider(ToolProvider, ABC):
160
160
  if allowed_commands is not None:
161
161
  self._compiled_allowed = [re.compile(p) for p in allowed_commands]
162
162
 
163
+ @property
164
+ def temp_dir(self) -> Path | None:
165
+ """Return the temporary directory for this execution environment, if any."""
166
+ return None
167
+
163
168
  def _check_allowed(self, cmd: str) -> bool:
164
169
  """Check if command is allowed based on the allowlist.
165
170
 
@@ -223,6 +228,23 @@ class CodeExecToolProvider(ToolProvider, ABC):
223
228
  """
224
229
  ...
225
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
+
226
248
  async def save_output_files(
227
249
  self,
228
250
  paths: list[str],
@@ -419,11 +441,13 @@ class CodeExecToolProvider(ToolProvider, ABC):
419
441
  except FileNotFoundError:
420
442
  return ToolResult(
421
443
  content=f"Image `{params.path}` not found.",
444
+ success=False,
422
445
  metadata=ToolUseCountMetadata(),
423
446
  )
424
447
  except ValueError as e:
425
448
  return ToolResult(
426
449
  content=str(e),
450
+ success=False,
427
451
  metadata=ToolUseCountMetadata(),
428
452
  )
429
453
 
@@ -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