HARvestGym / server /models.py
kdcyberdude's picture
Upload folder using huggingface_hub
e6ce96e verified
Raw
History Blame
20.4 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)",
"search_endpoints(query)",
"curl_exec(command)",
"search_episode_data(query)",
"done(result?)",
]
)
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_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 = {
1: "List products in category {category_name}",
2: "Retrieve the Wikipedia article for '{title}'",
3: "Add '{product_name}' to a guest cart",
4: "Retrieve all posts in the '{forum_category}' forum (you must log in first)",
5: "Create a forum post titled '{title}' in the '{category}' forum",
6: "Complete a guest checkout for '{product_name}'",
7: "Create a new product in the admin panel with SKU '{sku}' and price {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 = 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 = 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 ""
params = {"product_name": product_name, "sku": sku}
description = 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 = 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 = 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 ""
params = {"product_name": product_name, "sku": sku}
description = 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 = 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
# ---------------------------------------------------------------------------
# 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._step_rewards: list[float] = []
self._done = False
# Determine default template from env var
self._task_name = os.environ.get("HARVGYM_TASK", "har_classify_easy")
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]
# 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
template_id = self._get_template_id()
description, params, base_url = _sample_task(template_id, self._parameter_pools)
meta = TEMPLATE_META[template_id]
self._current_task = Task(
template_id=template_id,
description=description,
params=params,
app=meta["app"],
base_url=base_url,
difficulty=meta["tier"],
)
self._episode = Episode(task=self._current_task)
self._session_state = {}
self._episode_store = {}
self._called_paths = set()
self._last_curl_commands = []
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": meta["tier"],
"app": meta["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),
response_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
from urllib.parse import urlparse
from .tools.browser_agent import _normalise_path
try:
parsed_for_path = __import__("shlex").split(command)
for t in parsed_for_path:
if t.startswith("http"):
path = _normalise_path(urlparse(t.strip("'\"")).path)
if path and path not in self._called_paths:
self._called_paths.add(path)
reward += REWARD_NEW_PATH
break
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