BrowserForge / server /browser_env_environment.py
cryptodarth's picture
V1
42d1599
Raw
History Blame Contribute Delete
61 kB
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""OpenEnv server environment backed by BrowserGym MiniWoB tasks."""
from __future__ import annotations
import asyncio
import logging
import os
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple
from uuid import uuid4
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import State
try:
from ..action_mapping import ActionTranslationError, translate_browser_action
from ..agents import build_agent_bundle
from ..artifacts import save_step_artifacts
from ..curriculum import CurriculumPool, TaskVariant
from ..models import BrowserAction, BrowserObservation, ConstraintState
from ..observation import compact_elements_with_stats, extract_instruction, extract_url
from ..replay import ReplayStore
from ..reward import RewardEngine
except ImportError: # pragma: no cover - direct script execution fallback
from action_mapping import ActionTranslationError, translate_browser_action
from agents import build_agent_bundle
from artifacts import save_step_artifacts
from curriculum import CurriculumPool, TaskVariant
from models import BrowserAction, BrowserObservation, ConstraintState
from observation import compact_elements_with_stats, extract_instruction, extract_url
from replay import ReplayStore
from reward import RewardEngine
class BrowserGymRuntime:
"""Thin runtime wrapper over real BrowserGym environments."""
def __init__(self):
self.env = None
self._thread_loop: Optional[asyncio.AbstractEventLoop] = None
self._playwright = None
def _ensure_thread_event_loop(self) -> None:
loop: Optional[asyncio.AbstractEventLoop]
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = None
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._thread_loop = loop
def reset(self, variant: TaskVariant) -> Tuple[Mapping[str, Any], Mapping[str, Any]]:
"""Start a fresh BrowserGym environment for one curriculum variant.
Example:
runtime = BrowserGymRuntime()
raw_obs, info = runtime.reset(variant)
"""
self.close()
if "miniwob" in variant.task_id and not os.getenv("MINIWOB_URL"):
raise RuntimeError(
"MINIWOB_URL is required for BrowserGym MiniWoB tasks. "
"Install MiniWoB++ and set MINIWOB_URL to the local html/miniwob path."
)
self._ensure_thread_event_loop()
import gymnasium as gym
import playwright.sync_api
import browsergym.core
import browsergym.miniwob # noqa: F401 - registers MiniWoB envs
# BrowserGym keeps a process-global Playwright instance. In the OpenEnv server
# that object can otherwise leak across worker/session threads, which breaks
# Playwright sync calls with "no running event loop" style errors.
self._playwright = playwright.sync_api.sync_playwright().start()
browsergym.core._set_global_playwright(self._playwright)
headless = os.getenv("BROWSER_ENV_HEADLESS", "1") != "0"
self.env = gym.make(variant.task_id, headless=headless)
raw_obs, info = self.env.reset(seed=variant.seed)
return _ensure_mapping(raw_obs), _ensure_mapping(info)
def step(self, action: str) -> Tuple[Mapping[str, Any], float, bool, bool, Mapping[str, Any]]:
"""Execute one translated BrowserGym action string."""
if self.env is None:
raise RuntimeError("BrowserGym environment must be reset before step")
raw_obs, reward, terminated, truncated, info = self.env.step(action)
return _ensure_mapping(raw_obs), float(reward), bool(terminated), bool(truncated), _ensure_mapping(info)
def close(self) -> None:
"""Tear down the BrowserGym env and the process-global Playwright handle."""
if self.env is not None:
try:
self.env.close()
except Exception:
pass
self.env = None
if self._playwright is not None:
try:
import browsergym.core
if getattr(browsergym.core, "_PLAYWRIGHT", None) is self._playwright:
browsergym.core._set_global_playwright(None)
except Exception:
pass
try:
self._playwright.stop()
except Exception:
pass
self._playwright = None
if self._thread_loop is not None:
try:
self._thread_loop.close()
except Exception:
pass
try:
asyncio.set_event_loop(None)
except Exception:
pass
self._thread_loop = None
class BrowserEnvironment(Environment):
"""OpenEnv environment for constraint-aware browser-agent RL.
This class is the main integration point for the repo:
- BrowserGym supplies the real browser task runtime
- the translator resolves structured actions into BrowserGym commands
- the reward engine applies dense shaping
- the oracle/judge bundle provides optional LLM assistance and terminal scoring
- the curriculum/replay layers track progression and artifacts
"""
SUPPORTS_CONCURRENT_SESSIONS: bool = False
def __init__(self):
self.logger = logging.getLogger("browser_env.environment")
self._state = State(episode_id=str(uuid4()), step_count=0)
self.curriculum = CurriculumPool()
self.runtime = BrowserGymRuntime()
self.reward_engine = RewardEngine()
self.agents = build_agent_bundle()
self.llm_services_enabled = os.getenv("BROWSER_ENV_DISABLE_LLM") != "1"
# Keep curriculum self-evolution deterministic and robust: step-budget
# mutation works even when oracle/judge LLM services are disabled.
self.task_generator_enabled = os.getenv("BROWSER_ENV_ENABLE_STEP_BUDGET_ADAPTATION", "1") != "0"
self.replay = ReplayStore()
self.current_variant = self.curriculum.current()
self.raw_obs: Mapping[str, Any] = {}
self.raw_info: Mapping[str, Any] = {}
self.history: List[Dict[str, Any]] = []
self.trajectory: List[Dict[str, Any]] = []
self.constraints = ConstraintState()
self.latest_observation_stats: Dict[str, Any] = {}
self.last_translated_action: Optional[str] = None
self.consecutive_repeated_actions = 0
self.consecutive_no_progress_steps = 0
self.last_progress_evidence: Dict[str, Any] = {}
self.total_reward = 0.0
self.episode_done = False
self.final_success = False
self.final_failure_reason = "none"
self.final_failure_detail: Dict[str, Any] = {}
self.oracle_submit_after_type_repeat = max(
1,
int(os.getenv("BROWSER_ENV_ORACLE_SUBMIT_AFTER_TYPE_REPEAT", "1")),
)
self.oracle_submit_after_select_repeat = max(
1,
int(os.getenv("BROWSER_ENV_ORACLE_SUBMIT_AFTER_SELECT_REPEAT", "1")),
)
self.curriculum_advance_on_failure_after = max(
0,
int(os.getenv("BROWSER_ENV_CURRICULUM_ADVANCE_ON_FAILURE_AFTER", "3")),
)
self.max_repeated_actions = max(
1,
int(os.getenv("BROWSER_ENV_MAX_REPEATED_ACTIONS", "3")),
)
self.max_invalid_actions = max(
1,
int(os.getenv("BROWSER_ENV_MAX_INVALID_ACTIONS", "3")),
)
self.max_no_progress_steps_base = max(
0,
int(os.getenv("BROWSER_ENV_MAX_NO_PROGRESS_STEPS", "4")),
)
self.max_no_progress_steps_cap = max(
self.max_no_progress_steps_base,
int(os.getenv("BROWSER_ENV_MAX_NO_PROGRESS_STEPS_CAP", "8") or 8),
)
self.no_progress_step_ratio = max(
0.0,
float(os.getenv("BROWSER_ENV_NO_PROGRESS_STEP_RATIO", "0.4") or 0.4),
)
self.max_no_progress_steps = self.max_no_progress_steps_base
self.logger.info(
"env_init episode_id=%s task_id=%s variant=%s llm_enabled=%s",
self._state.episode_id,
self.current_variant.task_id,
self.current_variant.variant_id,
self.llm_services_enabled,
)
def reset(
self,
seed: Optional[int] = None,
episode_id: Optional[str] = None,
task_id: Optional[str] = None,
variant_id: Optional[str] = None,
**_: Any,
) -> BrowserObservation:
"""Reset to the next curriculum variant and return the first observation."""
self._state = State(episode_id=str(uuid4()), step_count=0)
if episode_id:
self._state.episode_id = episode_id
self.current_variant = self.curriculum.variant_for_reset(
seed,
task_id=task_id,
variant_id=variant_id,
)
if self.max_no_progress_steps_base <= 0:
self.max_no_progress_steps = 0
else:
scaled = int(round(self.current_variant.max_steps * self.no_progress_step_ratio))
self.max_no_progress_steps = max(
self.max_no_progress_steps_base,
min(self.max_no_progress_steps_cap, scaled),
)
self.raw_obs, self.raw_info = self.runtime.reset(self.current_variant)
self.history = []
self.trajectory = []
self.last_translated_action = None
self.consecutive_repeated_actions = 0
self.consecutive_no_progress_steps = 0
self.last_progress_evidence = {}
self.total_reward = 0.0
self.episode_done = False
self.final_success = False
self.final_failure_reason = "none"
self.final_failure_detail = {}
self.constraints = self._new_constraints()
reset_observation = self._make_observation(
reward=0.0,
done=False,
success=False,
failure_reason="none",
metadata={
"event": "reset",
"runtime": "browsergym",
"raw_info": dict(self.raw_info),
"requested_variant_id": variant_id,
"requested_task_id": task_id,
},
)
observation_stats = dict(reset_observation.metadata.get("observation_stats") or {})
self.logger.info(
"reset episode_id=%s task_id=%s variant=%s difficulty=%s seed=%s max_steps=%s max_no_progress_steps=%s instruction=%r elements=%s",
self._state.episode_id,
self.current_variant.task_id,
self.current_variant.variant_id,
self.current_variant.difficulty,
self.current_variant.seed,
self.current_variant.max_steps,
self.max_no_progress_steps,
extract_instruction(self.raw_obs, self.raw_info)[:200],
observation_stats.get("elements_after", 0),
)
return reset_observation
def step(
self,
action: BrowserAction,
timeout_s: Optional[float] = None,
**_: Any,
) -> BrowserObservation: # type: ignore[override]
"""Advance the environment by one structured browser action.
The step flow is intentionally staged:
1. optionally ask the oracle for help
2. translate the safe structured action into BrowserGym syntax
3. execute the BrowserGym step
4. apply shaped reward components
5. if terminal, ask the judge to score the full trajectory
"""
if self._is_done():
self.logger.warning(
"step_after_done episode_id=%s step=%s action=%s",
self._state.episode_id,
self._state.step_count,
action.action_type,
)
return self._make_observation(
reward=0.0,
done=True,
success=self.final_success,
failure_reason=self.final_failure_reason,
metadata={
"event": "step_after_done",
"failure_detail": dict(self.final_failure_detail),
},
)
pre_step_observation = self._make_observation(
reward=0.0,
done=False,
success=False,
failure_reason="none",
)
action_to_execute = action
oracle_delta = 0
current_elements = list(pre_step_observation.elements)
if action.action_type == "ask_oracle":
if self.constraints.oracle_calls >= self.constraints.llm_budget:
self.logger.warning(
"oracle_budget_exceeded episode_id=%s step=%s llm_budget=%s",
self._state.episode_id,
self._state.step_count,
self.constraints.llm_budget,
)
return self._penalized_invalid_observation(action, "oracle_budget_exceeded")
oracle_delta = 1
self.constraints.oracle_calls += 1
try:
action_to_execute = self.agents.suggest_action(pre_step_observation, self.history)
except Exception as exc:
self.logger.exception(
"oracle_failed episode_id=%s step=%s error=%s",
self._state.episode_id,
self._state.step_count,
exc,
)
return self._penalized_invalid_observation(action, "browser_error", oracle_delta=oracle_delta)
action_to_execute = self._repair_oracle_action(
action_to_execute,
current_elements,
instruction=pre_step_observation.instruction,
)
self.logger.info(
"oracle_action episode_id=%s step=%s suggested_action=%s target_id=%s text=%r reasoning=%r",
self._state.episode_id,
self._state.step_count,
action_to_execute.action_type,
action_to_execute.target_id,
action_to_execute.text,
action_to_execute.reasoning,
)
invalid_delta = 0
repetition_delta = 0
translation_metadata: Dict[str, Any] = {}
try:
translation = translate_browser_action(
action_to_execute,
current_elements,
instruction=pre_step_observation.instruction,
history=self.history,
)
translated = translation.browsergym_action
translation_metadata = dict(translation.metadata)
action_to_execute = translation.resolved_action
except ActionTranslationError as exc:
self.logger.warning(
"action_translation_error episode_id=%s step=%s action=%s target_id=%s error=%s",
self._state.episode_id,
self._state.step_count,
action_to_execute.action_type,
action_to_execute.target_id,
exc,
)
return self._penalized_invalid_observation(
action_to_execute,
"invalid_action",
oracle_delta=oracle_delta,
)
if translated == self.last_translated_action:
repetition_delta = 1
self.constraints.repeated_actions += 1
self.consecutive_repeated_actions += 1
else:
self.consecutive_repeated_actions = 0
self.last_translated_action = translated
browser_reward = 0.0
terminated = False
truncated = False
info: Mapping[str, Any] = {}
failure_reason = "none"
failure_detail: Dict[str, Any] = {}
try:
self.raw_obs, browser_reward, terminated, truncated, info = self.runtime.step(translated)
except Exception as exc:
invalid_delta = 1
self.constraints.invalid_actions += 1
failure_reason = "browsergym_action_error"
failure_detail = {
"error": str(exc),
"error_type": exc.__class__.__name__,
"translated_action": translated,
"target_id": action_to_execute.target_id,
}
info = dict(failure_detail)
self.logger.exception(
"runtime_step_failed episode_id=%s step=%s action=%s translated=%s target_id=%s error=%s",
self._state.episode_id,
self._state.step_count,
action_to_execute.action_type,
translated,
action_to_execute.target_id,
exc,
)
self._state.step_count += 1
success = bool(terminated and browser_reward > 0)
submit_like_failure = self._is_submit_like_action(action_to_execute, current_elements) and browser_reward <= 0.0
progress_evidence = self._assess_progress_evidence(
previous_elements=current_elements,
action=action_to_execute,
browser_reward=browser_reward,
success=success,
)
self.last_progress_evidence = dict(progress_evidence)
progress_signal = bool(progress_evidence.get("progress_signal"))
if progress_signal:
self.consecutive_no_progress_steps = 0
else:
self.consecutive_no_progress_steps += 1
# Treat repetition as terminal only when it is paired with no progress.
# This avoids aborting useful retries during transient UI/render delays.
repeated_action_loop = (
self.consecutive_repeated_actions >= self.max_repeated_actions
and not progress_signal
)
too_many_invalid_actions = self.constraints.invalid_actions >= self.max_invalid_actions
low_progress_abort = (
self.max_no_progress_steps > 0
and self.consecutive_no_progress_steps >= self.max_no_progress_steps
) or (
submit_like_failure
and not self._has_form_action()
and not success
)
done = bool(
terminated
or truncated
or self._state.step_count >= self.current_variant.max_steps
or repeated_action_loop
or too_many_invalid_actions
or low_progress_abort
)
if success:
failure_reason = "success"
failure_detail = {"mode": "success"}
elif failure_reason == "none" and repeated_action_loop:
failure_reason = "repeated_action_loop"
failure_detail = {
"mode": "repeated_action_loop",
"consecutive_repeated_actions": self.consecutive_repeated_actions,
"translated_action": translated,
}
elif failure_reason == "none" and too_many_invalid_actions:
failure_reason = "too_many_invalid_actions"
failure_detail = {
"mode": "too_many_invalid_actions",
"invalid_actions": self.constraints.invalid_actions,
}
elif failure_reason == "none" and low_progress_abort:
failure_reason = "low_progress_abort"
failure_detail = self._build_failure_detail(
action=action_to_execute,
translated=translated,
browser_reward=browser_reward,
terminated=terminated,
truncated=truncated,
submit_like_failure=submit_like_failure,
mode="low_progress_abort",
)
elif failure_reason == "none" and self._state.step_count >= self.current_variant.max_steps:
failure_reason = "max_steps_exceeded"
failure_detail = {
"mode": "max_steps_exceeded",
"step_count": self._state.step_count,
"max_steps": self.current_variant.max_steps,
"translated_action": translated,
}
elif failure_reason == "none" and done:
failure_reason = "submission_failed" if submit_like_failure else "task_failed"
failure_detail = self._build_failure_detail(
action=action_to_execute,
translated=translated,
browser_reward=browser_reward,
terminated=terminated,
truncated=truncated,
submit_like_failure=submit_like_failure,
mode=failure_reason,
)
delayed_penalty = self._delayed_penalty_for_failure(
failure_reason=failure_reason,
failure_detail=failure_detail,
)
if delayed_penalty:
self.constraints.delayed_failures += 1
reward_breakdown = self.reward_engine.compute(
browsergym_reward=browser_reward,
success=success,
step_delta=1,
progress_delta=int(progress_evidence.get("reward_credit", 0)),
oracle_delta=oracle_delta,
mistake_delta=invalid_delta,
repetition_delta=repetition_delta,
delayed_penalty=delayed_penalty,
)
event = {
"step": self._state.step_count,
"observation": _dump_model(pre_step_observation),
"action": _dump_action(action),
"executed_action": _dump_action(action_to_execute),
"browsergym_action": translated,
"target_resolution": translation_metadata,
"browsergym_reward": browser_reward,
"reward_breakdown": _dump_model(reward_breakdown),
"success": success,
"failure_reason": failure_reason,
"failure_detail": dict(failure_detail),
"progress_evidence": dict(progress_evidence),
"constraints": _dump_model(self.constraints),
}
self.trajectory.append(event)
judge_result = None
if done:
# The judge is terminal-only today: it sees the full trajectory and
# adds a final quality reward, while the dense step reward still
# comes from BrowserGym plus local shaping penalties.
try:
judge_result = self.agents.score_trajectory(self.trajectory)
except Exception as exc:
self.logger.exception(
"judge_failed episode_id=%s step=%s error=%s",
self._state.episode_id,
self._state.step_count,
exc,
)
event["judge"] = {
"label": "unscored",
"reward": 0.0,
"rationale": f"judge_failed:{exc.__class__.__name__}",
}
else:
reward_breakdown.trajectory_quality = judge_result.reward
reward_breakdown.judge_quality_reward = judge_result.reward
reward_breakdown.total += judge_result.reward
event["judge"] = {
"label": judge_result.label,
"reward": judge_result.reward,
"rationale": judge_result.rationale,
}
event["reward_breakdown"] = _dump_model(reward_breakdown)
artifact_paths = save_step_artifacts(
episode_id=self._state.episode_id or "unknown",
step=self._state.step_count,
raw_obs=self.raw_obs,
event=event,
)
event["artifacts"] = artifact_paths
self.history.append(
{
"action_type": action_to_execute.action_type,
"target_id": action_to_execute.target_id,
"text": action_to_execute.text,
"browsergym_action": translated,
"reward": reward_breakdown.total,
"status": failure_reason,
"target_resolution": translation_metadata,
"raw_info": dict(info),
}
)
self.total_reward += reward_breakdown.total
if done:
self._finish_episode(success, failure_reason)
self.logger.info(
"step episode_id=%s step=%s action=%s target_id=%s text=%r translated=%s reward=%.3f browser_reward=%.3f progress_score=%.3f progress_signals=%s done=%s success=%s failure_reason=%s oracle_calls=%s invalid_actions=%s repeated_actions=%s",
self._state.episode_id,
self._state.step_count,
action_to_execute.action_type,
action_to_execute.target_id,
action_to_execute.text,
translated,
reward_breakdown.total,
browser_reward,
float(progress_evidence.get("progress_score", 0.0)),
",".join(progress_evidence.get("signals", [])),
done,
success,
failure_reason,
self.constraints.oracle_calls,
self.constraints.invalid_actions,
self.constraints.repeated_actions,
)
return self._make_observation(
reward=reward_breakdown.total,
done=done,
success=success,
failure_reason=failure_reason,
reward_breakdown=reward_breakdown,
metadata={
"event": "step",
"runtime": "browsergym",
"requested_action": _dump_action(action),
"executed_action": _dump_action(action_to_execute),
"browsergym_action": translated,
"target_resolution": translation_metadata,
"browsergym_reward": browser_reward,
"terminated": terminated,
"truncated": truncated,
"raw_info": dict(info),
"judge": None
if judge_result is None
else {
"label": judge_result.label,
"reward": judge_result.reward,
"rationale": judge_result.rationale,
},
"failure_detail": dict(failure_detail),
"progress_evidence": dict(progress_evidence),
"curriculum": self.current_variant.to_config(),
"artifacts": artifact_paths,
},
)
@property
def state(self) -> State:
return self._state
def close(self) -> None:
self.logger.info(
"close episode_id=%s step=%s task_id=%s",
self._state.episode_id,
self._state.step_count,
self.current_variant.task_id,
)
self.runtime.close()
def _make_observation(
self,
*,
reward: float,
done: bool,
success: bool,
failure_reason: str,
reward_breakdown: Any = None,
metadata: Optional[Dict[str, Any]] = None,
) -> BrowserObservation:
history_window = self.history[-5:]
variant_config = dict(self.current_variant.to_config())
variant_config["instruction"] = extract_instruction(self.raw_obs, self.raw_info)
variant_config["history_texts"] = history_window
elements, observation_stats = compact_elements_with_stats(self.raw_obs, variant_config)
self.latest_observation_stats = observation_stats
breakdown = reward_breakdown or self.reward_engine.compute(
browsergym_reward=0.0,
success=False,
step_delta=0,
progress_delta=0,
oracle_delta=0,
mistake_delta=0,
repetition_delta=0,
)
metadata_payload = dict(metadata or {})
metadata_payload["observation_stats"] = observation_stats
metadata_payload.setdefault("observation_filter", "heuristic_ranker_v1")
return BrowserObservation(
episode_id=self._state.episode_id or "",
task_id=self.current_variant.task_id,
task_family=self.current_variant.task_family,
difficulty=self.current_variant.difficulty,
instruction=extract_instruction(self.raw_obs, self.raw_info),
url=extract_url(self.raw_obs),
step_index=self._state.step_count,
max_steps=self.current_variant.max_steps,
elements=elements,
history=history_window,
constraints=self.constraints,
reward_breakdown=breakdown,
done=done,
reward=reward,
success=success,
failure_reason=failure_reason, # type: ignore[arg-type]
metadata=metadata_payload,
)
def _new_constraints(self) -> ConstraintState:
llm_budget = 3
for env_name in ("BROWSER_ENV_ORACLE_BUDGET", "BROWSER_ENV_LLM_BUDGET"):
raw_value = os.getenv(env_name)
if not raw_value:
continue
try:
parsed = int(raw_value)
except ValueError:
continue
if parsed > 0:
llm_budget = parsed
break
return ConstraintState(
step_budget=self.current_variant.max_steps,
llm_budget=llm_budget,
oracle_calls=0,
invalid_actions=0,
repeated_actions=0,
delayed_failures=0,
current_difficulty=self.current_variant.difficulty,
curriculum_variant_id=self.current_variant.variant_id,
)
def _penalized_invalid_observation(
self,
action: BrowserAction,
failure_reason: str,
*,
oracle_delta: int = 0,
) -> BrowserObservation:
self.constraints.invalid_actions += 1
self._state.step_count += 1
terminal = (
failure_reason == "oracle_budget_exceeded"
or self._state.step_count >= self.current_variant.max_steps
or self.constraints.invalid_actions >= self.max_invalid_actions
)
if failure_reason == "invalid_action" and self.constraints.invalid_actions >= self.max_invalid_actions:
failure_reason = "too_many_invalid_actions"
breakdown = self.reward_engine.compute(
browsergym_reward=0.0,
success=False,
step_delta=1,
progress_delta=0,
oracle_delta=oracle_delta,
mistake_delta=1,
repetition_delta=0,
)
self.history.append(
{
"action_type": action.action_type,
"target_id": action.target_id,
"text": action.text,
"browsergym_action": None,
"reward": breakdown.total,
"status": failure_reason,
}
)
self.trajectory.append(
{
"step": self._state.step_count,
"action": _dump_action(action),
"executed_action": _dump_action(action),
"browsergym_action": None,
"browsergym_reward": 0.0,
"reward_breakdown": _dump_model(breakdown),
"success": False,
"failure_reason": failure_reason,
"failure_detail": {
"mode": failure_reason,
"action_type": action.action_type,
"target_id": action.target_id,
"translated_action": None,
},
"constraints": _dump_model(self.constraints),
}
)
self.total_reward += breakdown.total
if terminal:
self._finish_episode(False, failure_reason)
self.logger.warning(
"invalid_observation episode_id=%s step=%s action=%s target_id=%s failure_reason=%s terminal=%s",
self._state.episode_id,
self._state.step_count,
action.action_type,
action.target_id,
failure_reason,
terminal,
)
return self._make_observation(
reward=breakdown.total,
done=terminal,
success=False,
failure_reason=failure_reason,
reward_breakdown=breakdown,
metadata={
"event": "invalid_action",
"action": _dump_action(action),
"failure_detail": {
"mode": failure_reason,
"action_type": action.action_type,
"target_id": action.target_id,
"translated_action": None,
},
},
)
def _finish_episode(self, success: bool, failure_reason: str) -> None:
if self.episode_done:
return
self.episode_done = True
self.final_success = success
self.final_failure_reason = failure_reason
self.final_failure_detail = dict(self.trajectory[-1].get("failure_detail") or {}) if self.trajectory else {}
finished_variant_id = self.current_variant.variant_id
finished_variant_config = self.current_variant.to_config()
episode = {
"episode_id": self._state.episode_id,
"task_id": self.current_variant.task_id,
"variant_id": self.current_variant.variant_id,
"seed": self.current_variant.seed,
"steps": self._state.step_count,
"success": success,
"failure_reason": failure_reason,
"failure_detail": dict(self.final_failure_detail),
"progress_evidence": dict(self.last_progress_evidence),
"total_reward": self.total_reward,
"oracle_calls": self.constraints.oracle_calls,
"invalid_actions": self.constraints.invalid_actions,
"trajectory": self.trajectory,
}
self.replay.record_episode(episode)
self.replay.flush()
self.logger.info(
"episode_finished episode_id=%s task_id=%s variant=%s success=%s failure_reason=%s steps=%s total_reward=%.3f oracle_calls=%s invalid_actions=%s",
self._state.episode_id,
self.current_variant.task_id,
self.current_variant.variant_id,
success,
failure_reason,
self._state.step_count,
self.total_reward,
self.constraints.oracle_calls,
self.constraints.invalid_actions,
)
if success:
self.curriculum.note_success(finished_variant_id)
self.curriculum.advance_on_success()
else:
count = self.curriculum.note_failure(finished_variant_id)
if self.curriculum_advance_on_failure_after and count >= self.curriculum_advance_on_failure_after:
next_variant = self.curriculum.advance_on_failure()
self.logger.info(
"curriculum_advance_on_failure episode_id=%s from_variant=%s to_variant=%s failure_count=%s threshold=%s",
self._state.episode_id,
finished_variant_id,
next_variant.variant_id,
count,
self.curriculum_advance_on_failure_after,
)
if count >= 2 and self.task_generator_enabled:
try:
mutation = self.agents.mutate_task(
finished_variant_config,
{"reason": failure_reason, "count": count},
)
except RuntimeError:
mutation = None
if mutation and self._variant_is_valid(mutation):
before_count = len(self.curriculum.variants)
validated = self.curriculum.add_validated_variant(mutation)
if len(self.curriculum.variants) > before_count:
self.logger.info(
"curriculum_mutation_added episode_id=%s base_variant=%s mutated_variant=%s task_id=%s",
self._state.episode_id,
finished_variant_id,
validated.variant_id,
validated.task_id,
)
else:
self.logger.info(
"curriculum_mutation_duplicate episode_id=%s base_variant=%s variant=%s task_id=%s",
self._state.episode_id,
finished_variant_id,
validated.variant_id,
validated.task_id,
)
def _target_is_valid(self, action: BrowserAction, elements: List[Any]) -> bool:
if action.action_type in {"noop", "scroll"}:
return True
if action.action_type == "ask_oracle":
return True
ids = {element.id for element in elements if not element.id.startswith("distractor")}
if action.action_type == "submit":
return not action.target_id or action.target_id in ids
if not action.target_id:
return False
return action.target_id in ids
def _repair_oracle_action(
self,
suggested: BrowserAction,
elements: List[Any],
*,
instruction: str,
) -> BrowserAction:
"""Apply conservative guardrails to avoid oracle getting stuck in type loops."""
submit_after_select = self._maybe_submit_after_recent_select(suggested, elements, instruction=instruction)
if submit_after_select is not None:
return submit_after_select
if suggested.action_type == "select":
return self._repair_select_oracle_action(suggested, elements, instruction=instruction)
if suggested.action_type != "type":
return self._repair_non_type_oracle_action(suggested, elements, instruction=instruction)
if not suggested.target_id:
return suggested
typed_text = (suggested.text or "").strip()
if not typed_text:
return suggested
repeat_count = self._recent_identical_type_count(suggested.target_id)
text_present = self._elements_contain_text(elements, typed_text)
if repeat_count < self.oracle_submit_after_type_repeat and not text_present:
return suggested
submit_target = self._find_clickable_submit_target(elements)
if submit_target:
self.logger.info(
"oracle_auto_submit episode_id=%s step=%s from_target=%s submit_target=%s repeat_count=%s text_present=%s",
self._state.episode_id,
self._state.step_count,
suggested.target_id,
submit_target,
repeat_count,
text_present,
)
return BrowserAction(
action_type="click",
target_id=submit_target,
reasoning="auto_submit_after_repeated_type",
)
self.logger.info(
"oracle_auto_submit episode_id=%s step=%s from_target=%s submit_target=%s repeat_count=%s text_present=%s",
self._state.episode_id,
self._state.step_count,
suggested.target_id,
None,
repeat_count,
text_present,
)
return BrowserAction(action_type="submit", reasoning="auto_submit_after_repeated_type")
def _maybe_submit_after_recent_select(
self,
suggested: BrowserAction,
elements: List[Any],
*,
instruction: str,
) -> Optional[BrowserAction]:
"""Turn select loops into submit actions for select-then-submit tasks."""
if not self.history:
return None
last = self.history[-1]
if last.get("action_type") != "select":
return None
if suggested.action_type not in {"select", "noop", "scroll"}:
return None
instruction_lc = instruction.lower()
submit_target = self._find_clickable_submit_target(elements)
needs_submit = bool(submit_target) or "submit" in instruction_lc or "click submit" in instruction_lc
if not needs_submit:
return None
if submit_target:
self.logger.info(
"oracle_auto_submit_after_recent_select episode_id=%s step=%s submit_target=%s suggested_action=%s",
self._state.episode_id,
self._state.step_count,
submit_target,
suggested.action_type,
)
return BrowserAction(
action_type="click",
target_id=submit_target,
reasoning="auto_submit_after_recent_select",
)
self.logger.info(
"oracle_auto_submit_after_recent_select episode_id=%s step=%s submit_target=%s suggested_action=%s",
self._state.episode_id,
self._state.step_count,
None,
suggested.action_type,
)
return BrowserAction(action_type="submit", reasoning="auto_submit_after_recent_select")
def _repair_select_oracle_action(
self,
suggested: BrowserAction,
elements: List[Any],
*,
instruction: str,
) -> BrowserAction:
target_phrase = self._extract_instruction_target_phrase(instruction)
repaired = suggested
cleaned_text = (suggested.text or "").strip()
low_signal_text = cleaned_text.lower() in {"", "option", "select", "choose", "item", "test"}
if target_phrase and (low_signal_text or cleaned_text.lower() != target_phrase):
repaired = BrowserAction(
action_type="select",
target_id=suggested.target_id,
text=target_phrase,
reasoning="auto_select_text_from_instruction",
)
cleaned_text = target_phrase
repeat_count = self._recent_identical_action_count(
"select",
repaired.target_id,
cleaned_text,
)
if repeat_count < self.oracle_submit_after_select_repeat:
return self._repair_non_type_oracle_action(repaired, elements, instruction=instruction)
submit_target = self._find_clickable_submit_target(elements)
if submit_target:
self.logger.info(
"oracle_auto_submit_after_select episode_id=%s step=%s from_target=%s submit_target=%s repeat_count=%s",
self._state.episode_id,
self._state.step_count,
repaired.target_id,
submit_target,
repeat_count,
)
return BrowserAction(
action_type="click",
target_id=submit_target,
reasoning="auto_submit_after_repeated_select",
)
self.logger.info(
"oracle_auto_submit_after_select episode_id=%s step=%s from_target=%s submit_target=%s repeat_count=%s",
self._state.episode_id,
self._state.step_count,
repaired.target_id,
None,
repeat_count,
)
return BrowserAction(action_type="submit", reasoning="auto_submit_after_repeated_select")
def _repair_non_type_oracle_action(
self,
suggested: BrowserAction,
elements: List[Any],
*,
instruction: str,
) -> BrowserAction:
# Handle repeated click/select loops by switching to a better candidate that
# matches instruction text or a submit control.
if suggested.action_type not in {"click", "select", "submit"}:
return suggested
repeat_count = self._recent_identical_action_count(
suggested.action_type,
suggested.target_id,
suggested.text,
)
if repeat_count <= 0 and suggested.action_type != "submit":
return suggested
target_phrase = self._extract_instruction_target_phrase(instruction)
tried_ids = self._recent_target_ids(window=4)
if suggested.target_id:
tried_ids.add(suggested.target_id)
candidate = self._pick_best_click_target(
elements,
target_phrase=target_phrase,
exclude_ids=tried_ids,
)
if candidate and candidate != suggested.target_id:
self.logger.info(
"oracle_auto_retarget episode_id=%s step=%s action=%s from_target=%s to_target=%s repeat_count=%s phrase=%r",
self._state.episode_id,
self._state.step_count,
suggested.action_type,
suggested.target_id,
candidate,
repeat_count,
target_phrase,
)
return BrowserAction(
action_type="click",
target_id=candidate,
reasoning="auto_retarget_after_repeated_click_or_select",
)
if suggested.action_type == "submit":
return suggested
return suggested
def _recent_identical_type_count(self, target_id: str) -> int:
count = 0
for item in reversed(self.history):
if item.get("action_type") != "type":
break
if item.get("target_id") != target_id:
break
count += 1
return count
def _recent_identical_action_count(self, action_type: str, target_id: Optional[str], text: Optional[str]) -> int:
count = 0
wanted_text = (text or "").strip()
ignore_text = action_type in {"click", "select"}
for item in reversed(self.history):
if item.get("action_type") != action_type:
break
if (item.get("target_id") or None) != (target_id or None):
break
if not ignore_text and (item.get("text") or "").strip() != wanted_text:
break
count += 1
return count
def _recent_target_ids(self, window: int = 4) -> set[str]:
out: set[str] = set()
for item in self.history[-max(0, window) :]:
target_id = item.get("target_id")
if target_id:
out.add(str(target_id))
return out
def _elements_contain_text(self, elements: List[Any], text: str) -> bool:
wanted = text.strip().lower()
if not wanted:
return False
for element in elements:
value = (getattr(element, "text", "") or "").strip().lower()
if value == wanted:
return True
return False
def _find_clickable_submit_target(self, elements: List[Any]) -> Optional[str]:
keywords = ("submit", "send", "ok", "done", "go", "next", "continue")
for element in elements:
attrs = getattr(element, "attributes", {}) or {}
clickable = bool(attrs.get("clickable", False))
if not clickable:
continue
if not bool(getattr(element, "visible", True)):
continue
if not bool(getattr(element, "enabled", True)):
continue
haystack = " ".join(
[
str(getattr(element, "text", "") or "").lower(),
str(getattr(element, "role", "") or "").lower(),
str(getattr(element, "tag", "") or "").lower(),
str(attrs.get("name", "")).lower(),
str(attrs.get("aria-label", "")).lower(),
]
)
if any(keyword in haystack for keyword in keywords):
return str(getattr(element, "id", "") or "")
return None
def _extract_instruction_target_phrase(self, instruction: str) -> Optional[str]:
if not instruction:
return None
quoted = re.findall(r'"([^"]+)"|\'([^\']+)\'', instruction)
for pair in quoted:
value = (pair[0] or pair[1] or "").strip()
if value:
return value.lower()
return None
def _pick_best_click_target(
self,
elements: List[Any],
*,
target_phrase: Optional[str],
exclude_ids: set[str],
) -> Optional[str]:
scored: List[Tuple[Tuple[int, int, int, int], str]] = []
for element in elements:
element_id = str(getattr(element, "id", "") or "")
if not element_id or element_id in exclude_ids:
continue
attrs = getattr(element, "attributes", {}) or {}
clickable = bool(attrs.get("clickable", False))
if not clickable:
continue
visible = bool(getattr(element, "visible", True))
enabled = bool(getattr(element, "enabled", True))
text = str(getattr(element, "text", "") or "").strip().lower()
role = str(getattr(element, "role", "") or "").strip().lower()
phrase_score = 0
if target_phrase and text:
if text == target_phrase:
phrase_score = 3
elif target_phrase in text:
phrase_score = 2
elif text in target_phrase:
phrase_score = 1
submit_score = int("submit" in text or "submit" in role)
score = (phrase_score, submit_score, int(visible), int(enabled))
scored.append((score, element_id))
if not scored:
return None
scored.sort(reverse=True)
return scored[0][1]
def _has_form_action(self) -> bool:
return any(item.get("action_type") in {"type", "select"} for item in self.history)
def _is_submit_like_action(self, action: BrowserAction, elements: List[Any]) -> bool:
"""Return whether an action behaves like a form submission attempt."""
if action.action_type == "submit":
return True
if action.action_type != "click" or not action.target_id:
return False
submit_target = self._find_clickable_submit_target(elements)
return bool(submit_target and action.target_id == submit_target)
def _is_done(self) -> bool:
return self.episode_done or self._state.step_count >= self.current_variant.max_steps
def _build_failure_detail(
self,
*,
action: BrowserAction,
translated: str,
browser_reward: float,
terminated: bool,
truncated: bool,
submit_like_failure: bool,
mode: str,
) -> Dict[str, Any]:
detail_mode = mode
if truncated:
detail_mode = "runtime_truncated"
elif mode == "submission_failed" and submit_like_failure and not self._has_form_action():
detail_mode = "premature_submit"
elif mode == "submission_failed" and submit_like_failure and self._has_form_action():
detail_mode = "submit_without_success"
elif mode == "task_failed" and terminated and browser_reward <= 0.0:
detail_mode = "task_failed_terminal"
elif terminated and browser_reward <= 0.0:
detail_mode = "terminal_without_success"
return {
"mode": detail_mode,
"base_reason": mode,
"action_type": action.action_type,
"target_id": action.target_id,
"translated_action": translated,
"browser_reward": browser_reward,
"terminated": terminated,
"truncated": truncated,
"has_form_action": self._has_form_action(),
"no_progress_streak": self.consecutive_no_progress_steps,
"progress_score": float(self.last_progress_evidence.get("progress_score", 0.0)),
"progress_signals": list(self.last_progress_evidence.get("signals", [])),
}
def _delayed_penalty_for_failure(
self,
*,
failure_reason: str,
failure_detail: Dict[str, Any],
) -> float:
if failure_reason not in {"submission_failed", "low_progress_abort", "task_failed"}:
return 0.0
mode = str(failure_detail.get("mode") or "")
if mode == "premature_submit":
return -1.25
if mode == "submit_without_success":
return -1.0
if mode == "runtime_truncated":
return -0.5
if mode == "terminal_without_success":
return -0.75
if mode == "task_failed_terminal":
return -0.75
if self._has_form_action():
return -1.0
return -0.5
def _observation_config(self) -> Dict[str, Any]:
variant_config = dict(self.current_variant.to_config())
variant_config["instruction"] = extract_instruction(self.raw_obs, self.raw_info)
variant_config["history_texts"] = self.history[-5:]
return variant_config
def _current_elements_with_stats(self) -> Tuple[List[Any], Dict[str, Any]]:
return compact_elements_with_stats(self.raw_obs, self._observation_config())
def _element_state_signature(self, element: Any) -> Tuple[Any, ...]:
attrs = dict(getattr(element, "attributes", {}) or {})
return (
getattr(element, "text", "") or "",
attrs.get("value"),
attrs.get("aria-selected"),
attrs.get("aria-expanded"),
attrs.get("aria-pressed"),
attrs.get("checked"),
attrs.get("open"),
attrs.get("selected"),
attrs.get("class"),
getattr(element, "visible", True),
getattr(element, "enabled", True),
)
def _visible_submit_targets(self, elements: List[Any]) -> List[str]:
targets: List[str] = []
for element in elements:
attrs = dict(getattr(element, "attributes", {}) or {})
hints = attrs.get("semantic_hints") or []
if not getattr(element, "visible", True) or not getattr(element, "enabled", True):
continue
if "submit_like" in hints:
targets.append(str(getattr(element, "id", "")))
return targets
def _assess_progress_evidence(
self,
*,
previous_elements: List[Any],
action: BrowserAction,
browser_reward: float,
success: bool,
) -> Dict[str, Any]:
current_elements, current_stats = self._current_elements_with_stats()
previous_map = {str(getattr(element, "id", "")): element for element in previous_elements}
current_map = {str(getattr(element, "id", "")): element for element in current_elements}
previous_visible_ids = {element_id for element_id, element in previous_map.items() if getattr(element, "visible", True)}
current_visible_ids = {element_id for element_id, element in current_map.items() if getattr(element, "visible", True)}
union_ids = previous_visible_ids | current_visible_ids
changed_ratio = 0.0
if union_ids:
changed_ratio = len(previous_visible_ids ^ current_visible_ids) / float(len(union_ids))
target_id = str(action.target_id or "")
recent_targets = [
str(item.get("target_id") or "")
for item in self.history[-3:]
if item.get("target_id")
]
target_changed_from_recent_history = bool(target_id and target_id not in recent_targets)
previous_target = previous_map.get(target_id) if target_id else None
current_target = current_map.get(target_id) if target_id else None
previous_signature = self._element_state_signature(previous_target) if previous_target is not None else None
current_signature = self._element_state_signature(current_target) if current_target is not None else None
target_state_changed = (
previous_signature is not None
and current_signature is not None
and previous_signature != current_signature
)
text_entered = action.action_type in {"type", "clear"} and target_state_changed
selection_changed = action.action_type == "select" and target_state_changed
click_target_state_changed = action.action_type == "click" and target_state_changed
previous_submit_targets = set(self._visible_submit_targets(previous_elements))
current_submit_targets = set(self._visible_submit_targets(current_elements))
submit_target_appeared = bool(current_submit_targets - previous_submit_targets)
previous_top_ids = [str(getattr(element, "id", "")) for element in previous_elements[:3]]
current_top_ids = [str(getattr(element, "id", "")) for element in current_elements[:3]]
new_high_rank_element_surfaced = bool([element_id for element_id in current_top_ids if element_id and element_id not in previous_top_ids])
material_element_set_change = changed_ratio >= 0.25 or len(previous_visible_ids ^ current_visible_ids) >= 2
positive_browser_reward = browser_reward > 0.0
signal_weights = {
"positive_browser_reward": 2.0,
"material_element_set_change": 1.5,
"text_entered": 1.25,
"selection_changed": 1.25,
"click_target_state_changed": 1.15,
"submit_target_appeared": 1.0,
"new_high_rank_element_surfaced": 0.9,
"target_changed_from_recent_history": 0.35,
}
raw_signals = {
"positive_browser_reward": positive_browser_reward,
"material_element_set_change": material_element_set_change,
"text_entered": text_entered,
"selection_changed": selection_changed,
"click_target_state_changed": click_target_state_changed,
"submit_target_appeared": submit_target_appeared,
"new_high_rank_element_surfaced": new_high_rank_element_surfaced,
"target_changed_from_recent_history": target_changed_from_recent_history,
}
progress_score = sum(signal_weights[name] for name, active in raw_signals.items() if active)
strong_signal = any(
raw_signals[name]
for name in (
"positive_browser_reward",
"material_element_set_change",
"text_entered",
"selection_changed",
"click_target_state_changed",
"submit_target_appeared",
"new_high_rank_element_surfaced",
)
)
progress_signal = bool(success or progress_score >= 0.9)
reward_credit = int(
strong_signal
and not success
and browser_reward <= 0.0
)
return {
"progress_signal": progress_signal,
"reward_credit": reward_credit,
"progress_score": round(progress_score, 4),
"signals": [name for name, active in raw_signals.items() if active],
"changed_visible_ratio": round(changed_ratio, 4),
"previous_visible_count": len(previous_visible_ids),
"current_visible_count": len(current_visible_ids),
"previous_top_ids": previous_top_ids,
"current_top_ids": current_top_ids,
"recent_target_ids": recent_targets,
"current_observation_stats": current_stats,
}
def _variant_is_valid(self, config: Dict[str, Any]) -> bool:
try:
variant = TaskVariant.from_config(config)
except Exception:
return False
if not variant.task_id:
return False
if int(variant.max_steps) <= 0:
return False
try:
import gymnasium as gym
import browsergym.miniwob # noqa: F401
except Exception:
return False
if variant.task_id not in {str(key) for key in gym.registry.keys()}:
return False
if "miniwob" in variant.task_id and not os.getenv("MINIWOB_URL"):
return False
try:
headless = os.getenv("BROWSER_ENV_HEADLESS", "1") != "0"
env = gym.make(variant.task_id, headless=headless)
try:
env.reset(seed=variant.seed)
finally:
env.close()
except Exception:
return False
return True
def _ensure_mapping(value: Any) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _dump_model(model: Any) -> Dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump()
if hasattr(model, "dict"):
return model.dict()
return dict(model)
def _dump_action(action: BrowserAction) -> Dict[str, Any]:
return _dump_model(action)