Spaces:
Sleeping
Sleeping
| """ | |
| inference.py β LLM Agent (OpenEnv Compliant) | |
| ============================================= | |
| Meta OpenEnv Hackathon 2026 β FINAL COMPLIANT VERSION | |
| MANDATORY per competition spec: | |
| - Uses OpenAI client only (no Anthropic, no LangChain, no direct HTTP) | |
| - Reads API_BASE_URL, MODEL_NAME, HF_TOKEN from environment | |
| - Emits [START], [STEP], [END] with exact field order and formatting | |
| - [END] is always emitted even on exception (try-finally) | |
| - Score formatted to 3 decimal places (matches official sample script log_end) | |
| STDOUT FORMAT (from official sample script β authoritative source): | |
| [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=<0.000> rewards=<r1,r2,...,rn> | |
| HOW TO RUN: | |
| export HF_TOKEN=your_hf_token_here | |
| export API_BASE_URL=https://router.huggingface.co/v1 | |
| export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct | |
| export TASK_ID=1 | |
| python inference.py | |
| OWNER: LLM Engineer | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import re | |
| from openai import OpenAI | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| # --------------------------------------------------------------------------- | |
| # MANDATORY ENV VARS (per OpenEnv spec β DO NOT rename these variables) | |
| # --------------------------------------------------------------------------- | |
| 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: | |
| # Must emit valid [END] before exiting so evaluator logs are clean | |
| print("[END] success=false steps=0 score=0.000 rewards=", flush=True) | |
| sys.exit(1) | |
| TASK_ID = int(os.getenv("TASK_ID", "1")) | |
| ENV_BENCHMARK = "email-triage-rl" | |
| # --------------------------------------------------------------------------- | |
| # TASK REGISTRY | |
| # --------------------------------------------------------------------------- | |
| TASK_CONFIGS = { | |
| 1: "tasks/task_1_easy.json", | |
| 2: "tasks/task_2_medium.json", | |
| 3: "tasks/task_3_hard.json", | |
| } | |
| TASK_NAMES = { | |
| 1: "basic-prioritization", | |
| 2: "vip-tracking", | |
| 3: "full-relationship-management", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # OPENAI CLIENT (mandatory β points to HuggingFace router, free tier) | |
| # --------------------------------------------------------------------------- | |
| openai_client = OpenAI( | |
| base_url=API_BASE_URL, | |
| api_key=HF_TOKEN, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # HELPERS | |
| # --------------------------------------------------------------------------- | |
| def load_task_config(task_id: int) -> dict: | |
| """ | |
| Load JSON task config if file exists, else return built-in defaults. | |
| IMPORTANT: Returns the FULL config dict including the 'features' block. | |
| The environment reads features flags to gate mechanics like: | |
| - dynamic_followups (Task 2+) | |
| - sunset_penalty (Task 2+) | |
| - repeat_ignore_penalty (Task 3) | |
| - time_traps (Task 3) | |
| Passing an empty dict would cause the environment to run with ALL features | |
| disabled (Task 1 behaviour), regardless of task_id. | |
| """ | |
| path = TASK_CONFIGS.get(task_id, "") | |
| if path and os.path.exists(path): | |
| with open(path) as f: | |
| return json.load(f) | |
| # Built-in fallback defaults β mirrors the task JSON files exactly | |
| fallbacks = { | |
| 1: { | |
| "num_emails": 20, "vip_count": 5, "normal_count": 10, "spam_count": 5, | |
| "time_budget": 480, | |
| "features": { | |
| "action_cost_asymmetry": True, "dynamic_followups": False, | |
| "sunset_penalty": False, "time_traps": False, "repeat_ignore_penalty": False, | |
| }, | |
| }, | |
| 2: { | |
| "num_emails": 25, "vip_count": 8, "normal_count": 12, "spam_count": 5, | |
| "time_budget": 420, | |
| "features": { | |
| "action_cost_asymmetry": True, "dynamic_followups": True, | |
| "sunset_penalty": True, "time_traps": False, "repeat_ignore_penalty": False, | |
| }, | |
| }, | |
| 3: { | |
| "num_emails": 30, "vip_count": 8, "normal_count": 14, "spam_count": 8, | |
| "time_budget": 360, | |
| "features": { | |
| "action_cost_asymmetry": True, "dynamic_followups": True, | |
| "sunset_penalty": True, "time_traps": True, "repeat_ignore_penalty": True, | |
| }, | |
| }, | |
| } | |
| return fallbacks.get(task_id, fallbacks[1]) | |
| def build_prompt(obs) -> str: | |
| """ | |
| Convert EmailObservation into a natural language prompt. | |
| Intentional partial observability: | |
| - Agent sees email_length hint (NOT raw time cost) | |
| - Agent sees relationship_score for THIS sender only | |
| - Agent does NOT see future emails in the queue | |
| """ | |
| if obs.email_length < 200: | |
| length_hint = "short (quick to handle)" | |
| elif obs.email_length < 800: | |
| length_hint = "medium length" | |
| else: | |
| length_hint = "long and detailed (will cost significant time)" | |
| if obs.relationship_score >= 80: | |
| rel_hint = "excellent" | |
| elif obs.relationship_score >= 60: | |
| rel_hint = "good" | |
| elif obs.relationship_score >= 40: | |
| rel_hint = "strained" | |
| else: | |
| rel_hint = "critically damaged β this person is frustrated with you" | |
| return f"""You are an AI email triage assistant managing a professional inbox. | |
| CURRENT SITUATION: | |
| - Time remaining today: {obs.time_budget_remaining} minutes | |
| - Emails left to process: {obs.emails_remaining} | |
| CURRENT EMAIL: | |
| - From: {obs.sender} | |
| - Sender tier: {obs.sender_importance} (VIP = boss/key client, Normal = teammates, Spam = newsletters) | |
| - Subject: {obs.subject} | |
| - Email length: {length_hint} | |
| - Your relationship with this sender: {rel_hint} ({obs.relationship_score:.0f}/100) | |
| EMAIL BODY: | |
| {obs.body} | |
| DECISION: | |
| You must choose one action: | |
| 0 = IGNORE this email (saves time, but may damage the relationship β especially for VIPs) | |
| 1 = RESPOND to this email (costs time proportional to email complexity, improves relationship) | |
| TRADEOFFS TO CONSIDER: | |
| - Ignoring VIPs damages your relationship and they WILL follow up more urgently | |
| - Responding to spam wastes valuable time with no benefit | |
| - Long emails cost more time β is this worth the investment given your remaining budget? | |
| - Running out of time means a penalty for all unfinished high-value work | |
| Reply with ONLY a single digit: 0 or 1 | |
| Your decision:""" | |
| def parse_action(llm_response: str) -> int: | |
| """ | |
| Extract 0 or 1 from LLM response. | |
| Defaults to RESPOND (1) on parse failure β conservative safe default. | |
| """ | |
| text = llm_response.strip() | |
| if text in ("0", "1"): | |
| return int(text) | |
| match = re.search(r"\b([01])\b", text) | |
| if match: | |
| return int(match.group(1)) | |
| return 1 # conservative default: respond rather than silently ignore | |
| # --------------------------------------------------------------------------- | |
| # STDOUT LOGGING β exact format required by spec | |
| # --------------------------------------------------------------------------- | |
| def log_start(task: str, env: str, model: str) -> None: | |
| """[START] emitted once at episode begin.""" | |
| print(f"[START] task={task} env={env} model={model}", flush=True) | |
| def log_step(step: int, action: str, reward: float, done: bool, error) -> None: | |
| """ | |
| [STEP] emitted immediately after every env.step() call. | |
| - reward: 2 decimal places | |
| - done: lowercase boolean (true/false) | |
| - error: raw string or 'null' | |
| """ | |
| error_val = error if error else "null" | |
| done_val = str(done).lower() | |
| print( | |
| f"[STEP] step={step} action={action} " | |
| f"reward={reward:.2f} done={done_val} error={error_val}", | |
| flush=True, | |
| ) | |
| def log_end(success: bool, steps: int, score: float, rewards: list) -> None: | |
| """ | |
| [END] emitted after env.close(), always, even on exception. | |
| Format from official sample script (authoritative): | |
| [END] success=<bool> steps=<n> score=<0.000> rewards=<r1,r2,...,rn> | |
| score uses .3f (3 decimal places). | |
| rewards use .2f (2 decimal places), comma-separated. | |
| """ | |
| rewards_str = ",".join(f"{r:.2f}" for r in rewards) if rewards else "0.00" | |
| print( | |
| f"[END] success={str(success).lower()} steps={steps} " | |
| f"score={score:.3f} rewards={rewards_str}", | |
| flush=True, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # MAIN EPISODE RUNNER | |
| # --------------------------------------------------------------------------- | |
| def run_episode(task_id: int = 1) -> dict: | |
| """ | |
| Run one complete episode against the email-triage environment. | |
| Emits [START], one [STEP] per email, then [END] per OpenEnv spec. | |
| Uses try-finally to guarantee [END] is always emitted, even on crash. | |
| """ | |
| import client | |
| try: | |
| from grader import grade_episode | |
| has_grader = True | |
| except ImportError: | |
| has_grader = False | |
| task_name = TASK_NAMES.get(task_id, f"task-{task_id}") | |
| # Load full task config including features block β CRITICAL for correct behaviour. | |
| # Passing an empty/partial config would disable followups and sunset penalty | |
| # even for Tasks 2 and 3, making the environment behave like Task 1 always. | |
| task_config = load_task_config(task_id) | |
| log_start(task=task_name, env=ENV_BENCHMARK, model=MODEL_NAME) | |
| # Episode state β initialised before try so finally can always read them | |
| total_reward = 0.0 | |
| step_count = 0 | |
| rewards_log = [] | |
| episode_history = [] | |
| success = True | |
| final_state = None | |
| score = 0.0 | |
| try: | |
| # --- Verify server is reachable before starting --- | |
| if hasattr(client, "health_check") and not client.health_check(): | |
| raise ConnectionError( | |
| "Server not running. Start with:\n" | |
| " uvicorn server.app:app --host 0.0.0.0 --port 7860" | |
| ) | |
| # --- Start episode β pass full config so environment reads feature flags --- | |
| obs = client.reset_env(task_config=task_config, task_id=task_id) | |
| # --- Episode loop: one email at a time --- | |
| while True: | |
| prompt = build_prompt(obs) | |
| error_msg = None | |
| # LLM call β default to RESPOND (1) on any API failure | |
| try: | |
| response = openai_client.chat.completions.create( | |
| model=MODEL_NAME, | |
| max_tokens=10, | |
| messages=[{"role": "user", "content": prompt}], | |
| ) | |
| llm_response = response.choices[0].message.content or "1" | |
| except Exception as e: | |
| error_msg = str(e).replace('\n', ' ') | |
| llm_response = "1" | |
| action = parse_action(llm_response) | |
| action_label = "RESPOND" if action == 1 else "IGNORE" | |
| # Environment step | |
| try: | |
| result = client.step_env(action) | |
| except Exception as e: | |
| error_msg = str(e).replace('\n', ' ') | |
| success = False | |
| log_step( | |
| step = step_count + 1, | |
| action = action_label, | |
| reward = 0.0, | |
| done = True, | |
| error = error_msg, | |
| ) | |
| raise | |
| total_reward += result.reward | |
| step_count += 1 | |
| rewards_log.append(result.reward) | |
| # Mandatory [STEP] β emitted immediately after env.step() | |
| log_step( | |
| step = step_count, | |
| action = action_label, | |
| reward = result.reward, | |
| done = result.done, | |
| error = error_msg, | |
| ) | |
| # Use model_dump() for Pydantic v2 compatibility | |
| obs_dict = obs.model_dump() if hasattr(obs, "model_dump") else obs.__dict__ | |
| episode_history.append({ | |
| "step": step_count, | |
| "observation": obs_dict, | |
| "action": action, | |
| "reward": result.reward, | |
| "info": result.info, | |
| }) | |
| if result.done: | |
| break | |
| obs = result.observation | |
| # --- Grade episode --- | |
| final_state = client.get_state() | |
| if has_grader: | |
| score = grade_episode(episode_history, final_state, task_id) | |
| else: | |
| # Fallback: normalised total reward β will fail Phase 2 variance check | |
| score = max(0.0, min(1.0, total_reward / 100.0)) | |
| score = float(max(0.0, min(1.0, score))) # clamp to [0,1] per spec | |
| except Exception as e: | |
| success = False | |
| sys.stderr.write(f"[ERROR] Episode crashed: {e}\n") | |
| finally: | |
| # MANDATORY: [END] is ALWAYS emitted, even on exception | |
| log_end( | |
| success = success, | |
| steps = step_count, | |
| score = score, | |
| rewards = rewards_log, | |
| ) | |
| # Clean up environment connection if client supports it | |
| if hasattr(client, "close_env"): | |
| try: | |
| client.close_env() | |
| except Exception: | |
| pass | |
| return { | |
| "task_id": task_id, | |
| "total_reward": total_reward, | |
| "steps": step_count, | |
| "score": score, | |
| "episode_history": episode_history, | |
| "final_state": final_state, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # ENTRY POINT β TASK_ID env var selects which task (default=1) | |
| # Evaluators run all 3 tasks by changing TASK_ID externally. | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| for task_id in [1, 2, 3]: | |
| run_episode(task_id=task_id) |