commandagi 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
commandagi/client.py ADDED
@@ -0,0 +1,504 @@
1
+ """CommandAGI Python SDK — launch cloud computers and 3D robot simulations and control them.
2
+
3
+ A developer with an API key can spin up a real environment (a 3D physics world with a robot, or an
4
+ Ubuntu desktop), stream its observations (the robot's head camera / the screen), and send actions —
5
+ the same control plane the web app uses, wrapped in a small, Gym-flavored client.
6
+
7
+ Quickstart (robot testing):
8
+
9
+ from commandagi import CommandAGI
10
+
11
+ cagi = CommandAGI(api_key="cagi_...") # or set COMMANDAGI_API_KEY
12
+ with cagi.launch("simulation/warehouse") as world:
13
+ obs = world.observe() # JPEG bytes from the robot's head camera
14
+ for _ in range(10):
15
+ obs = world.step("move", speed=0.8) # drive forward, get the next frame
16
+ world.reset() # back to the episode start pose
17
+ # leaving the `with` block stops the world and releases the cloud VM
18
+
19
+ The control vocabulary for a computer world: click (x, y), type (text), key (key), move (x, y),
20
+ scroll. Robots in a (morphology-agnostic) simulator are driven by a small GENERIC vocabulary —
21
+ ``ctrl`` (set actuator targets), ``actuator`` (one named actuator), ``ik`` (inverse-kinematics to a
22
+ Cartesian target), ``trajectory`` (waypoints), ``describe`` — see :class:`World`'s generic-control
23
+ methods. Spin up your own simulator instance with :meth:`CommandAGI.launch_sim`.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import json
29
+ import os
30
+ import threading
31
+ import time
32
+ from typing import Iterator, Optional
33
+
34
+ import requests
35
+ import websocket # from the `websocket-client` package
36
+
37
+ DEFAULT_BASE_URL = "https://api.commandagi.com"
38
+
39
+ # The built-in 3D simulation worlds. Each is a scene a mobile robot is dropped into.
40
+ SIMULATIONS = ["simulation/warehouse", "simulation/house-on-fire", "simulation/school"]
41
+ COMPUTERS = ["computer/software-engineer", "computer/robots-engineer", "computer/video-professional"]
42
+
43
+
44
+ class CommandAGIError(Exception):
45
+ """Any SDK-level error (HTTP failure, launch rejected, timeout)."""
46
+
47
+
48
+ class World:
49
+ """A live world you control. Created by :meth:`CommandAGI.launch`; not constructed directly.
50
+
51
+ Observations are the latest sensor frame (the robot's head camera for sims, the screen for
52
+ computers) as encoded image bytes. Actions are sent over the same realtime channel the web UI
53
+ uses. Use it as a context manager so the cloud VM is always released.
54
+ """
55
+
56
+ def __init__(self, client: "CommandAGI", thread_id: str, device_id: str, kind: str):
57
+ self._client = client
58
+ self.thread_id = thread_id
59
+ self.device_id = device_id
60
+ self.kind = kind # "robot" | "computer"
61
+ self._frames: dict[str, bytes] = {}
62
+ self._latest: Optional[bytes] = None
63
+ self._descriptions: dict[str, dict] = {} # robot_id -> last describe payload
64
+ self._results: dict[str, dict] = {} # requestId -> action_result payload (answering controls)
65
+ self._lock = threading.Lock()
66
+ self._open = threading.Event()
67
+ self._closed = False
68
+ self._ws = websocket.WebSocketApp(
69
+ self._ws_url(),
70
+ on_open=self._on_open,
71
+ on_message=self._on_message,
72
+ )
73
+ # ping_interval keeps the long-lived channel alive; reconnect transparently re-establishes it
74
+ # (and _on_open re-takes control) if the edge drops it mid-thread.
75
+ self._thread = threading.Thread(
76
+ target=lambda: self._ws.run_forever(ping_interval=20, ping_timeout=10, reconnect=3),
77
+ daemon=True,
78
+ )
79
+ self._thread.start()
80
+ if not self._open.wait(timeout=15):
81
+ raise CommandAGIError("could not open the realtime control channel")
82
+
83
+ def _on_open(self, _ws) -> None:
84
+ # Re-take manual control on every (re)connect so our actions always drive the device.
85
+ self._open.set()
86
+ try:
87
+ self._send({"t": "remote.request", "deviceId": self.device_id, "on": True})
88
+ except Exception:
89
+ pass
90
+
91
+ # ── realtime plumbing ────────────────────────────────────────────────────
92
+ def _ws_url(self) -> str:
93
+ base = self._client.base_url.replace("https://", "wss://").replace("http://", "ws://")
94
+ return f"{base}/rt/thread/{self.thread_id}?role=owner&name=sdk"
95
+
96
+ def _on_message(self, _ws, raw: str) -> None:
97
+ try:
98
+ m = json.loads(raw)
99
+ except (ValueError, TypeError):
100
+ return
101
+ if m.get("t") == "frame" and isinstance(m.get("url"), str) and m["url"].startswith("data:"):
102
+ try:
103
+ data = base64.b64decode(m["url"].split(",", 1)[1])
104
+ except Exception:
105
+ return
106
+ with self._lock:
107
+ self._frames[m.get("channelId", "default")] = data
108
+ self._latest = data
109
+ elif m.get("t") in ("describe", "description") and isinstance(m.get("description"), dict):
110
+ # Best-effort: the runtime may echo a world/robot description back over the channel.
111
+ with self._lock:
112
+ self._descriptions[m.get("robotId", "")] = m["description"]
113
+ elif m.get("t") == "action_result" and isinstance(m.get("requestId"), str):
114
+ # An answering control (describe / scene_graph / pick / transform …) — match by requestId.
115
+ with self._lock:
116
+ self._results[m["requestId"]] = m.get("result")
117
+
118
+ def _send(self, msg: dict) -> None:
119
+ self._ws.send(json.dumps(msg))
120
+
121
+ def request(self, action: str, *, timeout: float = 5.0, **payload):
122
+ """Send an *answering* control action and block for the runtime's result (matched by a
123
+ requestId via the platform's action_result relay). Used by :meth:`describe` / Gym proprioception.
124
+ Returns the result dict, or raises on timeout."""
125
+ request_id = f"req-{threading.get_ident()}-{int(time.time()*1000)}"
126
+ with self._lock:
127
+ self._results.pop(request_id, None)
128
+ self._send({"t": "control", "deviceId": self.device_id, "action": action, "payload": payload, "requestId": request_id})
129
+ deadline = time.time() + timeout
130
+ while time.time() < deadline:
131
+ with self._lock:
132
+ if request_id in self._results:
133
+ return self._results.pop(request_id)
134
+ time.sleep(0.05)
135
+ raise CommandAGIError(f"control '{action}' timed out after {timeout}s")
136
+
137
+ # ── observations ─────────────────────────────────────────────────────────
138
+ def observe(self, *, fresh: bool = False, timeout: float = 30.0) -> bytes:
139
+ """Return the latest observation as encoded image bytes (JPEG for sims, PNG for computers).
140
+
141
+ Blocks until a frame is available. With ``fresh=True``, waits for a frame that arrives
142
+ *after* this call (useful right after an action).
143
+ """
144
+ if fresh:
145
+ with self._lock:
146
+ self._latest = None
147
+ deadline = time.time() + timeout
148
+ while time.time() < deadline:
149
+ with self._lock:
150
+ if self._latest is not None:
151
+ return self._latest
152
+ time.sleep(0.1)
153
+ raise CommandAGIError("no observation within timeout — is the world still live?")
154
+
155
+ def observe_array(self, **kw):
156
+ """Observe and decode to a numpy HxWx3 uint8 array (requires Pillow + numpy)."""
157
+ try:
158
+ import io
159
+
160
+ import numpy as np
161
+ from PIL import Image
162
+ except ImportError as e: # pragma: no cover
163
+ raise CommandAGIError("observe_array needs `pillow` and `numpy` installed") from e
164
+ return np.asarray(Image.open(io.BytesIO(self.observe(**kw))).convert("RGB"))
165
+
166
+ def stream(self) -> Iterator[bytes]:
167
+ """Yield observations as they arrive (roughly the device frame rate)."""
168
+ last = object()
169
+ while True:
170
+ with self._lock:
171
+ cur = self._latest
172
+ if cur is not None and cur is not last:
173
+ last = cur
174
+ yield cur
175
+ time.sleep(0.05)
176
+
177
+ # ── actions ──────────────────────────────────────────────────────────────
178
+ def act(self, action: str, **payload) -> None:
179
+ """Send a control action without waiting. E.g. ``act("move", speed=0.8)``."""
180
+ self._send({"t": "control", "deviceId": self.device_id, "action": action, "payload": payload})
181
+
182
+ def step(self, action: str, *, settle: float = 0.8, **payload) -> bytes:
183
+ """Send an action, let the world advance ``settle`` seconds, and return the next observation."""
184
+ self.act(action, **payload)
185
+ time.sleep(settle)
186
+ return self.observe(fresh=True)
187
+
188
+ def reset(self, *, settle: float = 1.0) -> bytes:
189
+ """Reset the episode (robot back to its start pose) and return the first observation."""
190
+ self.act("reset")
191
+ time.sleep(settle)
192
+ return self.observe(fresh=True)
193
+
194
+ # ── generic (morphology-agnostic) robot control ──────────────────────────
195
+ # The simulator is morphology-agnostic: a robot is described by its actuators and sites, and is
196
+ # driven by a small GENERIC vocabulary — ctrl / actuator / ik / trajectory / describe. There is
197
+ # deliberately NO drive / gripper here; "move forward" or "close the gripper" is just particular
198
+ # actuator targets on a particular morphology. All of these go through the same `control` channel
199
+ # as :meth:`act`, and address one robot within a (possibly multi-robot) device via ``robot_id``.
200
+ def ctrl(self, targets: dict, robot_id: str = "") -> None:
201
+ """Set actuator targets directly: ``{actuator_name: value, ...}``.
202
+
203
+ ``targets`` maps named actuators (joints/motors as exposed by :meth:`describe`) to their
204
+ target value. This is the lowest-level, fully generic control primitive.
205
+ """
206
+ self.act("ctrl", targets=targets, robotId=robot_id)
207
+
208
+ def actuator(self, name: str, value: float, robot_id: str = "") -> None:
209
+ """Set a single named actuator to ``value`` (sugar over :meth:`ctrl`)."""
210
+ self.act("actuator", name=name, value=value, robotId=robot_id)
211
+
212
+ def ik(self, target, site: Optional[str] = None, relative: bool = False, robot_id: str = "") -> None:
213
+ """Drive an end-effector ``site`` to a Cartesian ``target`` ``[x, y, z]`` via inverse kinematics.
214
+
215
+ ``site`` names the body/site to move (default: the robot's primary end-effector). With
216
+ ``relative=True`` the target is an offset from the site's current pose rather than absolute
217
+ world coordinates.
218
+ """
219
+ self.act("ik", target=list(target), site=site, relative=relative, robotId=robot_id)
220
+
221
+ def trajectory(self, waypoints, robot_id: str = "") -> None:
222
+ """Follow a sequence of ``waypoints`` (each an actuator-target dict or a Cartesian point).
223
+
224
+ Waypoints are interpreted by the runtime in order; this is the generic way to express a
225
+ multi-step motion plan for any morphology.
226
+ """
227
+ self.act("trajectory", waypoints=list(waypoints), robotId=robot_id)
228
+
229
+ def describe(self, robot_id: str = "") -> dict:
230
+ """Return a description of the world / robot(s): morphology, actuators, sites, objects.
231
+
232
+ Best-effort. The runtime answers a ``describe`` request over the thread control channel,
233
+ but there is currently no *synchronous* describe HTTP endpoint, so this issues the describe
234
+ action and then tries to read a description the runtime echoes back over the realtime
235
+ channel (see ``_descriptions``). If none arrives in time, returns ``{}`` — callers (e.g. the
236
+ agent runner) should also be able to obtain a description via the ``/agent/robot-act`` flow,
237
+ which inspects the live world server-side.
238
+ """
239
+ # Preferred: the `describe` action answers with the full world description (incl. live joint
240
+ # pos/vel) via the action_result relay. Falls back to the legacy echo path if unavailable.
241
+ try:
242
+ res = self.request("describe", robotId=robot_id)
243
+ if isinstance(res, dict) and res.get("robots") is not None:
244
+ return res
245
+ except CommandAGIError:
246
+ pass
247
+ with self._lock:
248
+ self._descriptions.pop(robot_id, None)
249
+ self.act("describe", robotId=robot_id)
250
+ deadline = time.time() + 5.0
251
+ while time.time() < deadline:
252
+ with self._lock:
253
+ desc = self._descriptions.get(robot_id)
254
+ if desc is not None:
255
+ return desc
256
+ time.sleep(0.1)
257
+ return {}
258
+
259
+ # ── lifecycle ──────────────────────────────────────────────────────────────
260
+ def close(self) -> None:
261
+ """Stop the world and release the cloud VM. Safe to call more than once."""
262
+ self._closed = True
263
+ try:
264
+ self._client._stop(self.thread_id)
265
+ finally:
266
+ try:
267
+ self._ws.close() # stops run_forever's reconnect loop
268
+ except Exception:
269
+ pass
270
+
271
+ def __enter__(self) -> "World":
272
+ return self
273
+
274
+ def __exit__(self, *_exc) -> None:
275
+ self.close()
276
+
277
+
278
+ class SimInstance:
279
+ """A live simulator instance — a hosted morphology-agnostic 3D world you can populate with
280
+ robots, share, and (with the agent runner) drive autonomously.
281
+
282
+ Created by :meth:`CommandAGI.launch_sim` (or rehydrated via :meth:`CommandAGI.get_sim`); not
283
+ constructed directly. A sim instance owns one realtime **thread** (``thread_id``); robots you
284
+ :meth:`join_robot` become devices on that thread. Use :meth:`grant` to let other users view it
285
+ or launch their own robots into it.
286
+ """
287
+
288
+ def __init__(self, client: "CommandAGI", instance_id: str, thread_id: str,
289
+ world_id: str = "", view: Optional[dict] = None):
290
+ self._client = client
291
+ self.id = instance_id
292
+ self.thread_id = thread_id
293
+ self.world_id = world_id
294
+ self._view = view or {}
295
+
296
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
297
+ return f"SimInstance(id={self.id!r}, thread_id={self.thread_id!r})"
298
+
299
+ def view(self) -> dict:
300
+ """Fetch the current instance view (metadata + attached devices) from the API."""
301
+ self._view = self._client._get(f"/sims/{self.id}")
302
+ return self._view
303
+
304
+ def devices(self) -> list:
305
+ """The robot devices currently attached to this instance (from the latest :meth:`view`)."""
306
+ return self.view().get("devices", [])
307
+
308
+ def join_robot(self, kind: str = "rover") -> dict:
309
+ """Spawn a robot of ``kind`` into the world and return ``{robotId, deviceId, threadId}``.
310
+
311
+ The robot becomes a device on this instance's thread; address it later via its ``deviceId``
312
+ (for control) and ``robotId`` (to target a specific robot within a multi-robot device).
313
+ """
314
+ res = self._client._post(f"/sims/{self.id}/join", {"kind": kind})
315
+ return {
316
+ "robotId": res.get("robotId"),
317
+ "deviceId": res.get("deviceId"),
318
+ "threadId": res.get("threadId", self.thread_id),
319
+ }
320
+
321
+ def grant(self, subject_id: str, capability: str = "operator", subject_type: str = "user") -> dict:
322
+ """Grant ``subject_id`` a capability on this instance.
323
+
324
+ ``capability``: ``"viewer"`` (may watch the stream) or ``"operator"`` (may also launch
325
+ robots into the world). ``subject_type`` is usually ``"user"``.
326
+ """
327
+ return self._client._post(
328
+ f"/sims/{self.id}/grants",
329
+ {"subjectType": subject_type, "subjectId": subject_id, "capability": capability},
330
+ )
331
+
332
+ def stop(self) -> None:
333
+ """Stop the simulator instance and release its resources. Safe to call more than once."""
334
+ try:
335
+ self._client._post(f"/sims/{self.id}/stop")
336
+ except CommandAGIError:
337
+ pass
338
+
339
+ def __enter__(self) -> "SimInstance":
340
+ return self
341
+
342
+ def __exit__(self, *_exc) -> None:
343
+ self.stop()
344
+
345
+
346
+ class CommandAGI:
347
+ """Client for the CommandAGI API. Authenticate with an API key (create one in the dashboard or
348
+ via ``POST /me/api-keys`` with an ``operator`` scope)."""
349
+
350
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
351
+ self.api_key = api_key or os.environ.get("COMMANDAGI_API_KEY")
352
+ if not self.api_key:
353
+ raise CommandAGIError("api_key is required (pass it or set COMMANDAGI_API_KEY)")
354
+ self.base_url = (base_url or os.environ.get("COMMANDAGI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
355
+
356
+ # ── http ─────────────────────────────────────────────────────────────────
357
+ def _headers(self) -> dict:
358
+ return {"authorization": f"Bearer {self.api_key}", "content-type": "application/json"}
359
+
360
+ def _post(self, path: str, body: Optional[dict] = None) -> dict:
361
+ r = requests.post(self.base_url + path, headers=self._headers(), json=body or {}, timeout=60)
362
+ if not r.ok:
363
+ raise CommandAGIError(f"POST {path} -> {r.status_code}: {r.text}")
364
+ return r.json() if r.text else {}
365
+
366
+ def _get(self, path: str) -> dict:
367
+ r = requests.get(self.base_url + path, headers=self._headers(), timeout=60)
368
+ if not r.ok:
369
+ raise CommandAGIError(f"GET {path} -> {r.status_code}: {r.text}")
370
+ return r.json()
371
+
372
+ def _stop(self, thread_id: str) -> None:
373
+ try:
374
+ self._post(f"/threads/{thread_id}/stop")
375
+ except CommandAGIError:
376
+ pass
377
+
378
+ # ── public ───────────────────────────────────────────────────────────────
379
+ def launch(self, snapshot: str, *, wait: bool = True, timeout: float = 600.0) -> World:
380
+ """Launch a world and return it (live, ready to control).
381
+
382
+ ``snapshot`` is a catalog id — a 3D sim (``simulation/warehouse``) or a computer
383
+ (``computer/software-engineer``). With ``wait=True`` (default) this blocks until the world's
384
+ device is streaming. The world has NO agent — you drive it. Always ``close()`` it (or use a
385
+ ``with`` block) to release the VM.
386
+ """
387
+ is_robot = snapshot.startswith("simulation/") or snapshot.startswith("physical/")
388
+ thread_id = self._post("/worlds", {"title": snapshot})["threadId"]
389
+ try:
390
+ res = self._post(f"/threads/{thread_id}/{'robots' if is_robot else 'computers'}", {"snapshotId": snapshot})
391
+ except CommandAGIError:
392
+ self._stop(thread_id)
393
+ raise
394
+ if res.get("status") != "granted":
395
+ self._stop(thread_id)
396
+ raise CommandAGIError(f"launch was not granted: {res}")
397
+ world = World(self, thread_id, res["deviceId"], "robot" if is_robot else "computer")
398
+ if wait:
399
+ self._wait_until_live(thread_id, timeout)
400
+ return world
401
+
402
+ def connect_world(self, thread_id: str, device_id: str, kind: str = "robot") -> World:
403
+ """Open a control channel to an *existing* device on a thread and return a :class:`World`.
404
+
405
+ Unlike :meth:`launch`, this does not provision anything — it attaches to a device that already
406
+ exists (e.g. a robot you added to a :class:`SimInstance` via
407
+ :meth:`SimInstance.join_robot`). Calling ``world.close()`` on it stops the whole thread, so
408
+ prefer :meth:`SimInstance.stop` for lifecycle and use this purely to observe/control.
409
+ """
410
+ return World(self, thread_id, device_id, kind)
411
+
412
+ def _web_url(self) -> str:
413
+ # api.commandagi.com → commandagi.com ; api-dev.commandagi.com → dev.commandagi.com
414
+ host = self.base_url.split("://", 1)[-1]
415
+ if host.startswith("api-dev."):
416
+ return "https://dev.commandagi.com"
417
+ if host.startswith("api."):
418
+ return "https://commandagi.com"
419
+ return self.base_url
420
+
421
+ def register_robot(self, name: str = "my-robot"):
422
+ """Register YOUR robot as a device and return a :class:`RobotBridge` to stream it.
423
+
424
+ Creates an agentless world + a bring-your-own robot device, then hands you a bridge: call
425
+ ``bridge.run(camera=..., on_action=...)`` to publish your robot's camera and receive control
426
+ actions. Watch/drive it at ``bridge.thread_url``.
427
+ """
428
+ from .bridge import RobotBridge
429
+
430
+ thread_id = self._post("/worlds", {"title": name})["threadId"]
431
+ try:
432
+ dev = self._post(f"/threads/{thread_id}/connect-device", {"kind": "robot", "name": name})
433
+ except CommandAGIError:
434
+ self._stop(thread_id)
435
+ raise
436
+ return RobotBridge(
437
+ dev["controlUrl"],
438
+ dev["token"],
439
+ dev["deviceId"],
440
+ thread_id=thread_id,
441
+ thread_url=f"{self._web_url()}/world/{thread_id}",
442
+ client=self,
443
+ )
444
+
445
+ # ── simulator instances ───────────────────────────────────────────────────
446
+ def launch_sim(self, scene: str = "the-matrix", visibility: str = "private",
447
+ title: Optional[str] = None) -> SimInstance:
448
+ """Launch a new simulator instance for ``scene`` and return a :class:`SimInstance`.
449
+
450
+ ``visibility`` is ``"private"`` | ``"unlisted"`` | ``"public"``. The instance starts empty —
451
+ add robots with :meth:`SimInstance.join_robot`. Use it as a context manager (or call
452
+ :meth:`SimInstance.stop`) to release it.
453
+ """
454
+ body: dict = {"scene": scene, "visibility": visibility}
455
+ if title is not None:
456
+ body["title"] = title
457
+ res = self._post("/sims", body)
458
+ return SimInstance(
459
+ self,
460
+ instance_id=res.get("instanceId") or res.get("id", ""),
461
+ thread_id=res.get("threadId", ""),
462
+ world_id=res.get("worldId", ""),
463
+ )
464
+
465
+ def sims(self) -> list:
466
+ """List the simulator instances visible to you (``[{id, title, scene, visibility, ...}]``)."""
467
+ return self._get("/sims").get("sims", [])
468
+
469
+ def get_sim(self, sim_id: str) -> SimInstance:
470
+ """Rehydrate a :class:`SimInstance` for an existing instance ``sim_id``."""
471
+ view = self._get(f"/sims/{sim_id}")
472
+ return SimInstance(
473
+ self,
474
+ instance_id=view.get("id", sim_id),
475
+ thread_id=view.get("threadId", ""),
476
+ world_id=view.get("worldId", ""),
477
+ view=view,
478
+ )
479
+
480
+ def robot_act(self, goal: str, devices: list, *, model: Optional[str] = None,
481
+ history: Optional[list] = None) -> dict:
482
+ """Ask the platform's agent for the next robot tool calls toward ``goal``.
483
+
484
+ ``devices`` is a list of ``{deviceId, world, camera}`` — one entry per robot device, where
485
+ ``world`` is that device's description (from :meth:`World.describe`) and ``camera`` is a
486
+ recent frame (a ``data:`` URL or base64 string). Returns
487
+ ``{reasoning, calls: [{tool, deviceId, robotId, ...}]}``. This is the building block the
488
+ :class:`~commandagi.agent.RobotAgent` runner loops over.
489
+ """
490
+ body: dict = {"goal": goal, "devices": devices}
491
+ if model is not None:
492
+ body["model"] = model
493
+ if history is not None:
494
+ body["history"] = history
495
+ return self._post("/agent/robot-act", body)
496
+
497
+ def _wait_until_live(self, thread_id: str, timeout: float) -> None:
498
+ deadline = time.time() + timeout
499
+ while time.time() < deadline:
500
+ devices = self._get(f"/threads/{thread_id}").get("devices", [])
501
+ if any(d.get("status") == "live" for d in devices):
502
+ return
503
+ time.sleep(3)
504
+ # Not fatal: the first observe() will surface a clearer timeout if nothing ever streams.
commandagi/gym_env.py ADDED
@@ -0,0 +1,145 @@
1
+ """A Gymnasium environment over a CommandAGI sim — drive a robot in a hosted 3D physics world with the
2
+ standard ``reset`` / ``step`` RL loop.
3
+
4
+ The action space is the robot's actuators (a ``Box`` over each actuator's ctrlrange, read from
5
+ :meth:`World.describe`); the observation is the head-camera image plus proprioception (joint
6
+ positions/velocities). Rewards are user-supplied (a callback over the description), since "reward" is
7
+ task-specific — the env gives you the faithful physics + observations and you decide the objective.
8
+
9
+ from commandagi import CommandAGI
10
+ from commandagi.gym_env import CommandAGIEnv
11
+
12
+ cagi = CommandAGI(api_key="cagi_...")
13
+ with cagi.launch("simulation/warehouse") as world:
14
+ env = CommandAGIEnv(world, reward_fn=lambda d: 0.0)
15
+ obs, info = env.reset()
16
+ for _ in range(100):
17
+ obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
18
+
19
+ Requires ``gymnasium`` and ``numpy`` (``pip install "commandagi[gym]"``). Designed to pair with
20
+ :mod:`commandagi.lerobot` to record demonstrations/rollouts as LeRobot-3.0 datasets.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import time
25
+ from typing import Any, Callable, Optional
26
+
27
+ try:
28
+ import gymnasium as gym
29
+ import numpy as np
30
+ from gymnasium import spaces
31
+ except ImportError as e: # pragma: no cover
32
+ raise ImportError("commandagi.gym_env needs `gymnasium` and `numpy` — pip install 'commandagi[gym]'") from e
33
+
34
+ from .client import CommandAGIError, World
35
+
36
+
37
+ def _actuators(desc: dict, robot_id: str) -> list[dict]:
38
+ """Flatten the actuators of the target robot (or the first robot) from a describe payload."""
39
+ robots = desc.get("robots") or []
40
+ if not robots:
41
+ return []
42
+ robot = next((r for r in robots if r.get("id") == robot_id), robots[0]) if robot_id else robots[0]
43
+ return list(robot.get("actuators") or [])
44
+
45
+
46
+ def _joint_state(desc: dict, robot_id: str) -> tuple[np.ndarray, np.ndarray]:
47
+ """(qpos, qvel) vectors over the target robot's joints, in describe() order."""
48
+ robots = desc.get("robots") or []
49
+ robot = next((r for r in robots if r.get("id") == robot_id), robots[0] if robots else {}) if robot_id else (robots[0] if robots else {})
50
+ qpos, qvel = [], []
51
+ for j in robot.get("joints") or []:
52
+ p, v = j.get("pos", 0.0), j.get("vel", 0.0)
53
+ qpos.extend(p if isinstance(p, list) else [p])
54
+ qvel.extend(v if isinstance(v, list) else [v])
55
+ return np.asarray(qpos, dtype=np.float32), np.asarray(qvel, dtype=np.float32)
56
+
57
+
58
+ class CommandAGIEnv(gym.Env):
59
+ """Gymnasium env wrapping a live :class:`World`. One robot, morphology-agnostic.
60
+
61
+ Args:
62
+ world: a live sim World (from ``CommandAGI.launch`` / ``SimInstance``).
63
+ robot_id: which robot to drive (default: the world's only/first robot).
64
+ reward_fn: ``description -> float`` reward; defaults to constant 0.
65
+ terminated_fn / truncated_fn: ``description -> bool`` episode-end predicates.
66
+ control_dt: seconds to let physics advance per step (the sim runs ~250 Hz server-side).
67
+ image_obs: include the camera frame in the observation dict (needs Pillow).
68
+ max_steps: auto-truncate after this many steps (None = unbounded).
69
+ """
70
+
71
+ metadata = {"render_modes": ["rgb_array"]}
72
+
73
+ def __init__(
74
+ self,
75
+ world: World,
76
+ *,
77
+ robot_id: str = "",
78
+ reward_fn: Optional[Callable[[dict], float]] = None,
79
+ terminated_fn: Optional[Callable[[dict], bool]] = None,
80
+ truncated_fn: Optional[Callable[[dict], bool]] = None,
81
+ control_dt: float = 0.1,
82
+ image_obs: bool = True,
83
+ max_steps: Optional[int] = None,
84
+ ):
85
+ super().__init__()
86
+ self.world = world
87
+ self.robot_id = robot_id
88
+ self.reward_fn = reward_fn or (lambda _d: 0.0)
89
+ self.terminated_fn = terminated_fn or (lambda _d: False)
90
+ self.truncated_fn = truncated_fn or (lambda _d: False)
91
+ self.control_dt = control_dt
92
+ self.image_obs = image_obs
93
+ self.max_steps = max_steps
94
+ self._steps = 0
95
+
96
+ desc = world.describe(robot_id)
97
+ acts = _actuators(desc, robot_id)
98
+ if not acts:
99
+ raise CommandAGIError("no actuators found — is this a robot sim and is it live yet?")
100
+ self._act_names = [a["name"] for a in acts]
101
+ lo = np.asarray([a.get("ctrlrange", [-1, 1])[0] for a in acts], dtype=np.float32)
102
+ hi = np.asarray([a.get("ctrlrange", [-1, 1])[1] for a in acts], dtype=np.float32)
103
+ self.action_space = spaces.Box(low=lo, high=hi, dtype=np.float32)
104
+
105
+ qpos, qvel = _joint_state(desc, robot_id)
106
+ obs_spaces: dict[str, spaces.Space] = {
107
+ "qpos": spaces.Box(-np.inf, np.inf, shape=qpos.shape, dtype=np.float32),
108
+ "qvel": spaces.Box(-np.inf, np.inf, shape=qvel.shape, dtype=np.float32),
109
+ }
110
+ if image_obs:
111
+ img = world.observe_array()
112
+ obs_spaces["image"] = spaces.Box(0, 255, shape=img.shape, dtype=np.uint8)
113
+ self.observation_space = spaces.Dict(obs_spaces)
114
+
115
+ def _obs(self, desc: dict) -> dict:
116
+ qpos, qvel = _joint_state(desc, self.robot_id)
117
+ obs: dict[str, Any] = {"qpos": qpos, "qvel": qvel}
118
+ if self.image_obs:
119
+ obs["image"] = self.world.observe_array()
120
+ return obs
121
+
122
+ def reset(self, *, seed: Optional[int] = None, options: Optional[dict] = None):
123
+ super().reset(seed=seed)
124
+ self.world.reset()
125
+ self._steps = 0
126
+ desc = self.world.describe(self.robot_id)
127
+ return self._obs(desc), {"description": desc}
128
+
129
+ def step(self, action):
130
+ targets = {name: float(v) for name, v in zip(self._act_names, np.asarray(action).reshape(-1))}
131
+ self.world.ctrl(targets, robot_id=self.robot_id)
132
+ time.sleep(self.control_dt)
133
+ desc = self.world.describe(self.robot_id)
134
+ obs = self._obs(desc)
135
+ reward = float(self.reward_fn(desc))
136
+ terminated = bool(self.terminated_fn(desc))
137
+ self._steps += 1
138
+ truncated = bool(self.truncated_fn(desc)) or (self.max_steps is not None and self._steps >= self.max_steps)
139
+ return obs, reward, terminated, truncated, {"description": desc, "actuators": self._act_names}
140
+
141
+ def render(self):
142
+ return self.world.observe_array() if self.image_obs else None
143
+
144
+ def close(self):
145
+ self.world.close()