| """ |
| Project Manager β OpenEnv Client (Round 2) |
| |
| Typed HTTP client for the multi-sprint /project/* endpoints. |
| Mirrors the R1 SprintEnvClient interface exactly, extended for R2. |
| |
| Usage: |
| from project_client import ProjectEnvClient |
| |
| client = ProjectEnvClient(base_url="https://sejal-k-ai-sprint-manager.hf.space") |
| obs = client.reset(task_name="project_easy", seed=42) |
| |
| while not obs["done"]: |
| result = client.step({"action_type": "assign", "task_id": "T01", "dev_id": "dev1"}) |
| obs = result.observation |
| |
| client.close() |
| |
| # Or as context manager: |
| with ProjectEnvClient() as client: |
| obs = client.reset("project_hard") |
| while not obs["done"]: |
| result = client.step({"action_type": "skip"}) |
| obs = result.observation |
| """ |
|
|
| from __future__ import annotations |
|
|
| import requests |
| from typing import Any, Optional |
|
|
|
|
| |
|
|
| class ProjectStepResult: |
| """ |
| Typed result from a project step() call. |
| |
| Extends R1 StepResult with R2-specific fields surfaced as properties. |
| """ |
|
|
| def __init__(self, payload: dict) -> None: |
| self.observation: dict = payload["observation"] |
| self.reward: float = payload["reward"] |
| self.done: bool = payload["done"] |
| self.info: dict = payload.get("info", {}) |
|
|
| |
|
|
| @property |
| def current_sprint(self) -> int: |
| return self.observation.get("current_sprint", 1) |
|
|
| @property |
| def current_day(self) -> int: |
| return self.observation.get("current_day", 1) |
|
|
| @property |
| def instruction_queue(self) -> list[dict]: |
| """All instructions released up to current_day.""" |
| return self.observation.get("instruction_queue", []) |
|
|
| @property |
| def active_instructions(self) -> list[dict]: |
| """Released instructions not yet followed.""" |
| return [i for i in self.instruction_queue if not i.get("followed", False)] |
|
|
| @property |
| def tech_debt(self) -> list[str]: |
| """Task IDs that became tech debt at sprint boundaries.""" |
| return self.observation.get("tech_debt", []) |
|
|
| @property |
| def sprint_rewards(self) -> list[float]: |
| """Per-sprint reward history.""" |
| return self.observation.get("sprint_rewards", []) |
|
|
| @property |
| def instruction_following_score(self) -> float: |
| return self.observation.get("instruction_following_score", 1.0) |
|
|
| @property |
| def tasks_completed(self) -> int: |
| return self.observation.get("tasks_completed", 0) |
|
|
| @property |
| def tasks_missed(self) -> int: |
| return self.observation.get("tasks_missed", 0) |
|
|
| @property |
| def cumulative_reward(self) -> float: |
| return self.observation.get("cumulative_reward", 0.0) |
|
|
| def __repr__(self) -> str: |
| return ( |
| f"ProjectStepResult(" |
| f"reward={self.reward:+.3f}, done={self.done}, " |
| f"day={self.current_day}/60, sprint={self.current_sprint}/6, " |
| f"completed={self.tasks_completed}, " |
| f"inst_score={self.instruction_following_score:.2f}, " |
| f"debt={len(self.tech_debt)})" |
| ) |
|
|
|
|
| |
|
|
| class ProjectEnvClient: |
| """ |
| HTTP client for the R2 multi-sprint Project Manager environment. |
| |
| Wraps the /project/* REST API into a clean typed Python interface. |
| Use this in RL training loops (train_llm.py), evaluation scripts |
| (evaluate_r2.py), and notebooks. |
| |
| All endpoints mirror the R1 SprintEnvClient interface so training |
| code can swap between R1 and R2 with minimal changes. |
| """ |
|
|
| def __init__( |
| self, |
| base_url: str = "http://localhost:7860", |
| timeout: int = 60, |
| ) -> None: |
| self.base_url = base_url.rstrip("/") |
| self.timeout = timeout |
| self._session = requests.Session() |
| self._prefix = "/project" |
|
|
| |
|
|
| def reset( |
| self, |
| task_name: str = "project_easy", |
| seed: Optional[int] = None, |
| episode_id: Optional[str] = None, |
| ) -> dict: |
| """ |
| Start a new 60-day multi-sprint episode. |
| |
| Args: |
| task_name: "project_easy" | "project_medium" | "project_hard" |
| seed: Random seed for reproducibility |
| episode_id: Optional custom episode identifier |
| |
| Returns: |
| Initial observation dict (includes current_sprint, instruction_queue, |
| tech_debt, sprint_rewards). |
| """ |
| payload: dict[str, Any] = {"task_name": task_name} |
| if seed is not None: |
| payload["seed"] = seed |
| if episode_id is not None: |
| payload["episode_id"] = episode_id |
|
|
| resp = self._session.post( |
| f"{self.base_url}{self._prefix}/reset", |
| json=payload, |
| timeout=self.timeout, |
| ) |
| resp.raise_for_status() |
| return resp.json() |
|
|
| def step(self, action: dict[str, Any]) -> ProjectStepResult: |
| """ |
| Take one action and advance the project by one day. |
| |
| Args: |
| action: dict with keys: action_type, task_id, dev_id, |
| new_priority, task_ids (for sprint_plan), notes. |
| Minimum required: {"action_type": "skip"} |
| |
| Returns: |
| ProjectStepResult with observation, reward, done, info, |
| plus R2 convenience properties. |
| """ |
| payload = {"action": action} |
| resp = self._session.post( |
| f"{self.base_url}{self._prefix}/step", |
| json=payload, |
| timeout=self.timeout, |
| ) |
| resp.raise_for_status() |
| return ProjectStepResult(resp.json()) |
|
|
| def state(self) -> dict: |
| """Return the full current internal state snapshot.""" |
| resp = self._session.get( |
| f"{self.base_url}{self._prefix}/state", |
| timeout=self.timeout, |
| ) |
| resp.raise_for_status() |
| return resp.json() |
|
|
| def health(self) -> dict: |
| """ |
| Check R2 server health. |
| Returns round=2 and current episode summary. |
| """ |
| resp = self._session.get( |
| f"{self.base_url}{self._prefix}/health", |
| timeout=self.timeout, |
| ) |
| resp.raise_for_status() |
| return resp.json() |
|
|
| def list_tasks(self) -> list[dict]: |
| """List all available R2 multi-sprint scenarios.""" |
| resp = self._session.get( |
| f"{self.base_url}{self._prefix}/tasks", |
| timeout=self.timeout, |
| ) |
| resp.raise_for_status() |
| return resp.json()["tasks"] |
|
|
| |
|
|
| def run_episode( |
| self, |
| task_name: str, |
| policy_fn, |
| seed: Optional[int] = None, |
| max_steps: int = 60, |
| verbose: bool = False, |
| ) -> dict: |
| """ |
| Convenience: run a full episode with a policy function. |
| |
| Args: |
| task_name: Scenario name |
| policy_fn: fn(observation: dict) -> action: dict |
| seed: Optional random seed |
| max_steps: Safety cap (default 60 = full project) |
| verbose: Print step summaries to stdout |
| |
| Returns: |
| dict with keys: steps, cumulative_reward, final_score, |
| tasks_completed, tasks_missed, instruction_following_score, |
| tech_debt, sprint_rewards, done |
| """ |
| obs = self.reset(task_name=task_name, seed=seed) |
| total_reward = 0.0 |
| steps = 0 |
|
|
| for step_num in range(1, max_steps + 1): |
| if obs.get("done", False): |
| break |
| action = policy_fn(obs) |
| result = self.step(action) |
| obs = result.observation |
| total_reward += result.reward |
| steps += 1 |
|
|
| if verbose: |
| print( |
| f" day={obs['current_day']-1:02d}/60 " |
| f"sprint={obs['current_sprint']}/6 " |
| f"done={obs['tasks_completed']} " |
| f"missed={obs['tasks_missed']} " |
| f"debt={len(obs['tech_debt'])} " |
| f"inst={obs['instruction_following_score']:.2f} " |
| f"reward={result.reward:+.3f}", |
| flush=True, |
| ) |
|
|
| return { |
| "steps": steps, |
| "cumulative_reward": round(total_reward, 4), |
| "tasks_completed": obs.get("tasks_completed", 0), |
| "tasks_missed": obs.get("tasks_missed", 0), |
| "instruction_following_score": obs.get("instruction_following_score", 0.0), |
| "tech_debt": obs.get("tech_debt", []), |
| "sprint_rewards": obs.get("sprint_rewards", []), |
| "done": obs.get("done", False), |
| } |
|
|
| |
|
|
| def close(self) -> None: |
| """Close the underlying HTTP session.""" |
| self._session.close() |
|
|
| def __enter__(self) -> "ProjectEnvClient": |
| return self |
|
|
| def __exit__(self, *args: Any) -> None: |
| self.close() |
|
|
| def __repr__(self) -> str: |
| return f"ProjectEnvClient(base_url='{self.base_url}', prefix='{self._prefix}')" |