Files
OpenJarvis/src/openjarvis/evals/datasets/webchorearena.py
T
Jon Saad-FalconandClaude Opus 4.8 3e2f4bcdb4 feat(core): consolidate all state under a single env-aware home directory (#462) (#549)
Previously `core/config.py` defined `DEFAULT_CONFIG_DIR = Path.home() /
".openjarvis"` as a by-value module constant imported into ~45 modules, and 34
modules hardcoded `Path.home() / ".openjarvis"` directly. The installer honored
`OPENJARVIS_HOME` but the Python runtime ignored it, producing a split-brain
layout (some modules honored the override, the core config dir did not). Eval
dataset caches also scattered into `~/.cache/<benchmark>`.

This introduces a single env-aware resolver in `openjarvis/core/paths.py` and
routes every state/config/cache path through it. OpenJarvis now keeps ALL of
its state under ONE root, resolved in priority order:

  1. $OPENJARVIS_HOME
  2. $XDG_DATA_HOME/openjarvis   (single nested dir, when XDG_DATA_HOME is set)
  3. ~/.openjarvis               (default — unchanged, so existing installs are
                                  untouched and no data migration is required)

Implementation:
- New `core/paths.py`: get_config_dir / get_config_path / get_data_dir /
  get_cache_dir, with a source-tree rejection guard (fails loudly per
  REVIEW.md if the root resolves inside the repo).
- `core/config.py`: DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_PATH are now resolved
  via the env-aware resolver at import (real attributes, so existing
  monkeypatch.setattr-based tests keep working). All dataclass field defaults
  that pointed at ~/.openjarvis converted to default_factory so they honor the
  override at instantiation.
- Routed all 34 hardcoders plus several string-literal escapees the original
  audit missed: prompt_loader / description_loader (were OPENJARVIS_HOME-only,
  no XDG), swebench_harness cache, tools/{memory,skill,user_profile}_manage
  defaults, server trace.db fallbacks, doctor_cmd hints.
- spec_search storage/paths now delegates to the unified resolver (gains XDG);
  its ConfigurationError is aliased to the core one.
- Eval dataset caches moved from ~/.cache/<name> to <root>/cache/<name>
  (~/.cache/huggingface left alone — it is HF's own cache).
- Docs + installer comment + `jarvis config path` to show resolved dirs.

Read-only macOS connectors and OS service files (LaunchAgents/systemd) are
intentionally left untouched.

Fixes #462

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:32:02 -07:00

268 lines
8.9 KiB
Python

"""WebChoreArena: Realistic tedious web browsing tasks.
Evaluates web agents on 532 tasks across Shopping, Shopping Admin,
Reddit, GitLab, and Cross-site environments. Tests massive memory,
calculation, and long-term memory capabilities.
Requires a running WebArena standalone environment (Shopping/Magento,
Reddit/Postmill, GitLab, Shopping Admin). Tasks are per-site JSON configs
cloned from the original GitHub repository.
Source: https://github.com/WebChoreArena/WebChoreArena
"""
from __future__ import annotations
import json
import logging
import os
import random
import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
logger = logging.getLogger(__name__)
_GITHUB_REPO = "https://github.com/WebChoreArena/WebChoreArena.git"
_SITE_DIRS = {
"shopping": "test_shopping.json",
"shopping_admin": "test_shopping_admin.json",
"reddit": "test_reddit.json",
"gitlab": "test_gitlab.json",
"cross": "test_cross.raw.json",
}
class WebChoreArenaDataset(DatasetProvider):
"""WebChoreArena benchmark — interactive browser-based web tasks.
Tasks are enumerated from the original GitHub repository's
``config_files/`` JSON files. Each task requires a live WebArena
standalone environment and Playwright for evaluation.
"""
dataset_id = "webchorearena"
dataset_name = "WebChoreArena"
def __init__(
self,
subset: str = "all",
cache_dir: Optional[str] = None,
headless: bool = True,
) -> None:
self._subset = subset # "all", "small", or a site name
self._cache_dir = (
Path(cache_dir) if cache_dir else get_cache_dir() / "webchorearena"
)
self._headless = headless
self._records: List[EvalRecord] = []
def load(
self,
*,
max_samples: Optional[int] = None,
split: Optional[str] = None,
seed: Optional[int] = None,
) -> None:
repo_dir = self._cache_dir / "repo"
config_dir = repo_dir / "AgentOccam" / "config_files"
if not config_dir.exists():
self._clone_repo(repo_dir)
tasks = self._load_tasks(config_dir)
if seed is not None:
random.Random(seed).shuffle(tasks)
if max_samples is not None:
tasks = tasks[:max_samples]
self._records = [self._task_to_record(t, i) for i, t in enumerate(tasks)]
logger.info(
"WebChoreArena[%s]: loaded %d tasks",
self._subset,
len(self._records),
)
def iter_records(self) -> Iterable[EvalRecord]:
return iter(self._records)
def size(self) -> int:
return len(self._records)
def verify_requirements(self) -> List[str]:
import urllib.request
issues: List[str] = []
try:
import playwright # noqa: F401
except ImportError:
issues.append(
"playwright required for WebChoreArena. "
"Install with: pip install playwright && playwright install"
)
# Core services — eval fails without these
_REQUIRED = {
"SHOPPING": "http://localhost:7770",
"SHOPPING_ADMIN": "http://localhost:7780",
"REDDIT": "http://localhost:9999",
"GITLAB": "http://localhost:8023",
"WIKIPEDIA": "http://localhost:8888",
}
# Optional — only needed for map tasks; requires a full OpenStreetMap
# Docker Compose setup (AWS AMI or manual)
_OPTIONAL = {
"MAP": "http://localhost:3000",
}
unreachable = []
for env_var, default_url in _REQUIRED.items():
url = os.environ.get(env_var, default_url)
try:
urllib.request.urlopen(url, timeout=5)
except Exception:
unreachable.append(f" {env_var}={url}")
if unreachable:
issues.append(
"The following WebArena backend services are not reachable.\n"
"Run scripts/setup_webchorearena.sh to start them:\n"
+ "\n".join(unreachable)
)
for env_var, default_url in _OPTIONAL.items():
url = os.environ.get(env_var, default_url)
try:
urllib.request.urlopen(url, timeout=5)
except Exception:
logger.warning(
"Optional WebArena service %s (%s) is not reachable — "
"map-related tasks will fail. "
"See scripts/setup_webchorearena.sh for setup instructions.",
env_var,
url,
)
return issues
def create_task_env(self, record: EvalRecord):
"""Return a WebChoreArenaTaskEnv for the given record."""
try:
from openjarvis.evals.execution.webchorearena_env import (
WebChoreArenaTaskEnv,
)
return WebChoreArenaTaskEnv(record.metadata, headless=self._headless)
except ImportError:
return None
# ------------------------------------------------------------------
def _clone_repo(self, repo_dir: Path) -> None:
"""Clone the WebChoreArena repository from GitHub."""
repo_dir.parent.mkdir(parents=True, exist_ok=True)
logger.info("Cloning WebChoreArena from %s ...", _GITHUB_REPO)
subprocess.run(
["git", "clone", "--depth", "1", _GITHUB_REPO, str(repo_dir)],
check=True,
capture_output=True,
)
logger.info("WebChoreArena cloned to %s", repo_dir)
def _load_tasks(self, config_dir: Path) -> List[Dict[str, Any]]:
"""Load task definitions from the original config_files/ directory."""
tasks: List[Dict[str, Any]] = []
if self._subset == "small":
small_ids = self._load_small_set_ids(config_dir)
for site_key, filename in _SITE_DIRS.items():
if self._subset not in ("all", "small") and self._subset != site_key:
continue
filepath = config_dir / filename
if not filepath.exists():
logger.warning("Config file not found: %s", filepath)
continue
try:
with open(filepath) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Failed to load %s: %s", filepath, exc)
continue
items = data if isinstance(data, list) else [data]
for item in items:
if not isinstance(item, dict):
continue
if not item.get("intent") and not item.get("intent_template"):
continue
if self._subset == "small":
task_id = str(item.get("task_id", ""))
if task_id not in small_ids:
continue
tasks.append(item)
return tasks
def _load_small_set_ids(self, config_dir: Path) -> set:
"""Load small subset IDs from small_set_ids.txt."""
ids: set = set()
small_file = config_dir / "small_set_ids.txt"
if small_file.exists():
with open(small_file) as f:
for line in f:
line = line.strip()
if line:
ids.add(line)
return ids
def _task_to_record(
self,
task: Dict[str, Any],
idx: int,
) -> EvalRecord:
"""Convert an original WebChoreArena task config into an EvalRecord."""
task_id = str(task.get("task_id", idx))
intent = str(task.get("intent", task.get("intent_template", "")))
sites = task.get("sites", [])
site = sites[0].lower().replace(" ", "_") if sites else "unknown"
return EvalRecord(
record_id=f"webchorearena-{task_id}",
problem=intent,
reference="",
category="agentic",
subject=site,
metadata={
"task_id": task_id,
"task_config": task,
"site": site,
"sites": sites,
"start_url": task.get("start_url", ""),
"start_url_lite": task.get("start_url_lite", ""),
"storage_state": task.get(
"storage_state", task.get("strage_state", "")
),
"required_obs": task.get("required_obs", "text"),
"type_main": task.get("type_main", ""),
"type_sub": task.get("type_sub", ""),
"affect_environment": task.get("affect_environment", False),
"eval": task.get("eval", {}),
"headless": self._headless,
},
)
__all__ = ["WebChoreArenaDataset"]