HARvestGym / inference.py
kdcyberdude's picture
Upload folder using huggingface_hub
b7eaaaa verified
Raw
History Blame
15.8 kB
"""
HARvestGym — Inference Script
==============================
Runs the RL agent (driven by an LLM via OpenAI client) through three tasks:
1. har_classify_easy — Template 1: list products in a category
2. har_classify_medium — Template 3: add product to guest cart
3. har_pipeline_hard — Template 6: complete guest checkout
STDOUT FORMAT (strictly enforced by hackathon):
[START] task=<task_name> env=<benchmark> model=<model_name>
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
Usage:
HF_TOKEN=hf_xxx uv run inference.py
HF_TOKEN=hf_xxx MODEL_NAME=Qwen/Qwen2.5-72B-Instruct uv run inference.py
"""
import asyncio
import json
import os
import sys
import textwrap
from typing import Any, List, Optional
from openai import OpenAI
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
HF_TOKEN = os.getenv("HF_TOKEN")
if not HF_TOKEN:
raise ValueError(
"HF_TOKEN environment variable is required but not set. "
"Export it with: export HF_TOKEN=hf_your_token_here"
)
BENCHMARK = "harvgym"
MAX_STEPS = 20
TEMPERATURE = 0.2 # Lower temp → more deterministic tool calls
MAX_TOKENS = 1024 # More room for reasoning + JSON
SUCCESS_SCORE_THRESHOLD = 0.5
# Task definitions: use FIXED task descriptions so the model always knows
# exactly what to do (env.reset() may randomize, but we tell it the target)
TASKS = [
{
"task_name": "har_classify_easy",
"template_id": 1,
"description": "List products in the 'Gear' category",
"app_base_url": "http://ec2-16-59-2-56.us-east-2.compute.amazonaws.com:7770/",
"difficulty": "easy",
},
{
"task_name": "har_classify_medium",
"template_id": 3,
"description": "Add 'Radiant Tee' (SKU: MH01-XS-Black) to a guest cart",
"app_base_url": "http://ec2-16-59-2-56.us-east-2.compute.amazonaws.com:7770/",
"difficulty": "medium",
},
{
"task_name": "har_pipeline_hard",
"template_id": 6,
"description": "Complete a full guest checkout for 'Radiant Tee' (SKU: MH01-XS-Black)",
"app_base_url": "http://ec2-16-59-2-56.us-east-2.compute.amazonaws.com:7770/",
"difficulty": "hard",
},
]
# ---------------------------------------------------------------------------
# Logging helpers (hackathon format)
# ---------------------------------------------------------------------------
def log_start(task: str, env: str, model: str) -> None:
print(f"[START] task={task} env={env} model={model}", flush=True)
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
error_val = error if error else "null"
done_val = str(done).lower()
action_clean = action.replace("\n", " ").replace("\r", "")[:200]
print(
f"[STEP] step={step} action={action_clean} reward={reward:.2f} done={done_val} error={error_val}",
flush=True,
)
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
print(
f"[END] success={str(success).lower()} steps={steps} score={score:.2f} rewards={rewards_str}",
flush=True,
)
# ---------------------------------------------------------------------------
# System prompt
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = textwrap.dedent("""
You are an API agent. Your goal is to complete a real-world task by calling the correct
sequence of HTTP API endpoints on a live Magento shopping application.
You have exactly these tools available. Output ONE tool call per turn as a JSON object:
1. browser_agent(task, url)
→ Discovers API endpoints for this app from recorded traffic + catalog.
→ Call this FIRST and ONLY ONCE (at step 1).
→ Returns: {"app": "shopping", "total_endpoints": N, "endpoints": [...]}
2. search_endpoints(query)
→ Semantic/keyword search over all discovered endpoint schemas.
→ Returns full endpoint details: method, path, required parameters, auth.
→ Use this to find the exact path and params before making curl calls.
→ Example: search_endpoints("guest cart create") → POST /rest/V1/guest-carts
3. curl_exec(command)
→ Execute an HTTP request. Returns {"status_code": N, "body": {...}}.
→ Session cookies are auto-injected. Do NOT manually set Cookie headers.
→ Always use: curl -s -X METHOD 'URL' -H 'Content-Type: application/json' -d '{...}'
4. search_episode_data(query)
→ Search all prior API responses in this episode for a value.
→ Use when a prior response was long and you need to find a specific item/ID.
5. done(result?)
→ Call ONLY when the task is fully complete. Do not call early.
RULES:
- Output ONLY a JSON object: {"tool": "...", "args": {...}}. No explanation, no markdown.
- Step 1: ALWAYS call browser_agent to discover endpoints.
- Step 2+: Use search_endpoints to find the right endpoint before calling curl_exec.
- Read IDs and values (cart_id, sku, item_id) from LAST TOOL RESULT in the context.
- Magento REST API base: http://host/rest/V1/
- To add an item to guest cart: POST /rest/V1/guest-carts/{cartId}/items
Body: {"cartItem": {"sku": "SKU", "qty": 1, "quote_id": "{cartId}"}}
- Do NOT call done() until the task is actually accomplished.
EXAMPLE SEQUENCE for "Add product to guest cart":
Step 1: {"tool": "browser_agent", "args": {"task": "Add product to guest cart", "url": "http://..."}}
Step 2: {"tool": "search_endpoints", "args": {"query": "create guest cart"}}
Step 3: {"tool": "curl_exec", "args": {"command": "curl -s -X POST 'http://.../rest/V1/guest-carts' -H 'Content-Type: application/json'"}}
Step 4: {"tool": "search_endpoints", "args": {"query": "add item to guest cart"}}
Step 5: {"tool": "curl_exec", "args": {"command": "curl -s -X POST 'http://.../rest/V1/guest-carts/CART_ID/items' -H 'Content-Type: application/json' -d '{\"cartItem\":{\"sku\":\"MH01-XS-Black\",\"qty\":1,\"quote_id\":\"CART_ID\"}}'"}}
Step 6: {"tool": "done", "args": {}}
""").strip()
# ---------------------------------------------------------------------------
# LLM agent loop
# ---------------------------------------------------------------------------
def _format_result_for_context(result: Any, max_chars: int = 3000) -> str:
"""Format tool result for the LLM context — more generous truncation."""
if result is None:
return "null"
try:
text = json.dumps(result, indent=2)
except Exception:
text = str(result)
if len(text) <= max_chars:
return text
# Smart truncation: keep beginning (has structure/IDs) and hint at truncation
kept = text[:max_chars]
# Try to close the JSON gracefully at the last complete line
last_newline = kept.rfind("\n")
if last_newline > max_chars * 0.8:
kept = kept[:last_newline]
return kept + f"\n... [truncated, {len(text) - max_chars} chars omitted — use search_episode_data to find specific values]"
def build_user_prompt(task_desc: str, app_base_url: str, step: int,
last_result: Any, history: List[dict],
session_state: dict) -> str:
"""Build the user prompt for each step."""
history_lines = []
if history:
# Show last 8 steps with meaningful result summaries
for h in history[-8:]:
result = h.get("result", {})
# For curl results: show status_code + first 200 chars of body
if isinstance(result, dict) and "status_code" in result:
body_preview = str(result.get("body", ""))[:300]
result_summary = f'status={result["status_code"]} body={body_preview}'
else:
result_summary = str(result)[:300]
history_lines.append(
f" Step {h['step']}: {h['tool']}({json.dumps(h.get('args', {}))[:100]}) "
f"→ {result_summary}"
)
session_str = json.dumps(session_state, indent=2)[:500] if session_state else "{}"
last_result_str = _format_result_for_context(last_result)
return textwrap.dedent(f"""
TASK: {task_desc}
APP URL: {app_base_url}
STEP: {step}/{MAX_STEPS}
SESSION STATE (cookies/tokens auto-managed):
{session_str}
LAST TOOL RESULT:
{last_result_str}
HISTORY (last {len(history_lines)} steps):
{chr(10).join(history_lines) if history_lines else " (none yet)"}
What is your next tool call? Output ONLY the JSON object.
""").strip()
def get_model_action(client: OpenAI, task_desc: str, app_base_url: str,
step: int, last_result: Any, history: List[dict],
session_state: dict) -> dict:
"""Ask the LLM for the next action. Returns parsed tool call dict."""
user_prompt = build_user_prompt(task_desc, app_base_url, step,
last_result, history, session_state)
try:
completion = client.chat.completions.create(
model=MODEL_NAME,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
stream=False,
)
text = (completion.choices[0].message.content or "").strip()
# Strip markdown code fences
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].split("```")[0].strip()
# Extract first {...} block
start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
text = text[start:end]
parsed = json.loads(text)
if "tool" in parsed:
return parsed
return {"tool": "done", "args": {"result": "Model returned non-tool response"}}
except json.JSONDecodeError:
# Fallback heuristics
if step == 1:
return {"tool": "browser_agent", "args": {"task": task_desc, "url": app_base_url}}
if "browser_agent" in text:
return {"tool": "browser_agent", "args": {"task": task_desc, "url": app_base_url}}
if "done" in text.lower():
return {"tool": "done", "args": {}}
return {"tool": "done", "args": {"result": f"Parse error: {text[:100]}"}}
except Exception as exc:
print(f"[DEBUG] LLM call failed: {exc}", flush=True)
if step == 1:
return {"tool": "browser_agent", "args": {"task": task_desc, "url": app_base_url}}
return {"tool": "done", "args": {"result": f"LLM error: {exc}"}}
# ---------------------------------------------------------------------------
# Single task episode runner
# ---------------------------------------------------------------------------
async def run_episode(task_config: dict, client: OpenAI) -> dict:
"""
Run a single episode for one task.
Returns: {"task_name", "success", "steps", "score", "rewards"}
"""
from server.models import HARvestGymEnvironment, HarvestGymAction
task_name = task_config["task_name"]
template_id = task_config["template_id"]
task_description = task_config["description"]
app_base_url = task_config["app_base_url"]
# Pin the template via env var so reset() samples from the right pool
os.environ["HARVGYM_TASK"] = task_name # use name, not int
env = HARvestGymEnvironment()
rewards: List[float] = []
steps_taken = 0
score = 0.0
success = False
last_result = None
history: List[dict] = []
session_state: dict = {}
log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
try:
obs = env.reset()
# CRITICAL: use the env-sampled task description — the judge grades exactly
# what env.reset() returned (random category/product), not our hardcoded string.
task_desc = obs.task or task_description
base_url = obs.app_base_url or app_base_url
for step in range(1, MAX_STEPS + 1):
if getattr(obs, "done", False):
break
action_dict = get_model_action(
client=client,
task_desc=task_desc,
app_base_url=base_url,
step=step,
last_result=last_result,
history=history,
session_state=session_state,
)
tool = action_dict.get("tool", "done")
args = action_dict.get("args", {})
action_str = f"{tool}({json.dumps(args)[:150]})"
error_str = None
try:
action = HarvestGymAction(tool=tool, args=args)
obs = env.step(action)
reward = float(obs.reward or 0.0)
done = bool(obs.done)
last_result = obs.last_tool_result
session_state = dict(obs.session_state or {})
history.append({
"step": step,
"tool": tool,
"args": args,
"result": last_result,
})
except Exception as exc:
reward = -0.1
done = False
error_str = str(exc)[:200]
rewards.append(reward)
steps_taken = step
log_step(step=step, action=action_str, reward=reward, done=done, error=error_str)
if done:
break
# Score: terminal reward from judge dominates.
# Reward range by design: terminal success = +2 to +5, terminal fail = -1.5
# Use a generous baseline so partial credit shows up.
total_reward = sum(rewards)
# Normalise to [0,1]: shift by +1.5 (min), divide by max-possible per task
# Template 1 max=2, Template 3 max=3.5, Template 6 max=5 → use 5.0 as ceiling
score = max(0.0, min(1.0, (total_reward + 1.5) / (5.0 + 1.5)))
success = total_reward >= 0.5 # any positive terminal reward = success
except Exception as exc:
error_str = str(exc)[:200]
print(f"[DEBUG] Episode error: {error_str}", flush=True)
finally:
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
return {
"task_name": task_name,
"success": success,
"steps": steps_taken,
"score": score,
"rewards": rewards,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def main() -> None:
client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
results = []
for task_config in TASKS:
result = await run_episode(task_config, client)
results.append(result)
# Summary
print("\n[SUMMARY]", flush=True)
for r in results:
status = "PASS" if r["success"] else "FAIL"
print(
f" [{status}] {r['task_name']} — score={r['score']:.2f} steps={r['steps']}",
flush=True,
)
overall_score = sum(r["score"] for r in results) / len(results) if results else 0.0
print(f"\n overall_score={overall_score:.2f}", flush=True)
if __name__ == "__main__":
asyncio.run(main())