debug-env / inference.py
hugsbun's picture
modified: inference.py
b8ca364
Raw
History Blame Contribute Delete
15.1 kB
"""
Debug-Env Benchmark Executor (Competition Version)
Evaluates LLM agents on code debugging tasks via the OpenEnv server.
Uses the OpenAI client pointed at Hugging Face's OpenAI-compatible router.
Usage:
python inference.py # task1, 1 run
TASK=task2 NUMBER_OF_RUNS=3 python inference.py
Required Environment Variables:
HF_TOKEN Hugging Face API key
API_BASE_URL LLM API endpoint (default: https://router.huggingface.co/v1)
MODEL_NAME Model identifier (default: Qwen/Qwen2.5-72B-Instruct)
Optional Environment Variables:
ENV_URL OpenEnv server URL (default: http://127.0.0.1:8000)
TASK task1–task3 (default: task1)
NUMBER_OF_RUNS runs for Pass@k (default: 1)
MAX_STEPS max steps per run (default: 10)
TEMPERATURE LLM temperature (default: 0.0)
MAX_TOKENS max tokens per call (default: 2048)
REQUEST_DELAY_MS ms between requests (default: 500)
Output:
- Structured [START]/[STEP]/[END] logs to stdout
- results_{task}_{timestamp}.json (written after every run, not just at end)
"""
import asyncio
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from math import comb
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
load_dotenv()
import httpx
from openai import OpenAI, APIError, RateLimitError
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# ── Configuration ───────────────────────────────────────────────────────────────
def _ensure_env_vars():
"""Ensure environment variables exist so os.environ[...] doesn't raise KeyError locally."""
if "API_BASE_URL" not in os.environ:
os.environ["API_BASE_URL"] = "https://router.huggingface.co/v1"
if "MODEL_NAME" not in os.environ:
os.environ["MODEL_NAME"] = "Qwen/Qwen2.5-72B-Instruct"
if "API_KEY" not in os.environ:
os.environ["API_KEY"] = os.environ.get("HF_TOKEN", "dummy_key_for_local_testing")
def get_config() -> Dict[str, Any]:
_ensure_env_vars()
return {
"env_url": os.getenv("ENV_URL", "http://localhost:7860"),
"api_base_url": os.environ["API_BASE_URL"],
"model_name": os.environ["MODEL_NAME"],
"api_key": os.environ["API_KEY"],
"task": os.getenv("TASK", "all"),
"number_of_runs": int(os.getenv("NUMBER_OF_RUNS", "1")),
"max_steps_per_run": int(os.getenv("MAX_STEPS", "10")),
"temperature": float(os.getenv("TEMPERATURE", "0.0")),
"max_tokens": int(os.getenv("MAX_TOKENS", "2048")),
"request_delay_ms": int(os.getenv("REQUEST_DELAY_MS", "500")),
}
def _validate_config(config: Dict[str, Any]) -> None:
if not config["api_key"]:
raise ValueError("API_KEY or HF_TOKEN is required.")
if not config["model_name"]:
raise ValueError("MODEL_NAME is required.")
if not config["api_base_url"]:
raise ValueError("API_BASE_URL is required.")
# ── LLM client (OpenAI SDK β†’ HuggingFace router) ────────────────────────────────
def _init_client() -> OpenAI:
"""OpenAI client pointed at the HuggingFace OpenAI-compatible endpoint."""
_ensure_env_vars()
return OpenAI(
base_url=os.environ["API_BASE_URL"],
api_key=os.environ["API_KEY"],
)
# ── OpenEnv HTTP helpers ─────────────────────────────────────────────────────────
async def env_reset(env_url: str, task: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{env_url}/reset", json={"task": task})
r.raise_for_status()
return r.json()
async def env_step(
env_url: str, tool: str, args: Dict[str, Any], delay_ms: int = 500
) -> Dict[str, Any]:
"""Execute one tool step with rate-limiting delay and retry on transient errors."""
action = {"tool": tool, "args": args}
await asyncio.sleep(delay_ms / 1000.0)
for attempt in range(3):
try:
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(f"{env_url}/step", json={"action": action})
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
wait = 2 ** attempt
logger.warning(f"Rate limited β€” retrying in {wait}s")
await asyncio.sleep(wait)
else:
raise
except Exception as e:
if attempt == 2:
raise
logger.warning(f"Request failed: {e} β€” retrying")
await asyncio.sleep(1)
raise RuntimeError(f"Failed to execute {tool} after 3 retries")
# ── Helpers ──────────────────────────────────────────────────────────────────────
def _log_step(step, tool_name, tool_args, reward, done, error=None):
"""Emit competition-format [STEP] line."""
if tool_args:
args_str = ", ".join(
f"'{v}'" if isinstance(v, str) and len(v) <= 40
else f"'{v[:40]}...'" if isinstance(v, str)
else str(v)
for v in tool_args.values()
)
action_str = f"{tool_name}({args_str})"
else:
action_str = f"{tool_name}()"
print(f"[STEP] step={step} action={action_str} reward={reward:.2f} done={str(done).lower()} error={error or 'null'}", flush=True)
def _parse_file_list(logs: str) -> List[str]:
"""Extract .py filenames from list_files output."""
return [
line.strip() for line in logs.splitlines()
if line.strip().endswith(".py") and not line.strip().startswith("#")
]
def _extract_code(raw: str) -> str:
"""
Extract Python code from an LLM response.
Handles:
- Raw code (no fences)
- ```python ... ``` fences
- ``` ... ``` fences
Returns the cleaned code string.
"""
raw = raw.strip()
# Try to extract from a code fence
fence_match = re.search(r'```(?:python)?\n(.*?)```', raw, re.DOTALL)
if fence_match:
return fence_match.group(1).strip()
# Strip leading/trailing fence markers if present without newline
if raw.startswith("```"):
lines = raw.split("\n")
# Remove first line (``` or ```python) and last ``` line
inner = lines[1:] if len(lines) > 1 else lines
if inner and inner[-1].strip() == "```":
inner = inner[:-1]
return "\n".join(inner).strip()
return raw
# ── Single run ───────────────────────────────────────────────────────────────────
async def execute_run(
run_number: int,
client: OpenAI,
config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Single-shot debugging workflow per run:
1. list_files β€” discover editable files (solution files hidden by server)
2. run_tests β€” see initial failures
3. read_file β€” read each source file
4. LLM call β€” ask for fixed code (one call per file, raw Python output)
5. edit_file β€” submit fix; server grades via line-by-line comparison
No retry loop β€” one shot per run.
"""
env_url = config["env_url"]
task = config["task"]
model = config["model_name"]
print(f"[START] task={task} env=debug-env model={model}", flush=True)
rewards: List[float] = []
tools_used: List[str] = []
steps_detail: List[Dict] = []
success = False
global_step = 0
start_ts = datetime.now(timezone.utc)
def _record(tool_name, tool_args, reward, done):
nonlocal global_step, success
global_step += 1
rewards.append(reward)
if tool_name not in tools_used:
tools_used.append(tool_name)
steps_detail.append({
"step": global_step, "tool": tool_name,
"args": tool_args, "reward": reward, "done": done,
})
_log_step(global_step, tool_name, tool_args, reward, done)
if done:
success = True
async def _call(tool_name, tool_args=None):
return await env_step(env_url, tool_name, tool_args or {}, delay_ms=config["request_delay_ms"])
# ── Reset ──────────────────────────────────────────────────────────────────
await env_reset(env_url, task)
# ── Step 1: list_files ─────────────────────────────────────────────────────
res = await _call("list_files")
reward, done = res.get("reward", 0.0), res.get("done", False)
file_list_logs = (res.get("observation") or {}).get("logs", "")
_record("list_files", {}, reward, done)
files = _parse_file_list(file_list_logs)
if not files:
logger.warning("list_files returned no .py files β€” falling back to broken_code.py")
files = ["broken_code.py"]
# ── Step 2: run_tests (initial) ────────────────────────────────────────────
if not success:
res = await _call("run_tests")
reward, done = res.get("reward", 0.0), res.get("done", False)
test_logs = (res.get("observation") or {}).get("logs", "")
_record("run_tests", {}, reward, done)
else:
test_logs = ""
# ── Step 3: read every source file ────────────────────────────────────────
file_contents: Dict[str, str] = {}
for f in files:
if success:
break
res = await _call("read_file", {"path": f})
reward, done = res.get("reward", 0.0), res.get("done", False)
content = (res.get("observation") or {}).get("logs", "")
_record("read_file", {"path": f}, reward, done)
file_contents[f] = content
# ── Step 4+5: LLM fix β†’ edit_file (one shot per file) ────────────────────
for fname in files:
if success:
break
current_content = file_contents.get(fname, "(file not yet read)")
prompt = (
f"You are a Python bug fixer. Fix the file '{fname}'.\n\n"
f"Test failures:\n{test_logs}\n\n"
f"Current content of '{fname}':\n{current_content}\n\n"
f"Output ONLY the complete corrected Python code for '{fname}'.\n"
"Rules:\n"
"- Do NOT include any explanation or comments about the fix.\n"
"- Do NOT use markdown fences (no ``` markers).\n"
"- Write the FULL file content from the first line to the last.\n"
"- If the fix is a missing colon, add it. If it is wrong logic, correct it.\n"
"- Do NOT add a filename comment (e.g. # broken_code.py) at the top.\n"
)
try:
response = client.chat.completions.create(
model=os.environ["MODEL_NAME"],
messages=[{"role": "user", "content": prompt}],
temperature=config["temperature"],
max_tokens=config["max_tokens"],
)
raw = response.choices[0].message.content or ""
except (RateLimitError, APIError) as e:
logger.error(f"LLM error for '{fname}': {e}")
continue
logger.info(f"LLM raw response for '{fname}': {raw[:300]!r}")
new_content = _extract_code(raw)
if not new_content:
logger.warning(f"LLM returned empty content for '{fname}' β€” skipping")
continue
res = await _call("edit_file", {"path": fname, "content": new_content})
reward, done = res.get("reward", 0.0), res.get("done", False)
_record("edit_file", {"path": fname}, reward, done)
# ── Remove Generated Solution ──────────────────────────────────────────────
for fname in files:
original = file_contents.get(fname)
if original:
await env_step(env_url, "edit_file", {"path": fname, "content": original}, delay_ms=0)
elapsed_ms = int((datetime.now(timezone.utc) - start_ts).total_seconds() * 1000)
eval_rewards = [s["reward"] for s in steps_detail if s["tool"] in ("edit_file", "run_tests")]
raw_reward = sum(eval_rewards) / len(eval_rewards) if eval_rewards else 0.001
final_reward = round(min(max(raw_reward, 0.001), 0.999), 4)
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
print(
f"[END] success={str(success).lower()} steps={global_step} "
f"score={final_reward:.3f} rewards={rewards_str}",
flush=True
)
return {
"run_number": run_number,
"success": success,
"final_reward": final_reward,
"rewards": rewards,
"steps": steps_detail,
"tools_used": tools_used,
"elapsed_ms": elapsed_ms,
}
# ── Main ─────────────────────────────────────────────────────────────────────────
async def main(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if config is None:
config = get_config()
try:
_validate_config(config)
except ValueError as e:
logger.error(f"Configuration error: {e}")
sys.exit(1)
tasks_input = config["task"].split(",") if "," in config["task"] else [config["task"]]
tasks = [f"task{i}" for i in range(1, 10)] if "all" in tasks_input else tasks_input
n_runs = config["number_of_runs"]
client = _init_client()
for task in tasks:
config["task"] = task
for run_number in range(1, n_runs + 1):
try:
result = await execute_run(run_number, client, config)
if result["success"]:
break
except Exception as e:
logger.error(f"Run {run_number} failed: {e}", exc_info=True)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Interrupted β€” partial results already saved to disk.")