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/__init__.py +26 -0
- commandagi/agent.py +172 -0
- commandagi/bridge.py +133 -0
- commandagi/client.py +504 -0
- commandagi/gym_env.py +145 -0
- commandagi/lerobot.py +211 -0
- commandagi-0.3.0.dist-info/METADATA +159 -0
- commandagi-0.3.0.dist-info/RECORD +9 -0
- commandagi-0.3.0.dist-info/WHEEL +4 -0
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()
|