Fix headed browser sessions being killed after every turn

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)
This commit is contained in:
Vishnu 2026-05-11 20:10:23 -04:00 committed by Teknium
parent 581e92e42c
commit 29899c2aa9
2 changed files with 50 additions and 2 deletions

View file

@ -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}")

View file

@ -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: