PortSimEnv / openenv /berth_openenv /environment.py
AdithyaSK's picture
AdithyaSK HF Staff
Upload folder using huggingface_hub
86cc49e verified
Raw History Blame Contribute Delete
13.1 kB
"""The OpenEnv environment: one berth-planning episode per session, three MCP tools.
get_situation() the quay, notices, ships already alongside, closures and the ships to berth
check_plan(plan) rule violations and cost of the agent's own plan (budgeted; never a score or a reference)
submit_plan(plan) the final answer: ends the episode; the rubric grades it once
`reset(task_id=...)` or `reset(split=, index=)` (or `seed=` for a random task) picks a task. Tasks are served through
OpenEnv's Task API (`list_splits`, `num_tasks`, `get_task`, `get_task_range`). The reward is `BerthPlanRubric`
applied to the `submit_plan` action; every other step has reward 0.0. An episode that runs out of tool calls ends
with reward 0.0.
"""
from __future__ import annotations
import json
import os
import random
import threading
import time
import uuid
from collections import OrderedDict
from typing import Annotated, Any
from fastmcp import FastMCP
from openenv.core.env_server.mcp_environment import MCPEnvironment
from openenv.core.env_server.mcp_types import CallToolAction
from openenv.core.env_server.types import Action, EnvironmentMetadata, Observation, State
from pydantic import BaseModel, Field
from berth_core import PROMPT_VERSION, Task, TaskPack, evaluate, load_pack, parse_plan, plan_to_list, rules, situation
from berth_core.model import PlanError
try:
from .rubric import SUBMIT_TOOL, BerthPlanRubric
except ImportError: # flat layout
from rubric import SUBMIT_TOOL, BerthPlanRubric
MAX_CHECKS = int(os.environ.get("BERTH_MAX_CHECKS", "10"))
MAX_TOOL_CALLS = int(os.environ.get("BERTH_MAX_TOOL_CALLS", "24"))
class PlanEntry(BaseModel):
ship: int = Field(description="ship id from the table")
berth_hour: int = Field(description="hour the ship berths (>= its arrival hour)")
section: int = Field(description="first (lowest-numbered) section the ship occupies")
cranes: int | None = Field(default=None, description="quay cranes working the ship (tasks with crane rules; "
"defaults to the ship's planned cranes)")
PlanArg = Annotated[list[PlanEntry] | str, Field(
description='one entry per ship: [{"ship": 0, "berth_hour": 36, "section": 22}, ...]')]
class BerthState(State):
task_id: str | None = None
split: str | None = None
checks_used: int = 0
tool_calls: int = 0
done: bool = False
reward: float | None = None
grade: dict | None = None
class Episode:
"""Everything one episode did, kept for the viewer's live page (bounded store, newest first)."""
def __init__(self, task: Task, episode_id: str):
self.task = task
self.episode_id = episode_id
self.started = time.time()
self.steps: list[dict] = []
self.done = False
self.grade: dict | None = None
self.end_reason: str | None = None
def summary(self) -> dict:
return {"episode_id": self.episode_id, "task_id": self.task.task_id, "started": self.started,
"done": self.done, "end_reason": self.end_reason, "steps": self.steps, "grade": self.grade}
class _Store:
def __init__(self, cap: int = 200):
self.cap = cap
self._d: OrderedDict[str, Episode] = OrderedDict()
self._lock = threading.Lock()
def add(self, ep: Episode):
with self._lock:
self._d[ep.episode_id] = ep
while len(self._d) > self.cap:
self._d.popitem(last=False)
def get(self, eid: str) -> Episode | None:
return self._d.get(eid)
def recent(self, n: int = 50) -> list[Episode]:
return list(reversed(self._d.values()))[:n]
STORE = _Store()
def _json(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def _plan_result(task: Task, plan) -> dict:
r = evaluate(task, plan)
return {
"feasible": r.feasible,
"violations": r.violations,
"cost": r.cost, "delay_cost": r.delay_cost, "moves": r.moves,
"ships": [{"ship": s.ship, "departure": s.departure, "delay_h": s.delay_h, "moved": s.moved, "cost": s.cost}
| ({"cranes": s.cranes, "hours_alongside": s.departure - s.berth_hour} if s.cranes is not None else {})
for s in r.ships if s.berth_hour is not None],
}
class BerthPlanningEnvironment(MCPEnvironment):
"""Re-plan a real week of container berthings at a Barcelona quay after a disruption."""
SUPPORTS_CONCURRENT_SESSIONS = True
def get_metadata(self) -> EnvironmentMetadata:
"""What OpenEnv's /metadata reports (the web UI shows it too)."""
return EnvironmentMetadata(
name="PortSimEnv v1",
description="Re-plan a broken week of container-ship dockings at a Port of Barcelona quay, built from the "
"port's real 2024 calls. Tools: get_situation, check_plan (10 per episode), submit_plan "
"(graded once, deterministically, against the CP-SAT optimum).",
version="1.0.0",
author="FineEnvs",
documentation_url="https://github.com/adithya-s-k/FineEnvs/tree/main/07-simulation-environments/portsim-v1",
)
def __init__(self, pack: TaskPack | None = None):
self.pack = pack or load_pack()
self._episode: Episode | None = None
self._state = BerthState()
mcp = FastMCP("berth_planning")
@mcp.tool
def get_situation() -> str:
"""The quay, notices, ships already alongside, closed sections and the table of ships to berth."""
ep = self._require()
return situation(ep.task)
@mcp.tool
def check_plan(plan: PlanArg) -> str:
"""Check your plan: rule violations per ship, departure and delay per ship, and the plan's cost.
Uses one of your checks. It does not grade the plan."""
ep = self._require(open_only=True)
if self._state.checks_used >= MAX_CHECKS:
return _json({"error": f"no checks left (you had {MAX_CHECKS}); submit_plan when ready"})
self._state.checks_used += 1
try:
parsed, problems = parse_plan(ep.task, _raw(plan))
except PlanError as e:
out = {"error": str(e), "checks_left": MAX_CHECKS - self._state.checks_used}
ep.steps.append({"turn": self._state.tool_calls, "tool": "check_plan", "plan": None, "result": out})
return _json(out)
out = _plan_result(ep.task, parsed)
if problems:
out["entry_problems"] = problems
out["feasible"] = False
out["checks_left"] = MAX_CHECKS - self._state.checks_used
ep.steps.append({"turn": self._state.tool_calls, "tool": "check_plan", "plan": plan_to_list(parsed),
"result": out})
return _json(out)
@mcp.tool
def submit_plan(plan: PlanArg) -> str:
"""Submit your final berth plan. Ends the episode; the plan is graded once."""
ep = self._require(open_only=True)
try:
parsed, problems = parse_plan(ep.task, _raw(plan))
except PlanError as e:
parsed, problems = {}, [str(e)]
# The rubric grades the same arguments again in step(); this result is what the agent reads.
g = self.rubric.graded(CallToolAction(tool_name=SUBMIT_TOOL, arguments={"plan": _raw(plan)}))
ep.done = True
ep.end_reason = "submitted"
ep.grade = g.as_dict() if g else None
ep.steps.append({"turn": self._state.tool_calls, "tool": "submit_plan", "plan": plan_to_list(parsed),
"result": {"grade": ep.grade}})
out = {"submitted": True, "feasible": g.feasible if g else False, "reward": g.reward if g else 0.0}
if g and g.feasible:
out.update(cost=g.cost, delay_cost=g.delay_cost, moves=g.moves)
elif g:
out.update(violations=g.violations[:20], entry_problems=g.parse_problems)
return _json(out)
super().__init__(mcp)
self.rubric = BerthPlanRubric()
# ------------------------------------------------------------ Task API
def list_splits(self):
return [{"name": s, "type": s, "num_tasks": self.pack.count(s)} for s in self.pack.splits()]
def num_tasks(self, split):
return self.pack.count(split)
def get_task(self, split, index):
return self.pack.public(self.pack.at(split, int(index)))
def get_task_range(self, split, start=None, stop=None):
n = self.pack.count(split)
start, stop = int(start or 0), int(n if stop is None else stop)
return [self.pack.public(self.pack.at(split, i)) for i in range(max(0, start), min(n, stop))]
def list_tasks(self, split):
return self.get_task_range(split)
# ------------------------------------------------------------ lifecycle
def reset(self, seed: int | None = None, episode_id: str | None = None, split: str | None = None,
index: int | None = None, task_id: str | None = None, **kwargs: Any) -> Observation:
if task_id is not None:
task = self.pack.get(task_id)
else:
split = split or ("train" if "train" in self.pack.splits() else self.pack.splits()[0])
n = self.pack.count(split)
index = random.Random(seed).randrange(n) if index is None else int(index)
task = self.pack.at(split, index)
ep = Episode(task, str(episode_id)[:64] if episode_id else uuid.uuid4().hex[:12])
STORE.add(ep)
self._episode = ep
self._state = BerthState(episode_id=ep.episode_id, step_count=0, task_id=task.task_id, split=task.split)
self._reset_rubric()
self.rubric.set_task(task)
return Observation(done=False, reward=None, metadata={
"episode_id": ep.episode_id,
"task_id": task.task_id,
"split": task.split,
"difficulty": task.difficulty,
"instructions": rules(task, MAX_CHECKS),
"situation": situation(task),
"max_checks": MAX_CHECKS,
"max_tool_calls": MAX_TOOL_CALLS,
"tools": ["get_situation", "check_plan", "submit_plan"],
"prompt_version": PROMPT_VERSION,
"viewer_url": f"/viewer/#/live/{ep.episode_id}",
})
def _require(self, open_only: bool = False) -> Episode:
if self._episode is None:
raise ValueError("no episode: call reset first")
if open_only and self._episode.done:
raise ValueError("the episode is over: the plan was already submitted")
return self._episode
@property
def state(self) -> BerthState:
return self._state
# ------------------------------------------------------------ stepping
def _after_step(self, action: Action, obs: Observation) -> Observation:
ep = self._episode
if ep is None or not isinstance(action, CallToolAction):
return obs
self._state.tool_calls += 1
self._state.step_count += 1
obs.reward = self._apply_rubric(action, obs) # 0.0 unless this was submit_plan
if ep.done:
g = self.rubric.last_grade
obs.done = True
obs.metadata = {**(obs.metadata or {}), "grade": g.as_dict() if g else ep.grade,
"rubric": {name: r.last_score for name, r in self.rubric.named_rubrics()},
"episode_id": ep.episode_id}
self._state.done, self._state.reward = True, obs.reward
self._state.grade = obs.metadata["grade"]
elif self._state.tool_calls >= MAX_TOOL_CALLS:
ep.done, ep.end_reason = True, "tool_call_limit"
obs.done, obs.reward = True, 0.0
obs.metadata = {**(obs.metadata or {}), "end_reason": "tool_call_limit", "episode_id": ep.episode_id}
self._state.done, self._state.reward = True, 0.0
return obs
def step(self, action: Action, timeout_s: float | None = None, **kwargs: Any) -> Observation:
return self._after_step(action, super().step(action, timeout_s=timeout_s, **kwargs))
async def step_async(self, action: Action, timeout_s: float | None = None, **kwargs: Any) -> Observation:
return self._after_step(action, await super().step_async(action, timeout_s=timeout_s, **kwargs))
def _step_impl(self, action: Action, timeout_s: float | None = None, **kwargs) -> Observation:
return Observation(done=False, reward=0.0, metadata={"error": "use the MCP tools: get_situation, check_plan, submit_plan"})
def _raw(plan):
"""Tool arguments arrive as PlanEntry models (or a JSON string); the core parser takes plain data."""
if isinstance(plan, list):
return [p.model_dump(exclude_none=True) if isinstance(p, BaseModel) else p for p in plan]
return plan