Spaces:
Sleeping
Sleeping
| """ | |
| CSV Cleaning Environment Implementation. | |
| A real-world data cleaning environment where an AI agent must clean messy CSV | |
| datasets using structured commands. Exposes cleaning tools through MCP. | |
| Supported tools: | |
| - rename_column(old_name, new_name) | |
| - cast_column(column, dtype) | |
| - fill_missing(column, strategy, value?) | |
| - drop_missing(column?) | |
| - drop_duplicates(columns?) | |
| - filter_rows(column, operator, value) | |
| - strip_whitespace(column) | |
| - replace_values(column, old_value, new_value) | |
| """ | |
| import json | |
| import os | |
| from typing import Any, Dict, List, Optional | |
| from uuid import uuid4 | |
| import pandas as pd | |
| try: | |
| from openenv.core.env_server.mcp_environment import MCPEnvironment | |
| from openenv.core.env_server.types import Action, Observation, State | |
| except ImportError: | |
| from openenv.core.env_server.mcp_environment import MCPEnvironment | |
| from openenv.core.env_server.types import Action, Observation, State | |
| from fastmcp import FastMCP | |
| from .tasks import TASKS, TaskDefinition | |
| class CsvCleaningEnvironment(MCPEnvironment): | |
| """ | |
| A data cleaning environment where agents fix messy CSV data. | |
| The environment generates a messy dataset for the selected task. | |
| Each step, the agent issues a cleaning command via MCP tools. | |
| The environment applies the command, updates the dataset, and | |
| returns reward based on progress toward the target clean dataset. | |
| """ | |
| def __init__(self): | |
| """Initialize with MCP server and cleaning tools.""" | |
| mcp = FastMCP("csv_cleaner_env") | |
| self._df: Optional[pd.DataFrame] = None | |
| self._target: Optional[pd.DataFrame] = None | |
| self._task: Optional[TaskDefinition] = None | |
| self._last_result: str = "" | |
| self._prev_score: float = 0.0 | |
| self._state = State(episode_id=str(uuid4()), step_count=0) | |
| self._done = False | |
| self._env_ref = self # capture for closures | |
| # ---- MCP Tools ---- | |
| def rename_column(old_name: str, new_name: str) -> str: | |
| """Rename a column in the dataset.""" | |
| return self._exec_rename_column(old_name, new_name) | |
| def cast_column(column: str, dtype: str) -> str: | |
| """Cast a column to a new type. dtype: int, float, str, datetime.""" | |
| return self._exec_cast_column(column, dtype) | |
| def fill_missing(column: str, strategy: str, value: str = "") -> str: | |
| """Fill missing values. strategy: mean, median, mode, constant. value used if strategy=constant.""" | |
| return self._exec_fill_missing(column, strategy, value) | |
| def drop_missing(column: str = "") -> str: | |
| """Drop rows with missing values. If column empty, drops rows with any null.""" | |
| return self._exec_drop_missing(column) | |
| def drop_duplicates(columns: str = "") -> str: | |
| """Remove duplicate rows. columns: comma-separated list or empty for all.""" | |
| return self._exec_drop_duplicates(columns) | |
| def filter_rows(column: str, operator: str, value: str) -> str: | |
| """Filter rows. operator: ==, !=, >, <, >=, <=, contains.""" | |
| return self._exec_filter_rows(column, operator, value) | |
| def strip_whitespace(column: str) -> str: | |
| """Strip leading/trailing whitespace from a string column.""" | |
| return self._exec_strip_whitespace(column) | |
| def replace_values(column: str, old_value: str, new_value: str) -> str: | |
| """Replace occurrences of old_value with new_value in a column.""" | |
| return self._exec_replace_values(column, old_value, new_value) | |
| def get_dataset_info() -> str: | |
| """Get current dataset info: columns, types, null counts, sample rows.""" | |
| return self._exec_get_info() | |
| super().__init__(mcp) | |
| # ------------------------------------------------------------------ | |
| # Tool implementations | |
| # ------------------------------------------------------------------ | |
| def _exec_rename_column(self, old_name: str, new_name: str) -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded. Call reset() first." | |
| if old_name not in self._df.columns: | |
| self._last_result = f"Error: Column '{old_name}' not found. Available: {list(self._df.columns)}" | |
| return self._last_result | |
| self._df = self._df.rename(columns={old_name: new_name}) | |
| self._last_result = f"Renamed '{old_name}' to '{new_name}'" | |
| return self._last_result | |
| def _exec_cast_column(self, column: str, dtype: str) -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| if column not in self._df.columns: | |
| self._last_result = f"Error: Column '{column}' not found." | |
| return self._last_result | |
| try: | |
| if dtype == "int": | |
| self._df[column] = pd.to_numeric(self._df[column], errors="coerce").astype("Int64") | |
| elif dtype == "float": | |
| self._df[column] = pd.to_numeric(self._df[column].astype(str).str.replace("$", "", regex=False), errors="coerce") | |
| elif dtype == "str": | |
| self._df[column] = self._df[column].astype(str) | |
| elif dtype in ("datetime", "date"): | |
| self._df[column] = pd.to_datetime(self._df[column], errors="coerce") | |
| else: | |
| self._last_result = f"Error: Unknown dtype '{dtype}'. Use: int, float, str, datetime." | |
| return self._last_result | |
| self._last_result = f"Cast '{column}' to {dtype}" | |
| except Exception as e: | |
| self._last_result = f"Error casting '{column}' to {dtype}: {e}" | |
| return self._last_result | |
| def _exec_fill_missing(self, column: str, strategy: str, value: str = "") -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| if column not in self._df.columns: | |
| self._last_result = f"Error: Column '{column}' not found." | |
| return self._last_result | |
| try: | |
| null_before = int(self._df[column].isnull().sum()) | |
| if strategy == "mean": | |
| fill_val = self._df[column].mean() | |
| self._df[column] = self._df[column].fillna(fill_val) | |
| elif strategy == "median": | |
| fill_val = self._df[column].median() | |
| self._df[column] = self._df[column].fillna(fill_val) | |
| elif strategy == "mode": | |
| mode_vals = self._df[column].mode() | |
| fill_val = mode_vals[0] if len(mode_vals) > 0 else "" | |
| self._df[column] = self._df[column].fillna(fill_val) | |
| elif strategy == "constant": | |
| self._df[column] = self._df[column].fillna(value) | |
| elif strategy == "zero": | |
| self._df[column] = self._df[column].fillna(0) | |
| else: | |
| self._last_result = f"Error: Unknown strategy '{strategy}'. Use: mean, median, mode, constant, zero." | |
| return self._last_result | |
| null_after = int(self._df[column].isnull().sum()) | |
| self._last_result = f"Filled {null_before - null_after} nulls in '{column}' using {strategy}" | |
| except Exception as e: | |
| self._last_result = f"Error filling missing in '{column}': {e}" | |
| return self._last_result | |
| def _exec_drop_missing(self, column: str = "") -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| before = len(self._df) | |
| try: | |
| if column and column in self._df.columns: | |
| self._df = self._df.dropna(subset=[column]).reset_index(drop=True) | |
| else: | |
| self._df = self._df.dropna().reset_index(drop=True) | |
| after = len(self._df) | |
| self._last_result = f"Dropped {before - after} rows with missing values" | |
| except Exception as e: | |
| self._last_result = f"Error dropping missing: {e}" | |
| return self._last_result | |
| def _exec_drop_duplicates(self, columns: str = "") -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| before = len(self._df) | |
| try: | |
| if columns: | |
| col_list = [c.strip() for c in columns.split(",")] | |
| valid_cols = [c for c in col_list if c in self._df.columns] | |
| if valid_cols: | |
| self._df = self._df.drop_duplicates(subset=valid_cols).reset_index(drop=True) | |
| else: | |
| self._last_result = f"Error: None of {col_list} found in columns." | |
| return self._last_result | |
| else: | |
| self._df = self._df.drop_duplicates().reset_index(drop=True) | |
| after = len(self._df) | |
| self._last_result = f"Removed {before - after} duplicate rows" | |
| except Exception as e: | |
| self._last_result = f"Error removing duplicates: {e}" | |
| return self._last_result | |
| def _exec_filter_rows(self, column: str, operator: str, value: str) -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| if column not in self._df.columns: | |
| self._last_result = f"Error: Column '{column}' not found." | |
| return self._last_result | |
| before = len(self._df) | |
| try: | |
| col_data = self._df[column] | |
| if operator == "==": | |
| mask = col_data.astype(str) == value | |
| elif operator == "!=": | |
| mask = col_data.astype(str) != value | |
| elif operator == ">": | |
| mask = pd.to_numeric(col_data, errors="coerce") > float(value) | |
| elif operator == "<": | |
| mask = pd.to_numeric(col_data, errors="coerce") < float(value) | |
| elif operator == ">=": | |
| mask = pd.to_numeric(col_data, errors="coerce") >= float(value) | |
| elif operator == "<=": | |
| mask = pd.to_numeric(col_data, errors="coerce") <= float(value) | |
| elif operator == "contains": | |
| mask = col_data.astype(str).str.contains(value, na=False) | |
| else: | |
| self._last_result = f"Error: Unknown operator '{operator}'." | |
| return self._last_result | |
| self._df = self._df[mask].reset_index(drop=True) | |
| after = len(self._df) | |
| self._last_result = f"Filtered: kept {after} rows ({before - after} removed)" | |
| except Exception as e: | |
| self._last_result = f"Error filtering: {e}" | |
| return self._last_result | |
| def _exec_strip_whitespace(self, column: str) -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| if column not in self._df.columns: | |
| self._last_result = f"Error: Column '{column}' not found." | |
| return self._last_result | |
| try: | |
| self._df[column] = self._df[column].astype(str).str.strip() | |
| self._last_result = f"Stripped whitespace from '{column}'" | |
| except Exception as e: | |
| self._last_result = f"Error stripping whitespace: {e}" | |
| return self._last_result | |
| def _exec_replace_values(self, column: str, old_value: str, new_value: str) -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| if column not in self._df.columns: | |
| self._last_result = f"Error: Column '{column}' not found." | |
| return self._last_result | |
| try: | |
| count = int((self._df[column].astype(str) == old_value).sum()) | |
| self._df[column] = self._df[column].astype(str).str.replace(old_value, new_value, regex=False) | |
| self._last_result = f"Replaced {count} occurrences of '{old_value}' with '{new_value}' in '{column}'" | |
| except Exception as e: | |
| self._last_result = f"Error replacing values: {e}" | |
| return self._last_result | |
| def _exec_get_info(self) -> str: | |
| if self._df is None: | |
| return "Error: No dataset loaded." | |
| obs_data = self._get_observation_dict() | |
| info = { | |
| "row_count": obs_data["row_count"], | |
| "duplicate_count": obs_data["duplicate_count"], | |
| "columns": obs_data["columns"], | |
| "task_description": obs_data["task_description"], | |
| "last_action_result": obs_data["last_action_result"], | |
| "progress": obs_data["progress"], | |
| } | |
| return json.dumps(info, indent=2) | |
| # ------------------------------------------------------------------ | |
| # Environment API | |
| # ------------------------------------------------------------------ | |
| def _get_observation_dict(self) -> Dict[str, Any]: | |
| """Build observation data from current state.""" | |
| if self._df is None: | |
| return { | |
| "columns": [], | |
| "row_count": 0, | |
| "duplicate_count": 0, | |
| "task_description": "", | |
| "last_action_result": self._last_result, | |
| "progress": 0.0, | |
| } | |
| columns_info = [] | |
| for col in self._df.columns: | |
| columns_info.append({ | |
| "name": col, | |
| "dtype": str(self._df[col].dtype), | |
| "null_count": int(self._df[col].isnull().sum()), | |
| "unique_count": int(self._df[col].nunique()), | |
| "sample_values": [str(v) for v in self._df[col].dropna().head(3).tolist()], | |
| }) | |
| progress = 0.0 | |
| if self._task and self._target is not None: | |
| progress = self._task.grade(self._df, self._target) | |
| return { | |
| "columns": columns_info, | |
| "row_count": len(self._df), | |
| "duplicate_count": int(self._df.duplicated().sum()), | |
| "task_description": self._task.description if self._task else "", | |
| "last_action_result": self._last_result, | |
| "progress": round(min(max(progress, 0.0), 1.0), 4), | |
| } | |
| def reset( | |
| self, | |
| seed: Optional[int] = None, | |
| episode_id: Optional[str] = None, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| """Reset environment with a messy dataset for the configured task.""" | |
| task_name = kwargs.get("task", os.getenv("CSV_CLEANER_TASK", "fix_column_types")) | |
| actual_seed = seed if seed is not None else 42 | |
| if task_name not in TASKS: | |
| available = list(TASKS.keys()) | |
| return Observation( | |
| done=True, | |
| reward=0.0, | |
| metadata={"error": f"Unknown task '{task_name}'. Available: {available}"}, | |
| ) | |
| self._task = TASKS[task_name] | |
| self._df = self._task.generate_messy(actual_seed) | |
| self._target = self._task.generate_target(actual_seed) | |
| self._done = False | |
| self._last_result = "Environment ready. Use get_dataset_info to see the current state." | |
| self._prev_score = self._task.grade(self._df, self._target) | |
| self._state = State( | |
| episode_id=episode_id or str(uuid4()), | |
| step_count=0, | |
| ) | |
| obs_data = self._get_observation_dict() | |
| return Observation( | |
| done=False, | |
| reward=0.0, | |
| metadata={ | |
| "status": "ready", | |
| "task": task_name, | |
| "difficulty": self._task.difficulty, | |
| "max_steps": self._task.max_steps, | |
| "checklist": self._task.checklist, | |
| **obs_data, | |
| }, | |
| ) | |
| def _step_impl( | |
| self, | |
| action: Action, | |
| timeout_s: Optional[float] = None, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| """Handle non-MCP actions (returns error — use MCP tools instead).""" | |
| return Observation( | |
| done=False, | |
| reward=0.0, | |
| metadata={ | |
| "error": f"Unknown action type: {type(action).__name__}. " | |
| "Use MCP tools (get_dataset_info, cast_column, fill_missing, etc.)", | |
| }, | |
| ) | |
| def step( | |
| self, | |
| action: Action, | |
| timeout_s: Optional[float] = None, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| """Execute a step. Increments step count, computes reward.""" | |
| self._state.step_count += 1 | |
| # Let MCPEnvironment handle tool dispatch | |
| obs = super().step(action, timeout_s=timeout_s, **kwargs) | |
| # Compute reward based on progress delta | |
| reward = 0.0 | |
| done = False | |
| if self._task and self._target is not None and self._df is not None: | |
| current_score = self._task.grade(self._df, self._target) | |
| reward = max(0.0, current_score - self._prev_score) | |
| self._prev_score = current_score | |
| # Check if done (target reached or max steps exceeded) | |
| if current_score >= 0.95: | |
| done = True | |
| reward += 0.1 # bonus for completing | |
| elif self._state.step_count >= self._task.max_steps: | |
| done = True | |
| self._done = done | |
| # Inject our reward/done into the observation | |
| obs.reward = round(reward, 4) | |
| obs.done = done | |
| if obs.metadata is None: | |
| obs.metadata = {} | |
| obs.metadata.update(self._get_observation_dict()) | |
| return obs | |
| async def step_async( | |
| self, | |
| action: Action, | |
| timeout_s: Optional[float] = None, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| """Async step used by the WebSocket handler.""" | |
| self._state.step_count += 1 | |
| obs = await super().step_async(action, timeout_s=timeout_s, **kwargs) | |
| reward = 0.0 | |
| done = False | |
| if self._task and self._target is not None and self._df is not None: | |
| current_score = self._task.grade(self._df, self._target) | |
| reward = max(0.0, current_score - self._prev_score) | |
| self._prev_score = current_score | |
| if current_score >= 0.95: | |
| done = True | |
| reward += 0.1 | |
| elif self._state.step_count >= self._task.max_steps: | |
| done = True | |
| self._done = done | |
| obs.reward = round(reward, 4) | |
| obs.done = done | |
| if obs.metadata is None: | |
| obs.metadata = {} | |
| obs.metadata.update(self._get_observation_dict()) | |
| return obs | |
| def state(self) -> State: | |
| """Get current environment state.""" | |
| return self._state | |