HARvestGym / server /models.py
kdcyberdude's picture
Upload folder using huggingface_hub
9eebce3 verified
Raw
History Blame
29.1 kB
"""
HARvestGym Environment — core OpenEnv models.py
Implements the OpenEnv spec:
- Observation, Action, Reward as Pydantic models
- reset() → initial observation + clean state
- step(action) → (observation, reward, done, info)
- state() → current state snapshot
The environment manages episode state, dispatches tool calls, computes per-step
rewards, and invokes the judge at episode end.
"""
from __future__ import annotations
import json
import os
import random
from pathlib import Path
from typing import Any
from uuid import uuid4
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import State
from pydantic import Field
from openenv.core.env_server.types import Action as BaseAction, Observation as BaseObservation
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class HarvestGymObservation(BaseObservation):
"""What the RL agent sees at each step."""
task: str = Field(default="", description="Natural language task description")
app_base_url: str = Field(default="", description="Root URL of the target application")
last_tool_result: Any = Field(default=None, description="Result of last tool call")
history: list[dict] = Field(default_factory=list, description="Full episode trajectory")
session_state: dict = Field(default_factory=dict, description="Auto-managed cookies/tokens")
step_count: int = Field(default=0)
max_steps: int = Field(default=20)
available_tools: list[str] = Field(
default_factory=lambda: [
"browser_agent(task, url) — discover API endpoints from HAR + catalog; call once at step 1",
"search_endpoints(query) — find endpoint schema by natural language query",
"curl_exec(command) — execute HTTP request; returns {status_code, body}",
"search_episode_data(query) — search prior responses for a specific value/ID",
"done(result?) — signal task complete; triggers final scoring",
]
)
class HarvestGymAction(BaseAction):
"""One tool call from the RL agent."""
tool: str = Field(..., description="Tool name: browser_agent|search_endpoints|curl_exec|search_episode_data|done")
args: dict = Field(default_factory=dict, description="Tool-specific arguments")
class HarvestGymReward(BaseObservation):
"""Reward signal (returned as part of the observation)."""
value: float = Field(default=0.0, description="Scalar reward for this step")
breakdown: dict = Field(default_factory=dict, description="Per-signal reward components")
# ---------------------------------------------------------------------------
# Per-step reward constants
# ---------------------------------------------------------------------------
REWARD_VALID_API_CALL = 0.2 # curl_exec returns 2xx
REWARD_NEW_PATH = 0.1 # curl path not seen before this episode
REWARD_CORRECT_PARAM = 0.25 # judge: correct parameter sourcing (applied at end)
REWARD_SESSION_VALUE = 0.1 # auth token/cookie correctly used
PENALTY_REPEATED_CALL = -0.15 # exact duplicate curl command
PENALTY_REPEATED_DIFF_PARAM_CALL = -0.05 # duplicate curl but with different parameters
PENALTY_REPEATED_PATH = -0.15 # same (method, normalised path) called more than once
PENALTY_BROWSER_AGENT_AGAIN = -0.3 # browser_agent called after step 1
PENALTY_MALFORMED_CURL = -0.1 # curl can't be parsed/executed
PENALTY_4XX = -0.05 # recoverable HTTP error
MAX_STEPS = 20
# ---------------------------------------------------------------------------
# Task templates
# ---------------------------------------------------------------------------
TEMPLATE_META = {
1: {"tier": "easy", "app": "shopping", "base_url_port": 7770},
2: {"tier": "easy", "app": "wikipedia", "base_url_port": 8888},
3: {"tier": "medium", "app": "shopping", "base_url_port": 7770},
4: {"tier": "medium", "app": "forum", "base_url_port": 9999},
5: {"tier": "hard", "app": "forum", "base_url_port": 9999},
6: {"tier": "hard", "app": "shopping", "base_url_port": 7770},
7: {"tier": "hard", "app": "shopping_admin", "base_url_port": 7780},
}
EC2_HOST = os.environ.get("EC2_HOST", "ec2-16-59-2-56.us-east-2.compute.amazonaws.com")
TASK_NAME_TO_TEMPLATE = {
"har_classify_easy": 1,
"har_classify_medium": 3,
"har_pipeline_hard": 6,
}
TEMPLATE_DESCRIPTIONS: dict[int, list[str]] = {
1: [
"List products in category {category_name}",
"Show all products under the {category_name} category",
"Fetch the product listing for the '{category_name}' category",
"What products are available in the {category_name} category?",
],
2: [
"Retrieve the Wikipedia article for '{title}'",
"Fetch the Wikipedia page about '{title}'",
"Get the Wikipedia entry for '{title}'",
"Look up '{title}' on Wikipedia and return the article",
],
3: [
"Find '{product_name}' in the store and add it to the shopping cart",
"Add '{product_name}' to the cart",
"Shop for '{product_name}' and put it in the cart",
"I want to buy '{product_name}' — add it to my cart",
],
4: [
"Retrieve all posts in the '{forum_category}' forum (you must log in first)",
"Fetch the post list for the '{forum_category}' forum category",
"Get all threads in the '{forum_category}' forum section",
"List the forum posts under '{forum_category}' (authentication required)",
],
5: [
"Create a post titled '{title}' in the '{category}' forum. Note: authentication is required.",
"Post a new thread called '{title}' in the '{category}' forum",
"Submit a forum post with the title '{title}' to the '{category}' section",
"Publish '{title}' as a new post in the '{category}' forum",
],
6: [
"Complete a full guest checkout for '{product_name}'. The checkout involves multiple dependent steps — each step produces a value needed by the next. The task is complete when a confirmed order is placed.",
"Place a guest order for '{product_name}'. The process spans several API calls that build on each other; you are done when an order confirmation is received.",
"Buy '{product_name}' as a guest user and complete the checkout. Each stage of the checkout requires information returned by the previous stage.",
"Finish a guest checkout for '{product_name}'. Work through each step in sequence — the output of every step feeds into the next — until the order is confirmed.",
],
7: [
"Create a new product in the admin panel with SKU '{sku}' and price {price}. Admin access is required.",
"Add a product to the catalog via the admin interface: SKU '{sku}', price {price}",
"As an admin, create a new product listing with SKU '{sku}' priced at {price}",
"Use admin credentials to create a product with SKU '{sku}' and a price of {price}",
],
}
def _load_parameter_pools() -> dict:
pools_path = Path(__file__).parent.parent / "parameter_pools.json"
if pools_path.exists():
with open(pools_path) as f:
return json.load(f)
return {}
def _sample_task(template_id: int, parameter_pools: dict) -> tuple[str, dict, str]:
"""
Sample a task instance from the parameter pool.
Returns: (task_description, params_dict, app_base_url)
"""
meta = TEMPLATE_META[template_id]
pool_key = f"template_{template_id}"
pool_data = parameter_pools.get(pool_key, {})
pool = pool_data.get("pool", {})
params: dict = {}
if template_id == 1:
items = pool.get("category_name", [{"name": "Gear", "category_id": 3}])
chosen = random.choice(items)
params = {"category_name": chosen["name"], "category_id": chosen.get("category_id")}
description = random.choice(TEMPLATE_DESCRIPTIONS[1]).format(**params)
elif template_id == 2:
items = pool.get("title", [{"title": "Python (programming language)", "expected_slug": "Python_(programming_language)"}])
if not items:
items = [{"title": "Python (programming language)", "expected_slug": "Python_(programming_language)"}]
chosen = random.choice(items)
title = chosen.get("title", chosen) if isinstance(chosen, dict) else chosen
params = {"title": title, "expected_slug": chosen.get("expected_slug", title.replace(" ", "_"))}
description = random.choice(TEMPLATE_DESCRIPTIONS[2]).format(**params)
elif template_id == 3:
items = pool.get("product_name", [{"name": "Radiant Tee", "sku": "MH01"}])
if not items:
items = [{"name": "Radiant Tee", "sku": "MH01"}]
chosen = random.choice(items)
product_name = chosen.get("name", chosen) if isinstance(chosen, dict) else chosen
sku = chosen.get("sku", "") if isinstance(chosen, dict) else ""
product_id = chosen.get("product_id") if isinstance(chosen, dict) else None
params = {"product_name": product_name, "sku": sku}
if product_id:
params["product_id"] = product_id
description = random.choice(TEMPLATE_DESCRIPTIONS[3]).format(**params)
elif template_id == 4:
items = pool.get("forum_category", [{"slug": "general", "name": "General"}])
if not items:
items = [{"slug": "general", "name": "General"}]
chosen = random.choice(items)
forum_cat = chosen.get("slug", chosen.get("name", "general")) if isinstance(chosen, dict) else chosen
params = {"forum_category": forum_cat}
description = random.choice(TEMPLATE_DESCRIPTIONS[4]).format(**params)
elif template_id == 5:
categories = pool.get("forum_category", [{"slug": "general"}])
titles = pool.get("post_title", ["Testing the API agent framework"])
if not categories:
categories = [{"slug": "general"}]
if not titles:
titles = ["Testing the API agent framework"]
chosen_cat = random.choice(categories)
chosen_title = random.choice(titles) if isinstance(titles[0], str) else random.choice(titles).get("title", "Test post")
forum_cat = chosen_cat.get("slug", "general") if isinstance(chosen_cat, dict) else chosen_cat
params = {"title": chosen_title, "category": forum_cat}
description = random.choice(TEMPLATE_DESCRIPTIONS[5]).format(**params)
elif template_id == 6:
items = pool.get("product_name", [{"name": "Radiant Tee", "sku": "MH01"}])
if not items:
items = [{"name": "Radiant Tee", "sku": "MH01"}]
chosen = random.choice(items)
product_name = chosen.get("name", chosen) if isinstance(chosen, dict) else chosen
sku = chosen.get("sku", "") if isinstance(chosen, dict) else ""
product_id = chosen.get("product_id") if isinstance(chosen, dict) else None
params = {"product_name": product_name, "sku": sku}
if product_id:
params["product_id"] = product_id
description = random.choice(TEMPLATE_DESCRIPTIONS[6]).format(**params)
elif template_id == 7:
items = pool.get("admin_sku", [{"sku": "HAR-TEST-001", "price": "29.99"}])
if not items:
items = [{"sku": "HAR-TEST-001", "price": "29.99"}]
chosen = random.choice(items)
sku = chosen.get("sku", "HAR-TEST-001") if isinstance(chosen, dict) else chosen
price = str(chosen.get("price", "29.99")) if isinstance(chosen, dict) else "29.99"
params = {"sku": sku, "price": price}
description = random.choice(TEMPLATE_DESCRIPTIONS[7]).format(**params)
else:
params = {}
description = f"Template {template_id}"
port = meta["base_url_port"]
base_url = f"http://{EC2_HOST}:{port}/"
return description, params, base_url
def _load_fixed_task_from_env() -> dict | None:
"""Load an exact task specification when the caller wants deterministic reset()."""
raw = os.environ.get("HARVGYM_TASK_SPEC_JSON", "").strip()
if not raw:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
print("[HARvestGym] Ignoring invalid HARVGYM_TASK_SPEC_JSON", flush=True)
return None
return parsed if isinstance(parsed, dict) else None
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
class HARvestGymEnvironment(Environment):
"""
HARvestGym: RL environment for training API-native web agents.
The agent must discover and execute the correct sequence of HTTP API calls
to complete real-world tasks on live web applications — starting from only
a task description and a URL, with no prior knowledge of the API schema.
"""
SUPPORTS_CONCURRENT_SESSIONS: bool = True
def __init__(self):
self._state = State(episode_id=str(uuid4()), step_count=0)
self._parameter_pools = _load_parameter_pools()
self._current_task = None # Task dataclass
self._episode = None # Episode dataclass
self._session_state: dict = {}
self._episode_store: dict = {} # embeddings, BM25 corpus, etc.
self._called_paths: set = set() # for new-path reward
self._last_curl_commands: list = [] # for duplicate detection
self._called_methods_paths: list[tuple[str, str]] = [] # for same-path penalty
self._step_rewards: list[float] = []
self._done = False
# Determine default template from env var
self._task_name = os.environ.get("HARVGYM_TASK", "har_classify_easy")
# -----------------------------------------------------------------------
# Metadata — exposed via GET /metadata and used by RL training loops
# to build the system prompt and tool definitions automatically.
# -----------------------------------------------------------------------
def get_metadata(self): # → EnvironmentMetadata
from openenv.core.env_server.types import EnvironmentMetadata
return EnvironmentMetadata(
name="HARvestGym",
version="1.0.0",
author="kdcyberdude",
description=(
"HARvestGym is a real-world RL environment for training API-native web agents. "
"The agent receives a natural language task (e.g. 'Add Radiant Tee to cart') "
"and must discover and execute the correct sequence of HTTP API calls on a live "
"Magento e-commerce application — starting from only a URL and a task description. "
"\n\n"
"TOOLS (5 available):\n"
"1. browser_agent(task, url)\n"
" Discovers all API endpoints from recorded HAR traffic + ground-truth catalog. "
" Returns structured endpoint index. Call EXACTLY ONCE at step 1.\n\n"
"2. search_endpoints(query)\n"
" Semantic/keyword search over the discovered endpoint catalog. "
" Returns method, path, parameter schema, and auth requirements. "
" Use before each curl_exec to find the correct endpoint and payload.\n\n"
"3. curl_exec(command)\n"
" Execute a real HTTP request. Returns {status_code, headers, body}. "
" Session cookies/tokens are auto-injected. "
" Response body contains IDs (cartId, item_id, orderId) for subsequent steps.\n\n"
"4. search_episode_data(query)\n"
" Search all prior API responses from this episode for a specific value. "
" Use when earlier responses were truncated and you need to retrieve an ID or field.\n\n"
"5. done(result?)\n"
" Signal task completion. Triggers the deterministic judge and final scoring. "
" Call ONLY after all required API calls are verified successful.\n\n"
"REWARD SIGNALS:\n"
"+0.20 valid API call (2xx response)\n"
"+0.10 new unique API path explored\n"
"+0.25 correct parameter sourcing (judge)\n"
"+0.10 auth token/cookie correctly propagated\n"
"-0.05 4xx HTTP error\n"
"-0.10 malformed curl command\n"
"-0.15 exact duplicate curl call\n"
"-0.30 browser_agent called after step 1\n"
"-1.50 episode timeout (max 20 steps)\n"
"\n"
"TASK TIERS: easy (list products), medium (add to cart), hard (full checkout pipeline)"
),
documentation_url="https://huggingface.co/spaces/kdcyberdude/HARvestGym",
)
def _get_template_id(self) -> int:
"""Resolve task name or template ID from env var."""
task_name = self._task_name
if task_name in TASK_NAME_TO_TEMPLATE:
return TASK_NAME_TO_TEMPLATE[task_name]
if task_name.startswith("easy_"):
return 1
if task_name.startswith("medium_"):
return 3
if task_name.startswith("hard_"):
return 6
# Try integer
try:
tid = int(task_name)
if 1 <= tid <= 7:
return tid
except (ValueError, TypeError):
pass
return 1 # default: easy
def reset(self) -> HarvestGymObservation:
"""Reset environment: clear episode state, sample new task."""
from .episode import Episode, Task
fixed_task = _load_fixed_task_from_env()
if fixed_task:
template_id = int(fixed_task.get("template_id", self._get_template_id()))
meta = TEMPLATE_META.get(template_id, TEMPLATE_META[self._get_template_id()])
params = dict(fixed_task.get("params") or {})
description = fixed_task.get("description") or TEMPLATE_DESCRIPTIONS[template_id].format(**params)
base_url = fixed_task.get("base_url") or f"http://{EC2_HOST}:{meta['base_url_port']}/"
difficulty = fixed_task.get("difficulty") or meta["tier"]
app = fixed_task.get("app") or meta["app"]
else:
template_id = self._get_template_id()
description, params, base_url = _sample_task(template_id, self._parameter_pools)
meta = TEMPLATE_META[template_id]
difficulty = meta["tier"]
app = meta["app"]
self._current_task = Task(
template_id=template_id,
description=description,
params=params,
app=app,
base_url=base_url,
difficulty=difficulty,
)
self._episode = Episode(task=self._current_task)
self._session_state = {}
self._episode_store = {}
self._called_paths = set()
self._last_curl_commands = []
self._called_methods_paths = []
self._step_rewards = []
self._done = False
self._state = State(episode_id=str(uuid4()), step_count=0)
return HarvestGymObservation(
task=description,
app_base_url=base_url,
last_tool_result=None,
history=[],
session_state={},
step_count=0,
max_steps=MAX_STEPS,
done=False,
reward=0.0,
metadata={
"template_id": template_id,
"difficulty": difficulty,
"app": app,
},
)
def step(self, action: HarvestGymAction) -> HarvestGymObservation: # type: ignore[override]
"""Execute one tool call and return the next observation."""
from .episode import Step, CurlCall
if self._done:
# Episode already finished
return self._make_obs(
last_tool_result={"error": "Episode already done. Call reset()."},
reward=0.0,
done=True,
)
self._state.step_count += 1
step_num = self._state.step_count
tool = action.tool.lower().strip()
args = action.args or {}
# Dispatch tool
result, step_reward, done = self._dispatch_tool(tool, args, step_num)
# Record step in episode
step_obj = Step(
step_num=step_num,
tool=tool,
action=f"{tool}({json.dumps(args)})",
result=result,
)
# If curl_exec, parse the curl call for judge
if tool == "curl_exec":
command = args.get("command", "")
try:
from .tools.curl_exec import parse_curl_command
parsed = parse_curl_command(command)
from urllib.parse import urlparse
path = urlparse(parsed["url"]).path if parsed["url"] else ""
from .tools.browser_agent import _normalise_path
norm_path = _normalise_path(path)
resp = result if isinstance(result, dict) else {}
step_obj.curl_parsed = CurlCall(
method=parsed["method"],
url=parsed["url"] or "",
path=norm_path,
headers=parsed["headers"],
body=parsed["body"],
status_code=resp.get("status_code", 0),
# Use _judge_body (full structured body) for judge grading;
# falls back to body (truncated) if not present
response_body=resp.get("_judge_body", resp.get("body")),
response_headers=resp.get("headers", {}),
)
except Exception:
pass
if self._episode:
self._episode.steps.append(step_obj)
self._episode.total_steps = step_num
self._step_rewards.append(step_reward)
# Check max steps
if step_num >= MAX_STEPS and not done:
done = True
if self._episode:
self._episode.terminated_by = "max_steps"
# Invoke judge
judge_reward = self._invoke_judge()
step_reward += judge_reward
if done and self._episode and not self._episode.terminated_by:
self._episode.terminated_by = "done_call"
self._done = done
# Build history entry
history_entry = {
"step": step_num,
"tool": tool,
"args": args,
"result": result,
"reward": step_reward,
}
if self._episode:
history_for_obs = [
{"step": s.step_num, "tool": s.tool, "result": s.result}
for s in self._episode.steps
]
else:
history_for_obs = [history_entry]
return HarvestGymObservation(
task=self._current_task.description if self._current_task else "",
app_base_url=self._current_task.base_url if self._current_task else "",
last_tool_result=result,
history=history_for_obs,
session_state=dict(self._session_state),
step_count=step_num,
max_steps=MAX_STEPS,
done=done,
reward=step_reward,
metadata={
"step": step_num,
"tool": tool,
"step_reward": step_reward,
},
)
def _dispatch_tool(self, tool: str, args: dict, step_num: int) -> tuple[Any, float, bool]:
"""
Dispatch to the correct tool. Returns (result, step_reward, done).
"""
reward = 0.0
done = False
if tool == "browser_agent":
task = args.get("task", self._current_task.description if self._current_task else "")
url = args.get("url", self._current_task.base_url if self._current_task else "")
# Penalty if called after step 1
if step_num > 1:
reward += PENALTY_BROWSER_AGENT_AGAIN
from .tools.browser_agent import run_browser_agent
result = run_browser_agent(task, url, episode_store=self._episode_store)
elif tool == "search_endpoints":
query = args.get("query", "")
from .tools.search_endpoints import search_endpoints
result = search_endpoints(query, self._episode_store)
elif tool == "curl_exec":
command = args.get("command", "")
if not command:
return {"error": "curl_exec requires 'command' argument"}, PENALTY_MALFORMED_CURL, False
# Duplicate detection
if command in self._last_curl_commands:
reward += PENALTY_REPEATED_CALL
self._last_curl_commands.append(command)
from .tools.curl_exec import curl_exec
result = curl_exec(
command=command,
session_state=self._session_state,
episode_store=self._episode_store,
app_base_url=self._current_task.base_url if self._current_task else "",
)
status = result.get("status_code", 0)
if status == -1 or "error" in result:
reward += PENALTY_MALFORMED_CURL
elif 200 <= status < 300:
reward += REWARD_VALID_API_CALL
# New path bonus + same-path penalty
from urllib.parse import urlparse
from .tools.browser_agent import _normalise_path
try:
import shlex as _shlex
# Extract HTTP method (-X flag or infer from data flags)
_tokens = _shlex.split(command)
_method = "GET"
for _i, _tok in enumerate(_tokens):
if _tok in ("-X", "--request") and _i + 1 < len(_tokens):
_method = _tokens[_i + 1].upper()
break
if _method == "GET" and any(t in command for t in ("-d ", "--data", "-F ")):
_method = "POST"
_norm_path = None
for _t in _tokens:
if _t.startswith("http"):
_norm_path = _normalise_path(urlparse(_t.strip("'\"")).path)
break
if _norm_path:
_mp = (_method, _norm_path)
if _mp in self._called_methods_paths:
reward += PENALTY_REPEATED_PATH
self._called_methods_paths.append(_mp)
if _norm_path not in self._called_paths:
self._called_paths.add(_norm_path)
reward += REWARD_NEW_PATH
except Exception:
pass
elif 400 <= status < 500:
reward += PENALTY_4XX
elif tool == "search_episode_data":
query = args.get("query", "")
from .tools.search_episode_data import search_episode_data
result = search_episode_data(query, self._episode_store)
elif tool == "done":
result_str = args.get("result", "")
result = {"status": "done", "result": result_str}
done = True
# Invoke judge for final reward
judge_reward = self._invoke_judge()
reward += judge_reward
else:
result = {"error": f"Unknown tool: {tool}. Available: browser_agent, search_endpoints, curl_exec, search_episode_data, done"}
reward += PENALTY_MALFORMED_CURL
return result, reward, done
def _invoke_judge(self) -> float:
"""Run the judge on the completed episode and return terminal reward."""
if self._episode is None or self._current_task is None:
return -1.5
try:
from .judge import evaluate
episode_result = evaluate(self._episode)
return episode_result.reward
except Exception as e:
print(f"[HARvestGym] Judge error: {e}", flush=True)
return -1.5
def _make_obs(self, last_tool_result: Any, reward: float, done: bool) -> HarvestGymObservation:
return HarvestGymObservation(
task=self._current_task.description if self._current_task else "",
app_base_url=self._current_task.base_url if self._current_task else "",
last_tool_result=last_tool_result,
history=[],
session_state=dict(self._session_state),
step_count=self._state.step_count,
max_steps=MAX_STEPS,
done=done,
reward=reward,
)
@property
def state(self) -> State:
return self._state