Skip to content

Commit d0d3150

Browse files
authored
Merge pull request #72 from vstorm-co/fixes/0.3.11
fix: browser tool
2 parents b1525b3 + 87993ba commit d0d3150

6 files changed

Lines changed: 70 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.3.11] - 2026-04-13
9+
10+
### Fixed
11+
12+
- **Browser opens on every message (`BrowserCapability`)**`async_playwright()` was entered eagerly
13+
at the start of `wrap_run`, spawning the Playwright Node.js driver process (which in turn opened a
14+
browser window) on every agent run — even when no browser tool was ever called. The Playwright context
15+
manager is now entered lazily inside the first-tool-call launcher, so runs that never use the browser
16+
incur zero Playwright overhead and no browser process is started.
17+
- **Browser window always visible (`browser_headless` default)** — the CLI config defaulted
18+
`browser_headless = false`, meaning any browser launch produced a visible Chrome window. Changed to
19+
`browser_headless = true`.
20+
821
## [0.3.10] - 2026-04-12
922

1023
### Changed

apps/cli/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ class CliConfig:
109109
logfire: bool = False
110110
include_browser: bool = True
111111
"""Enable browser automation via Playwright (requires ``pydantic-deep[browser]``)."""
112-
browser_headless: bool = False
113-
"""Run browser without a visible window. Default ``False`` — browser window is shown."""
112+
browser_headless: bool = True
113+
"""Run browser without a visible window. Default ``True`` — browser window is hidden."""
114114

115115

116116
def load_config(path: Path | None = None) -> CliConfig:

pydantic_deep/capabilities/browser.py

Lines changed: 50 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ async def _auto_install_chromium() -> bool:
112112
class BrowserCapability(AbstractCapability[Any]):
113113
"""Provides a real async Playwright browser to the agent.
114114
115-
Manages the full browser lifecycle: Chromium is launched before the agent
116-
run starts (via ``wrap_run``) and closed in a ``finally`` block, guaranteeing
117-
cleanup on both success and failure paths.
115+
Manages the full browser lifecycle: Playwright and Chromium are started
116+
lazily on the first browser-tool call and closed in a ``finally`` block,
117+
guaranteeing cleanup on both success and failure paths. Runs that never
118+
invoke a browser tool incur zero Playwright overhead.
118119
119120
Requires the ``browser`` optional extra::
120121
@@ -228,10 +229,38 @@ async def prepare_tools(
228229
result.append(td)
229230
return result
230231

231-
def _make_launcher(self, pw: Any) -> Any:
232-
"""Return an async callable that lazily launches Chromium on first tool call."""
232+
async def wrap_run(
233+
self,
234+
ctx: RunContext[Any],
235+
*,
236+
handler: WrapRunHandler,
237+
) -> AgentRunResult[Any]:
238+
"""Install a lazy browser launcher and clean up after the run.
239+
240+
Both Playwright and Chromium are started only when the first browser
241+
tool is actually called. Runs that never use the browser incur zero
242+
Playwright overhead — no subprocess is spawned, no browser window
243+
appears.
244+
245+
A ``finally`` block guarantees cleanup of the browser and the
246+
Playwright driver whether the run succeeds, raises, or is cancelled.
247+
248+
If Chromium is not installed and ``auto_install`` is ``True`` (the
249+
default), ``playwright install chromium`` is run automatically on the
250+
first tool call, and the launch is retried once.
251+
"""
252+
_require_browser()
253+
assert async_playwright is not None # guaranteed by _require_browser()
254+
_start_playwright = async_playwright # local non-None reference for the closure
255+
256+
_pw_ctx: Any = None # Playwright context manager — entered lazily
233257

234258
async def _launch() -> None:
259+
nonlocal _pw_ctx
260+
_pw_ctx = _start_playwright()
261+
pw = await _pw_ctx.__aenter__()
262+
self._state.playwright_instance = pw
263+
235264
browser = None
236265
try:
237266
browser = await pw.chromium.launch(headless=self.headless)
@@ -278,44 +307,19 @@ def _on_popup(popup: Any) -> None:
278307
self._state.browser = browser
279308
self._state.page = page
280309

281-
return _launch
282-
283-
async def wrap_run(
284-
self,
285-
ctx: RunContext[Any],
286-
*,
287-
handler: WrapRunHandler,
288-
) -> AgentRunResult[Any]:
289-
"""Set up Playwright context and install a lazy browser launcher.
290-
291-
Chromium is **not** launched here. Instead a launcher callable is
292-
stored on ``_state._lazy_launcher`` and is invoked by
293-
``BrowserToolset._ensure_page()`` only when a browser tool is first
294-
called. Runs that never use the browser incur zero Playwright
295-
overhead.
296-
297-
A ``finally`` block cleans up all state and closes the browser (if it
298-
was ever launched) whether the run succeeds, raises, or is cancelled.
299-
300-
If Chromium is not installed and ``auto_install`` is ``True`` (the
301-
default), ``playwright install chromium`` is run automatically on the
302-
first tool call, and the launch is retried once.
303-
"""
304-
_require_browser()
305-
assert async_playwright is not None # guaranteed by _require_browser()
306-
async with async_playwright() as pw:
307-
self._state.playwright_instance = pw
308-
self._state._lazy_launcher = self._make_launcher(pw)
309-
try:
310-
return await handler()
311-
finally:
312-
self._state._lazy_launcher = None
313-
self._state.playwright_instance = None
314-
self._state.launch_error = None
315-
if self._state.browser is not None:
316-
browser = self._state.browser
317-
self._state.page = None
318-
self._state.browser = None
319-
await browser.close()
320-
else:
321-
self._state.page = None
310+
self._state._lazy_launcher = _launch
311+
try:
312+
return await handler()
313+
finally:
314+
self._state._lazy_launcher = None
315+
self._state.playwright_instance = None
316+
self._state.launch_error = None
317+
if self._state.browser is not None:
318+
browser = self._state.browser
319+
self._state.page = None
320+
self._state.browser = None
321+
await browser.close()
322+
else:
323+
self._state.page = None
324+
if _pw_ctx is not None:
325+
await _pw_ctx.__aexit__(None, None, None)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pydantic-deep"
3-
version = "0.3.10"
3+
version = "0.3.11"
44
description = "Batteries-included agent harness for Python — tool-calling, sandboxed execution, multi-agent teams, and unlimited context on Pydantic AI"
55
readme = "README.md"
66
keywords = [

tests/test_browser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,7 @@ async def handler() -> Any:
669669
await cap.wrap_run(_ctx(), handler=handler)
670670

671671
assert launcher_installed is not None # launcher was installed
672+
pw.__aenter__.assert_not_called() # Playwright driver NOT started
672673
pw.chromium.launch.assert_not_called() # Chromium NOT started
673674
browser.close.assert_not_called() # nothing to close
674675

tests/test_cli_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,10 @@ def test_include_browser_defaults_true(self) -> None:
293293
config = CliConfig()
294294
assert config.include_browser is True
295295

296-
def test_browser_headless_defaults_false(self) -> None:
297-
"""Browser should show a visible window by default."""
296+
def test_browser_headless_defaults_true(self) -> None:
297+
"""Browser should run headless (no visible window) by default."""
298298
config = CliConfig()
299-
assert config.browser_headless is False
299+
assert config.browser_headless is True
300300

301301
def test_loads_include_browser_from_file(self, tmp_path: Path) -> None:
302302
config_file = tmp_path / "config.toml"

0 commit comments

Comments
 (0)