File size: 33,644 Bytes
df84e45 1d12f00 df84e45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 | """The OpenEnv environment: MCP tools over one shared episode.
`reset(split=, index=, mode=, surface=)` picks a Crello template and how it is
played; the tools a model sees depend on the surface:
tools add_image, add_text, add_shape, update_element, ... (structured edits)
html write_html, read_html (one page of markup)
gui screenshot, left_click, left_click_drag, type, key (a real editor)
On the gui surface the editor is the built-in one in one of six layouts, or a real app (skin="penpot", "impress",
"inkscape-desktop", "gimp"; realapps/), which the server runs locally or in Daytona sandboxes it manages.
and every surface shares get_task, view_reference and submit. `submit` ends the
episode and its reward is the score of the final document (see core/score.py).
Pure MCP clients, which have no `reset`, start with `start_task`.
Tasks are served through OpenEnv's Task API (`list_splits`, `num_tasks`,
`get_task`, `get_task_range`), so a trainer can enumerate a split and then
reset onto any index.
"""
from __future__ import annotations
import base64
import json
import os
import random
from typing import Annotated, Any, Literal
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.transforms.visibility import Visibility
from fastmcp.utilities.types import Image
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, Observation
from pydantic import Field
try:
from ..core import ops
from ..core.contact import contact_sheet
from ..core.html_profile import HTMLProfileError, html_to_doc
from ..core.pack import TaskPack, load_pack
from ..core.prompts import PROMPT_VERSION, instructions
from ..models import DesignState
from .episodes import MODES, REAL_SKINS, SKINS, STORE, SURFACES, Episode
from .gui import VIEWPORT, GuiDriver, self_url
except ImportError: # flat layout inside the Docker image
from core import ops
from core.contact import contact_sheet
from core.html_profile import HTMLProfileError, html_to_doc
from core.pack import TaskPack, load_pack
from core.prompts import PROMPT_VERSION, instructions
from models import DesignState
from server.episodes import MODES, REAL_SKINS, SKINS, STORE, SURFACES, Episode
from server.gui import VIEWPORT, GuiDriver, self_url
EDIT_TOOLS = {
"add_image", "add_text", "add_shape", "update_element", "delete_element",
"duplicate_element", "reorder_element", "align_elements", "set_background", "undo", "redo",
"write_html",
}
DEFAULT_MAX_STEPS = {"tools": 60, "html": 20, "gui": 200}
# Tools that only look (and the lifecycle call) don't spend the action budget.
OBSERVATION_TOOLS = {"start_task", "get_task", "view_reference", "view_canvas", "view_assets", "list_fonts",
"list_elements", "read_html", "screenshot", "wait"}
OBSERVATION_FACTOR = 3
Num = float | int
FILE_DIALOGS = ("mac", "windows", "linux")
COORDINATES = ("pixels", "normalized")
Coord = Annotated[list[int], Field(description="[x, y] on the screenshot: pixels, or a 0-1000 grid when "
"your instructions say so", min_length=2, max_length=2)]
def _key_sequence(keys) -> list[str]:
"""'enter', ['ctrl+a', 'delete'] or the JSON string '["enter"]' -> key combos."""
if isinstance(keys, str):
text = keys.strip()
if text.startswith("["):
try:
keys = json.loads(text)
except json.JSONDecodeError:
keys = [text.strip("[]\"' ")]
else:
return [text] if text else []
return [str(k).strip() for k in keys if str(k).strip()]
def _json(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
class DesignCanvasEnvironment(MCPEnvironment):
"""Recreate a Crello design on a real canvas, through tools, HTML or a GUI."""
SUPPORTS_CONCURRENT_SESSIONS = True
def __init__(self, pack: TaskPack | None = None):
self.pack = pack or load_pack()
self._episode: Episode | None = None
self._state = DesignState()
self._reward_mode = "final"
self._last_total = 0.0
mcp = FastMCP("design_canvas")
self._mcp = mcp
self._register_common(mcp)
self._register_tools_surface(mcp)
self._register_html_surface(mcp)
self._register_gui_surface(mcp)
super().__init__(mcp)
self._set_surface("tools")
# ============================================================ 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):
return self.pack.task_range(split, start, 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,
mode: str = "reference",
surface: str = "tools",
skin: str | None = None,
theme: str | None = None,
layout: str | None = None,
max_steps: int | None = None,
reward_mode: str = "final",
coordinates: str = "pixels",
file_dialog: str | None = None,
**kwargs: Any,
) -> Observation:
if coordinates not in COORDINATES:
raise ValueError(f"coordinates must be one of {COORDINATES}")
if mode not in MODES:
raise ValueError(f"mode must be one of {MODES}")
if surface not in SURFACES:
raise ValueError(f"surface must be one of {SURFACES}")
if reward_mode not in ("final", "dense"):
raise ValueError("reward_mode must be final or dense")
if skin in REAL_SKINS and surface != "gui": # an app is driven by mouse and keyboard only
raise ValueError(f"skin {skin!r} is a real app: use surface='gui' (tools and html edit the canvas "
"directly, with no app)")
rng = random.Random(seed)
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])
count = self.pack.count(split)
index = rng.randrange(count) if index is None else int(index)
task = self.pack.at(split, index)
# A seeded reset randomises the look (layout and theme), so a policy has to read
# the screen rather than memorise where controls live. Mirrored panels
# (layout="swap") exist but are opt-in: no real editor flips its UI.
skin = skin or (rng.choice(SKINS) if seed is not None else "figma")
theme = theme or (rng.choice(("light", "dark")) if seed is not None else "light")
layout = layout or "default"
if layout not in ("default", "swap"):
raise ValueError("layout must be default or swap")
# The file window images are opened from looks like macOS, Windows or Linux (GTK).
# Drawn after skin and theme so seeded looks from earlier versions are unchanged.
file_dialog = file_dialog or (rng.choice(FILE_DIALOGS) if seed is not None else None)
if file_dialog is not None and file_dialog not in FILE_DIALOGS:
raise ValueError(f"file_dialog must be one of {FILE_DIALOGS}")
self._close_gui()
ep = Episode(
pack=self.pack, task=task, mode=mode, surface=surface, skin=skin, theme=theme,
max_steps=int(max_steps or DEFAULT_MAX_STEPS[surface]),
)
if episode_id:
ep.episode_id = str(episode_id)[:64]
STORE.add(ep)
self._episode = ep
self._reward_mode = reward_mode
# GUI coordinates: screenshot pixels, or the 0-1000 grid many open VLMs (Qwen, Gemma)
# are trained to point with. Converted to pixels at the tool, so the editor never knows.
self._coordinates = coordinates
self._last_total = 0.0
self._set_surface(surface)
editor = f"/editor/?episode={ep.episode_id}&skin={skin}&theme={theme}" + (
"&layout=swap" if layout == "swap" else "") + (f"&os={file_dialog}" if file_dialog else "") + (
# A seeded reset also fixes where the file window opens and how it lists files.
f"&fs={rng.randrange(1 << 30)}" if seed is not None else "")
if skin in REAL_SKINS: # a real app behind the same interface: its own editor, set up per episode
from ..realapps import READY, available, get_backend
ok, why = available()
if not ok:
raise RuntimeError(f"{REAL_SKINS[skin][0]} is not available on this server: {why}")
backend = get_backend(skin, fonts_dir=self.pack.root / "fonts")
gt = self.pack.gt_doc(task)
if skin == "penpot": # a web app: opened in the env's browser
session = backend.new_episode(f"{task['task_id']} {ep.episode_id}", gt.width, gt.height, ep.assets)
GuiDriver.get().open(ep.episode_id, session.url, cookies=backend.client.cookies(),
ready=READY[skin], headers=backend.client.headers or None).result(300)
else: # a desktop app on its own X display: the GUI tools drive that screen instead of a page
session = backend.new_episode(skin, gt.width, gt.height, ep.assets, fonts=self.pack.fonts)
GuiDriver.get().attach_remote(ep.episode_id, session.client)
ep.realapp = (backend, session)
# the app lives behind the server (often in a sandbox): what a client can open is its live screen
editor = f"/api/episodes/{ep.episode_id}/screen.png"
elif surface == "gui":
GuiDriver.get().open(ep.episode_id, self_url() + editor + "&agent=1").result(45)
self._state = DesignState(
episode_id=ep.episode_id, step_count=0, task_id=task["task_id"], split=task["split"],
mode=mode, surface=surface, skin=skin, editor_url=editor,
)
public = os.environ.get("DESIGN_CANVAS_PUBLIC_URL", "").rstrip("/")
return self._observation(ep, public + editor)
def _observation(self, ep: Episode, editor: str) -> Observation:
info = ep.info()
meta = {
**info,
"instructions": instructions(ep.surface, ep.mode, info,
(VIEWPORT["width"], VIEWPORT["height"]),
coordinates=getattr(self, "_coordinates", "pixels"), skin=ep.skin),
"coordinates": getattr(self, "_coordinates", "pixels"),
"prompt_version": PROMPT_VERSION,
"skin": ep.skin,
"theme": ep.theme,
"layout": "swap" if "layout=swap" in editor else "default",
"file_dialog": next((d for d in FILE_DIALOGS if f"&os={d}" in editor), None),
"editor_url": editor,
"tools": sorted(self._surface_tools(ep.surface)),
}
if ep.skin in REAL_SKINS: # which real app the episode runs in (its tools and reward are the usual ones)
meta["app"] = {"skin": ep.skin, "name": REAL_SKINS[ep.skin][0], "real": True}
if ep.mode == "reference":
meta["reference_jpeg_b64"] = base64.b64encode(
self.pack.target_jpeg(ep.task, 768)).decode()
return Observation(done=False, reward=None, metadata=meta)
def _set_surface(self, surface: str) -> None:
# One FastMCP server per session; swap the visibility transform so
# list_tools only advertises this surface's tools.
self._mcp._transforms = [t for t in self._mcp._transforms
if not isinstance(t, Visibility)]
self._mcp.enable(tags={"common", surface}, only=True)
@staticmethod
def _surface_tools(surface: str) -> set[str]:
return {name for name, tags in _TOOL_TAGS.items() if tags & {"common", surface}}
def _close_gui(self):
if self._episode is not None and self._episode.surface == "gui":
try:
GuiDriver.get().close(self._episode.episode_id).result(10)
except Exception:
pass
if self._episode.realapp is not None and hasattr(self._episode.realapp[0], "release"):
GuiDriver.get().detach_remote(self._episode.episode_id)
backend, session = self._episode.realapp
self._episode.realapp = None # released once: the app (a desktop, a sandbox) is free again
backend.release(session.url)
def close(self):
self._close_gui()
super().close()
@property
def state(self) -> DesignState:
ep = self._episode
if ep is not None:
self._state.step_count = ep.step_count
self._state.n_elements = len(ep.doc.elements)
self._state.done = ep.done
if ep.result is not None:
self._state.reward = ep.result.total
self._state.grade = ep.result.as_dict()
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
tool = action.tool_name
# The budget counts actions, not looking: a harness that screenshots after every turn,
# or an agent that checks its render, spends nothing. Looking has its own generous cap
# so an agent can't loop on it forever.
if tool in OBSERVATION_TOOLS:
ep.observation_count += 1
if not ep.done and ep.observation_count >= OBSERVATION_FACTOR * ep.max_steps:
ep.submit("env", reason="observation limit")
else:
ep.step_count += 1
if not ep.done and ep.step_count >= ep.max_steps:
ep.submit("env", reason="step limit")
if ep.done and ep.result is not None:
obs.done = True
final = ep.result.total
obs.reward = final - self._last_total if self._reward_mode == "dense" else final
self._last_total = final
obs.metadata = {**(obs.metadata or {}), "grade": ep.result.as_dict(),
"episode_id": ep.episode_id}
elif self._reward_mode == "dense" and tool in EDIT_TOOLS:
total = ep.score().total
obs.reward = total - self._last_total
self._last_total = total
else:
obs.reward = 0.0
return obs
def step(self, action: Action, timeout_s: float | None = None, **kwargs: Any) -> Observation:
obs = super().step(action, timeout_s=timeout_s, **kwargs)
return self._after_step(action, obs)
async def step_async(self, action: Action, timeout_s: float | None = None,
**kwargs: Any) -> Observation:
obs = await super().step_async(action, timeout_s=timeout_s, **kwargs)
return self._after_step(action, obs)
def _step_impl(self, action: Action, timeout_s: float | None = None, **kwargs) -> Observation:
return Observation(done=False, reward=0.0, metadata={
"error": f"unsupported action {type(action).__name__}; use CallToolAction"})
# ============================================================ helpers
def _ep(self) -> Episode:
if self._episode is None:
raise ToolError("no task is loaded: call reset() or the start_task tool first")
return self._episode
def _edit(self, op: str, args: dict) -> str:
ep = self._ep()
if ep.done:
raise ToolError("the episode is finished; start a new task")
try:
result = ep.apply(op, {k: v for k, v in args.items() if v is not None}, "agent")
except ops.OpError as exc:
raise ToolError(str(exc)) from None
return _json(result)
# ============================================================ tools
def _register_common(self, mcp: FastMCP) -> None:
env = self
@mcp.tool(tags={"common"})
def start_task(
split: Annotated[str, Field(description="train, validation or test")] = "validation",
index: Annotated[int | None, Field(description="task index; random if omitted")] = None,
mode: Annotated[Literal["reference", "description"], Field(
description="reference: recreate a target image; description: build from a brief"
)] = "reference",
surface: Annotated[Literal["tools", "html"], Field(
description="tools: structured edit calls; html: write one page of markup"
)] = "tools",
) -> str:
"""Load a design task (for MCP clients without reset). Returns the instructions.
After starting, open the returned editor_url in a browser to watch the canvas live.
"""
obs = env.reset(split=split, index=index, mode=mode, surface=surface)
meta = obs.metadata
return _json({k: meta[k] for k in ("episode_id", "task_id", "instructions",
"editor_url", "tools")})
@mcp.tool(tags={"common"})
def get_task() -> str:
"""The current task: canvas size, assets (id, kind, aspect ratio), fonts used, and,
in description mode, the design brief."""
return _json(env._ep().info())
@mcp.tool(tags={"common"})
def view_reference() -> Image:
"""The target design you are recreating (reference mode only)."""
ep = env._ep()
if ep.mode != "reference":
raise ToolError("description mode has no target image; read the brief in get_task")
return Image(data=env.pack.target_jpeg(ep.task, 768), format="jpeg")
@mcp.tool(tags={"common"})
def submit() -> str:
"""Finish the episode and score the current canvas. Irreversible."""
ep = env._ep()
grade = ep.submit("agent")
return grade.report()
@mcp.tool(tags={"tools", "html"})
def view_canvas() -> Image:
"""Render your current canvas, exactly as it will be scored."""
ep = env._ep()
return Image(data=ep.render_jpeg(768), format="jpeg")
@mcp.tool(tags={"tools", "html"})
def view_assets() -> Image:
"""One image showing every asset of this task, labelled with its id, kind and
aspect ratio. Checkerboard = transparent."""
ep = env._ep()
font = env.pack.fonts.file_path("Montserrat", "regular")
return Image(data=contact_sheet(ep.task["assets"], ep.assets, font), format="png")
@mcp.tool(tags={"tools", "html"})
def list_fonts(query: Annotated[str | None, Field(
description="optional case-insensitive filter")] = None) -> str:
"""The font families available. Any other family is rejected."""
fams = env.pack.fonts.families()
if query:
fams = [f for f in fams if query.lower() in f.lower()]
return _json({"count": len(fams), "fonts": fams})
def _register_tools_surface(self, mcp: FastMCP) -> None:
env = self
tags = {"tools"}
@mcp.tool(tags=tags)
def list_elements() -> str:
"""Everything on the canvas, back to front, with ids, boxes and styles."""
ep = env._ep()
return ep.doc.summary() + "\n\n" + _json(ep.doc.public()["elements"])
@mcp.tool(tags=tags)
def add_image(
asset_id: Annotated[str, Field(description="asset id, e.g. a3")],
x: Num | None = None, y: Num | None = None,
width: Num | None = None, height: Num | None = None,
angle: Annotated[Num, Field(description="degrees clockwise")] = 0,
opacity: Annotated[Num, Field(ge=0, le=1)] = 1,
) -> str:
"""Place an asset on top of the canvas. Give width or height alone to keep the
asset's aspect ratio; omit both for a default size. Returns the new element id."""
return env._edit("add_image", dict(asset_id=asset_id, x=x, y=y, width=width,
height=height, angle=angle, opacity=opacity))
@mcp.tool(tags=tags)
def add_text(
text: Annotated[str, Field(description="the copy; use \\n for line breaks")],
x: Num | None = None, y: Num | None = None,
width: Annotated[Num | None, Field(
description="box width for alignment; omit to fit the text")] = None,
font_family: str = "Montserrat",
font_size: Annotated[Num, Field(description="px")] = 48,
color: Annotated[str, Field(description="#rrggbb")] = "#000000",
align: Literal["left", "center", "right"] = "left",
bold: bool = False, italic: bool = False, underline: bool = False,
uppercase: bool = False,
line_height: Annotated[Num, Field(description="multiple of font size")] = 1.0,
letter_spacing: Annotated[Num, Field(description="px added after each letter")] = 0,
angle: Num = 0, opacity: Annotated[Num, Field(ge=0, le=1)] = 1,
runs: Annotated[list[dict] | None, Field(
description="optional styled pieces [{text, color?, bold?, italic?}] that "
"concatenate to the text")] = None,
) -> str:
"""Add a text box on top of the canvas. Height follows the number of lines."""
return env._edit("add_text", dict(
text=text, x=x, y=y, width=width, font_family=font_family, font_size=font_size,
color=color, align=align, bold=bold, italic=italic, underline=underline,
uppercase=uppercase, line_height=line_height, letter_spacing=letter_spacing,
angle=angle, opacity=opacity, runs=runs))
@mcp.tool(tags=tags)
def add_shape(
shape: Literal["rect", "ellipse"] = "rect",
x: Num | None = None, y: Num | None = None,
width: Num | None = None, height: Num | None = None,
fill: Annotated[str, Field(description="#rrggbb or none")] = "#d9d9d9",
stroke: str | None = None, stroke_width: Num = 0,
radius: Annotated[Num, Field(description="corner radius (rect)")] = 0,
angle: Num = 0, opacity: Annotated[Num, Field(ge=0, le=1)] = 1,
) -> str:
"""Draw a rectangle or ellipse on top of the canvas."""
return env._edit("add_shape", dict(
shape=shape, x=x, y=y, width=width, height=height, fill=fill, stroke=stroke,
stroke_width=stroke_width, radius=radius, angle=angle, opacity=opacity))
@mcp.tool(tags=tags)
def update_element(
element_id: str,
properties: Annotated[dict, Field(
description="fields to change, e.g. {\"x\": 40, \"font_size\": 72, "
"\"color\": \"#ff0000\"}. Any field from add_image/add_text/"
"add_shape, plus visible, locked, flip_x, flip_y")],
) -> str:
"""Change an element's position, size, rotation or style."""
if not isinstance(properties, dict):
raise ToolError("properties must be an object")
return env._edit("update_element", {"element_id": element_id, **properties})
@mcp.tool(tags=tags)
def delete_element(element_id: str) -> str:
"""Remove an element."""
return env._edit("delete_element", {"element_id": element_id})
@mcp.tool(tags=tags)
def duplicate_element(element_id: str, dx: Num = 20, dy: Num = 20) -> str:
"""Copy an element just above itself, offset by (dx, dy)."""
return env._edit("duplicate_element", {"element_id": element_id, "dx": dx, "dy": dy})
@mcp.tool(tags=tags)
def reorder_element(
element_id: str,
to: Annotated[str, Field(
description="front, back, forward, backward, or a layer index (0 = back)")],
) -> str:
"""Change an element's place in the stacking order."""
return env._edit("reorder_element", {"element_id": element_id, "to": to})
@mcp.tool(tags=tags)
def align_elements(
element_ids: list[str],
alignment: Literal["left", "center", "right", "top", "middle", "bottom"],
relative_to: Literal["canvas", "selection"] = "canvas",
) -> str:
"""Align elements to the canvas or to each other."""
return env._edit("align_elements", {"element_ids": element_ids,
"alignment": alignment,
"relative_to": relative_to})
@mcp.tool(tags=tags)
def set_background(color: Annotated[str, Field(description="#rrggbb")]) -> str:
"""Set the canvas background colour."""
return env._edit("set_background", {"color": color})
@mcp.tool(tags=tags)
def undo() -> str:
"""Undo the last change."""
ok = env._ep().undo("agent")
return "undone" if ok else "nothing to undo"
@mcp.tool(tags=tags)
def redo() -> str:
"""Redo the last undone change."""
ok = env._ep().redo("agent")
return "redone" if ok else "nothing to redo"
def _register_html_surface(self, mcp: FastMCP) -> None:
env = self
@mcp.tool(tags={"html"})
def write_html(html: Annotated[str, Field(
description='the full page: <div id="canvas" ...> with one child per element')],
) -> str:
"""Replace the canvas with this HTML. Returns what was parsed and any warnings."""
ep = env._ep()
if ep.done:
raise ToolError("the episode is finished; start a new task")
try:
document, warnings = html_to_doc(html, ep.doc.width, ep.doc.height)
ep.apply("replace_document", {"document": document}, "agent")
except (HTMLProfileError, ops.OpError, ValueError) as exc:
raise ToolError(f"HTML rejected: {exc}") from None
out = {"elements": len(ep.doc.elements), "summary": ep.doc.summary()}
if warnings:
out["warnings"] = sorted(set(warnings))
return _json(out)
@mcp.tool(tags={"html"})
def read_html() -> str:
"""The current canvas as HTML in the profile, ready to edit and write back."""
return env._ep().html()
def _register_gui_surface(self, mcp: FastMCP) -> None:
env = self
tags = {"gui"}
driver = GuiDriver.get
def gui_ep() -> Episode:
ep = env._ep()
if ep.surface != "gui":
raise ToolError("GUI actions need reset(surface='gui')")
if ep.done:
raise ToolError("the episode is finished")
return ep
def mods(text: str | None) -> list[str]:
return [m for m in (text or "").split("+") if m]
def px(c) -> tuple[int, int]:
"""The agent's [x, y] as screenshot pixels."""
x, y = int(c[0]), int(c[1])
if getattr(env, "_coordinates", "pixels") == "normalized":
x = round(x * (VIEWPORT["width"] - 1) / 1000)
y = round(y * (VIEWPORT["height"] - 1) / 1000)
return x, y
@mcp.tool(tags=tags)
async def screenshot() -> Image:
"""Capture the editor window as the next observation."""
ep = env._ep()
return Image(data=await driver().screenshot(ep.episode_id), format="png")
@mcp.tool(tags=tags)
async def left_click(coordinate: Coord, text: Annotated[str | None, Field(
description="modifier keys held during the click, e.g. shift or ctrl")] = None,
) -> str:
"""Left-click at [x, y]."""
ep = gui_ep()
await driver().click(ep.episode_id, *px(coordinate), modifiers=mods(text))
return f"clicked {coordinate}"
@mcp.tool(tags=tags)
async def double_click(coordinate: Coord) -> str:
"""Double-click at [x, y] (edits text, opens a field)."""
ep = gui_ep()
await driver().click(ep.episode_id, *px(coordinate), clicks=2)
return f"double-clicked {coordinate}"
@mcp.tool(tags=tags)
async def triple_click(coordinate: Coord) -> str:
"""Triple-click at [x, y] (selects all text in a field)."""
ep = gui_ep()
await driver().click(ep.episode_id, *px(coordinate), clicks=3)
return f"triple-clicked {coordinate}"
@mcp.tool(tags=tags)
async def right_click(coordinate: Coord) -> str:
"""Right-click at [x, y] (context menu)."""
ep = gui_ep()
await driver().click(ep.episode_id, *px(coordinate), button="right")
return f"right-clicked {coordinate}"
@mcp.tool(tags=tags)
async def mouse_move(coordinate: Coord) -> str:
"""Move the pointer to [x, y] without clicking (shows hover states)."""
ep = gui_ep()
await driver().move(ep.episode_id, *px(coordinate))
return f"moved to {coordinate}"
@mcp.tool(tags=tags)
async def left_click_drag(start_coordinate: Coord, coordinate: Coord,
text: str | None = None) -> str:
"""Press at start_coordinate, drag to coordinate, release. Moves, resizes and
rotates elements via their handles, and drops assets onto the canvas."""
ep = gui_ep()
await driver().drag(ep.episode_id, *px(start_coordinate), *px(coordinate),
modifiers=mods(text))
return f"dragged {start_coordinate} -> {coordinate}"
@mcp.tool(tags=tags)
async def scroll(coordinate: Coord,
scroll_direction: Literal["up", "down", "left", "right"],
scroll_amount: Annotated[int, Field(ge=1, le=20)] = 3,
text: str | None = None) -> str:
"""Scroll at [x, y]. Hold ctrl (text="ctrl") to zoom the canvas."""
ep = gui_ep()
step = 100 * scroll_amount
dx = {"left": -step, "right": step}.get(scroll_direction, 0)
dy = {"up": -step, "down": step}.get(scroll_direction, 0)
await driver().scroll(ep.episode_id, *px(coordinate), dx, dy, modifiers=mods(text))
return f"scrolled {scroll_direction} {scroll_amount}"
@mcp.tool(name="type", tags=tags)
async def type_text(text: str) -> str:
"""Type text at the keyboard focus."""
ep = gui_ep()
await driver().type_text(ep.episode_id, text)
return f"typed {len(text)} characters"
@mcp.tool(tags=tags)
async def key(keys: Annotated[str | list[str], Field(
description="a key or chord, e.g. enter, escape, delete, ctrl+z, shift+up; "
"a list presses each in turn")],
) -> str:
"""Press a key or key combination."""
ep = gui_ep()
sequence = _key_sequence(keys)
if not sequence:
raise ToolError("keys is empty")
for combo in sequence:
await driver().key(ep.episode_id, combo)
return f"pressed {' then '.join(sequence)}"
@mcp.tool(tags=tags)
async def wait(duration: Annotated[Num, Field(ge=0, le=10)] = 1) -> str:
"""Wait for the given seconds."""
ep = gui_ep()
await driver().wait(ep.episode_id, float(duration))
return f"waited {duration}s"
_TOOL_TAGS: dict[str, set[str]] = {
"start_task": {"common"}, "get_task": {"common"}, "view_reference": {"common"},
"submit": {"common"},
"view_canvas": {"tools", "html"}, "view_assets": {"tools", "html"},
"list_fonts": {"tools", "html"},
**{n: {"tools"} for n in ("list_elements", "add_image", "add_text", "add_shape",
"update_element", "delete_element", "duplicate_element",
"reorder_element", "align_elements", "set_background", "undo",
"redo")},
"write_html": {"html"}, "read_html": {"html"},
**{n: {"gui"} for n in ("screenshot", "left_click", "double_click", "triple_click",
"right_click", "mouse_move", "left_click_drag", "scroll", "type",
"key", "wait")},
}
|