From 29899c2aa9f3baaab0d1ac26fbb74d9991cfc363 Mon Sep 17 00:00:00 2001 From: Vishnu <857006+vkkong@users.noreply.github.com> Date: Mon, 11 May 2026 20:10:23 -0400 Subject: [PATCH] Fix headed browser sessions being killed after every turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-turn `_cleanup_task_resources` unconditionally calls `cleanup_browser`, closing the browser window immediately after each bot reply. This makes headed mode (`AGENT_BROWSER_HEADED=1`) unusable — the window flashes up and disappears on every response. This mirrors the existing VM persistence pattern: skip per-turn cleanup when headed mode is active and let the inactivity reaper handle idle sessions instead. Full-session teardown on gateway shutdown remains unconditional. Also adds `browser.headed` config.yaml support and passes `--headed` to agent-browser in local mode when configured, so users don't need to rely solely on the `AGENT_BROWSER_HEADED` env var. Closes #11020 (lead bug) --- agent/chat_completion_helpers.py | 14 +++++++++++- tools/browser_tool.py | 38 +++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 68404da5f527..3bfe49781bf8 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2125,6 +2125,11 @@ def cleanup_task_resources(agent, task_id: str) -> None: ``terminal.lifetime_seconds`` is exceeded. Non-persistent backends are torn down per-turn as before to prevent resource leakage (the original intent of this hook for the Morph backend, see commit fbd3a2fd). + + Skips ``cleanup_browser`` in headed mode so the browser window stays + visible between turns. The inactivity reaper in + ``browser_tool._cleanup_inactive_browser_sessions`` still handles + idle sessions. """ try: if is_persistent_env(task_id): @@ -2139,7 +2144,14 @@ def cleanup_task_resources(agent, task_id: str) -> None: if agent.verbose_logging: logger.warning(f"Failed to cleanup VM for task {task_id}: {e}") try: - _ra().cleanup_browser(task_id) + if os.environ.get("AGENT_BROWSER_HEADED"): + if agent.verbose_logging: + logging.debug( + f"Skipping per-turn cleanup_browser for headed session {task_id}; " + f"idle reaper will handle it." + ) + else: + _ra().cleanup_browser(task_id) except Exception as e: if agent.verbose_logging: logger.warning(f"Failed to cleanup browser for task {task_id}: {e}") diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 71c60237235e..bcc1455d7831 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -885,6 +885,40 @@ def _get_browser_engine() -> str: return _cached_browser_engine +_cached_headed_mode: Optional[bool] = None +_headed_mode_resolved = False + + +def _is_headed_mode() -> bool: + """Return True when the browser should launch in headed (visible) mode. + + Reads ``config["browser"]["headed"]`` with ``AGENT_BROWSER_HEADED`` env + var as fallback. Result is cached after the first call. + """ + global _cached_headed_mode, _headed_mode_resolved + if _headed_mode_resolved: + return _cached_headed_mode # type: ignore[return-value] + + _headed_mode_resolved = True + _cached_headed_mode = False + + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() + val = cfg.get("browser", {}).get("headed") + if val is not None: + _cached_headed_mode = str(val).strip().lower() in ("true", "1", "yes") + except Exception as e: + logger.debug("Could not read browser.headed from config: %s", e) + + if not _cached_headed_mode: + env_val = os.environ.get("AGENT_BROWSER_HEADED", "").strip() + if env_val and env_val.lower() in ("true", "1", "yes"): + _cached_headed_mode = True + + return _cached_headed_mode + + def _should_inject_engine(engine: str) -> bool: """Return True when the engine flag should be added to agent-browser commands. @@ -2347,8 +2381,10 @@ def _run_browser_command( # --session creates a local browser instance and silently ignores --cdp. backend_args = ["--cdp", session_info["cdp_url"]] else: - # Local mode — launch a headless Chromium instance + # Local mode — launch Chromium (headless by default, headed when configured) backend_args = ["--session", session_info["session_name"]] + if _is_headed_mode(): + backend_args.append("--headed") # Lightpanda engine injection (local mode only, agent-browser v0.25.3+). # Use the resolved session backend rather than global cloud-provider state: