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/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""CommandAGI Python SDK.
|
|
2
|
+
|
|
3
|
+
Launch cloud computers and 3D robot simulations and control them programmatically.
|
|
4
|
+
|
|
5
|
+
from commandagi import CommandAGI
|
|
6
|
+
cagi = CommandAGI(api_key="cagi_...")
|
|
7
|
+
with cagi.launch("simulation/warehouse") as world:
|
|
8
|
+
obs = world.observe()
|
|
9
|
+
obs = world.step("move", speed=0.8)
|
|
10
|
+
"""
|
|
11
|
+
from .agent import RobotAgent, attach_robots
|
|
12
|
+
from .bridge import RobotBridge
|
|
13
|
+
from .client import COMPUTERS, SIMULATIONS, CommandAGI, CommandAGIError, SimInstance, World
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CommandAGI",
|
|
17
|
+
"World",
|
|
18
|
+
"SimInstance",
|
|
19
|
+
"RobotAgent",
|
|
20
|
+
"attach_robots",
|
|
21
|
+
"RobotBridge",
|
|
22
|
+
"CommandAGIError",
|
|
23
|
+
"SIMULATIONS",
|
|
24
|
+
"COMPUTERS",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.3.0"
|
commandagi/agent.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Autonomous multi-robot agent runner.
|
|
2
|
+
|
|
3
|
+
One agent, many robots. :class:`RobotAgent` drives a whole set of robot devices in a single thread
|
|
4
|
+
toward a natural-language goal: each step it gathers every device's world description and a fresh
|
|
5
|
+
camera frame, asks the platform's reasoning model (``POST /agent/robot-act``) what to do next, and
|
|
6
|
+
applies the returned tool calls back to the right device over the generic control vocabulary
|
|
7
|
+
(``ctrl`` / ``actuator`` / ``ik`` / ``trajectory``). It stops when the model emits a ``done`` call or
|
|
8
|
+
``max_steps`` is reached.
|
|
9
|
+
|
|
10
|
+
from commandagi import CommandAGI
|
|
11
|
+
from commandagi.agent import RobotAgent, attach_robots
|
|
12
|
+
|
|
13
|
+
cagi = CommandAGI(api_key="cagi_…")
|
|
14
|
+
sim = cagi.launch_sim(scene="warehouse")
|
|
15
|
+
devices = attach_robots(cagi, sim, kinds=["rover", "arm"]) # two robots, one thread
|
|
16
|
+
|
|
17
|
+
agent = RobotAgent(cagi, sim.thread_id, devices, goal="bring the red box to the arm")
|
|
18
|
+
result = agent.run(max_steps=25) # blocks; prints each step
|
|
19
|
+
print("done:", result["done"], "in", result["steps"], "steps")
|
|
20
|
+
sim.stop()
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from .client import CommandAGI, World
|
|
28
|
+
|
|
29
|
+
# Maps a returned tool name to how its call is applied on a World. ``done`` is terminal (handled
|
|
30
|
+
# specially). Anything else is forwarded as a raw action, so the runner keeps working if the API
|
|
31
|
+
# grows the vocabulary.
|
|
32
|
+
_GENERIC_TOOLS = {"ctrl", "actuator", "ik", "trajectory", "describe"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def attach_robots(client: CommandAGI, sim, kinds: list[str]) -> list[dict]:
|
|
36
|
+
"""Spawn several robots into one :class:`~commandagi.client.SimInstance` and return their devices.
|
|
37
|
+
|
|
38
|
+
Returns a list of ``{deviceId, robotId, kind}`` — one per requested kind — all on
|
|
39
|
+
``sim.thread_id``. This is the convenience wrapper for building a multi-robot thread to hand to
|
|
40
|
+
:class:`RobotAgent`.
|
|
41
|
+
"""
|
|
42
|
+
devices: list[dict] = []
|
|
43
|
+
for kind in kinds:
|
|
44
|
+
info = sim.join_robot(kind=kind)
|
|
45
|
+
devices.append({"deviceId": info["deviceId"], "robotId": info.get("robotId", ""), "kind": kind})
|
|
46
|
+
return devices
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _frame_data_url(frame: bytes) -> str:
|
|
50
|
+
mime = "image/png" if frame[:8] == b"\x89PNG\r\n\x1a\n" else "image/jpeg"
|
|
51
|
+
return f"data:{mime};base64," + base64.b64encode(frame).decode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RobotAgent:
|
|
55
|
+
"""An autonomous runner that drives many robot devices in one thread toward ``goal``.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
client:
|
|
60
|
+
A :class:`~commandagi.client.CommandAGI` client.
|
|
61
|
+
thread_id:
|
|
62
|
+
The thread the robot devices live on (e.g. ``sim.thread_id``).
|
|
63
|
+
devices:
|
|
64
|
+
A list of ``{deviceId, robotId?, kind?}`` describing the robots to drive (e.g. the output of
|
|
65
|
+
:func:`attach_robots`).
|
|
66
|
+
goal:
|
|
67
|
+
The natural-language objective handed to ``/agent/robot-act`` each step.
|
|
68
|
+
model:
|
|
69
|
+
Optional model id override for the reasoning call.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, client: CommandAGI, thread_id: str, devices: list[dict], goal: str,
|
|
73
|
+
*, model: Optional[str] = None):
|
|
74
|
+
self.client = client
|
|
75
|
+
self.thread_id = thread_id
|
|
76
|
+
self.goal = goal
|
|
77
|
+
self.model = model
|
|
78
|
+
self._device_meta = {d["deviceId"]: d for d in devices}
|
|
79
|
+
# One control channel per device, all on the same thread. The agent addresses robots by
|
|
80
|
+
# deviceId; a single World per device carries its observe()/describe()/control.
|
|
81
|
+
self.worlds: dict[str, World] = {
|
|
82
|
+
d["deviceId"]: client.connect_world(thread_id, d["deviceId"], kind="robot")
|
|
83
|
+
for d in devices
|
|
84
|
+
}
|
|
85
|
+
self.history: list[dict] = []
|
|
86
|
+
|
|
87
|
+
# ── per-step pieces ────────────────────────────────────────────────────────
|
|
88
|
+
def _gather(self) -> list[dict]:
|
|
89
|
+
"""Collect ``{deviceId, world, camera}`` for every device (description + a fresh frame)."""
|
|
90
|
+
payload: list[dict] = []
|
|
91
|
+
for device_id, world in self.worlds.items():
|
|
92
|
+
try:
|
|
93
|
+
description = world.describe(robot_id=self._device_meta[device_id].get("robotId", ""))
|
|
94
|
+
except Exception:
|
|
95
|
+
description = {}
|
|
96
|
+
try:
|
|
97
|
+
camera = _frame_data_url(world.observe(timeout=15.0))
|
|
98
|
+
except Exception:
|
|
99
|
+
camera = None
|
|
100
|
+
payload.append({"deviceId": device_id, "world": description, "camera": camera})
|
|
101
|
+
return payload
|
|
102
|
+
|
|
103
|
+
def _apply(self, call: dict) -> None:
|
|
104
|
+
"""Apply one returned tool call to the addressed device's :class:`World`."""
|
|
105
|
+
tool = call.get("tool")
|
|
106
|
+
device_id = call.get("deviceId")
|
|
107
|
+
robot_id = call.get("robotId", "")
|
|
108
|
+
world = self.worlds.get(device_id)
|
|
109
|
+
if world is None or tool in (None, "done"):
|
|
110
|
+
return
|
|
111
|
+
if tool == "ctrl":
|
|
112
|
+
world.ctrl(call.get("targets", {}), robot_id=robot_id)
|
|
113
|
+
elif tool == "actuator":
|
|
114
|
+
world.actuator(call.get("name", ""), call.get("value", 0.0), robot_id=robot_id)
|
|
115
|
+
elif tool == "ik":
|
|
116
|
+
world.ik(call.get("target", []), site=call.get("site"),
|
|
117
|
+
relative=bool(call.get("relative", False)), robot_id=robot_id)
|
|
118
|
+
elif tool == "trajectory":
|
|
119
|
+
world.trajectory(call.get("waypoints", []), robot_id=robot_id)
|
|
120
|
+
elif tool == "describe":
|
|
121
|
+
world.describe(robot_id=robot_id)
|
|
122
|
+
else:
|
|
123
|
+
# Unknown/extended tool: forward generically with whatever fields came back.
|
|
124
|
+
extra = {k: v for k, v in call.items() if k not in ("tool", "deviceId", "robotId")}
|
|
125
|
+
world.act(tool, robotId=robot_id, **extra)
|
|
126
|
+
|
|
127
|
+
# ── main loop ──────────────────────────────────────────────────────────────
|
|
128
|
+
def step(self) -> dict:
|
|
129
|
+
"""Run a single perceive→reason→act cycle. Returns ``{reasoning, calls, done}``."""
|
|
130
|
+
devices = self._gather()
|
|
131
|
+
res = self.client.robot_act(self.goal, devices, model=self.model, history=self.history or None)
|
|
132
|
+
reasoning = res.get("reasoning", "")
|
|
133
|
+
calls = res.get("calls", []) or []
|
|
134
|
+
done = any(c.get("tool") == "done" for c in calls)
|
|
135
|
+
for call in calls:
|
|
136
|
+
self._apply(call)
|
|
137
|
+
self.history.append({"reasoning": reasoning, "calls": calls})
|
|
138
|
+
return {"reasoning": reasoning, "calls": calls, "done": done}
|
|
139
|
+
|
|
140
|
+
def run(self, max_steps: int = 50, *, verbose: bool = True) -> dict:
|
|
141
|
+
"""Loop :meth:`step` until a ``done`` call or ``max_steps``.
|
|
142
|
+
|
|
143
|
+
Returns ``{done, steps, history}``. With ``verbose`` (default), prints the reasoning and the
|
|
144
|
+
applied calls each step.
|
|
145
|
+
"""
|
|
146
|
+
steps = 0
|
|
147
|
+
done = False
|
|
148
|
+
for i in range(max_steps):
|
|
149
|
+
out = self.step()
|
|
150
|
+
steps = i + 1
|
|
151
|
+
if verbose:
|
|
152
|
+
print(f"[step {steps}] {out['reasoning']}")
|
|
153
|
+
for call in out["calls"]:
|
|
154
|
+
print(f" → {call.get('tool')} on {call.get('deviceId')} {call.get('robotId', '')}".rstrip())
|
|
155
|
+
if out["done"]:
|
|
156
|
+
done = True
|
|
157
|
+
break
|
|
158
|
+
return {"done": done, "steps": steps, "history": self.history}
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
"""Close every device control channel (does not stop the thread). Safe to call twice."""
|
|
162
|
+
for world in self.worlds.values():
|
|
163
|
+
try:
|
|
164
|
+
world._ws.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> "RobotAgent":
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *_exc) -> None:
|
|
172
|
+
self.close()
|
commandagi/bridge.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Bring-your-own-robot: stream YOUR robot's camera into CommandAGI and receive actions.
|
|
2
|
+
|
|
3
|
+
This is the *producer* side of the platform (the mirror of `World`, which is the consumer side that
|
|
4
|
+
drives a hosted world). You register a robot, then run a bridge that publishes its camera frames and
|
|
5
|
+
hands incoming control actions to your hardware. Anyone with access to the thread — a person in the
|
|
6
|
+
web UI, an agent, or another developer's `World` client — then sees your robot's camera and can drive
|
|
7
|
+
it, exactly like a first-party simulation.
|
|
8
|
+
|
|
9
|
+
from commandagi import CommandAGI
|
|
10
|
+
|
|
11
|
+
cagi = CommandAGI(api_key="cagi_…")
|
|
12
|
+
bridge = cagi.register_robot("my-rover")
|
|
13
|
+
print("watch + drive it at:", bridge.thread_url)
|
|
14
|
+
|
|
15
|
+
bridge.run(
|
|
16
|
+
camera=lambda: my_robot.jpeg_frame(), # -> bytes (JPEG/PNG)
|
|
17
|
+
on_action=lambda action, payload: my_robot.do(action, payload),
|
|
18
|
+
fps=10,
|
|
19
|
+
) # blocks; Ctrl-C to stop + release
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Callable, Optional
|
|
28
|
+
|
|
29
|
+
import websocket # websocket-client
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _data_url(frame: bytes) -> str:
|
|
33
|
+
mime = "image/png" if frame[:8] == b"\x89PNG\r\n\x1a\n" else "image/jpeg"
|
|
34
|
+
return f"data:{mime};base64," + base64.b64encode(frame).decode()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RobotBridge:
|
|
38
|
+
"""A live bridge between your robot and a CommandAGI thread. Create it with
|
|
39
|
+
:meth:`CommandAGI.register_robot`; then call :meth:`run`."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, control_url: str, token: str, device_id: str, *, thread_id: str, thread_url: str, client=None):
|
|
42
|
+
self.control_url = control_url
|
|
43
|
+
self.token = token
|
|
44
|
+
self.device_id = device_id
|
|
45
|
+
self.thread_id = thread_id
|
|
46
|
+
self.thread_url = thread_url
|
|
47
|
+
self._client = client
|
|
48
|
+
self._camera: Optional[Callable[[], bytes]] = None
|
|
49
|
+
self._on_action: Optional[Callable[[str, dict], None]] = None
|
|
50
|
+
self._channel = "cam-head"
|
|
51
|
+
self._fps = 10.0
|
|
52
|
+
self._ws: Optional[websocket.WebSocketApp] = None
|
|
53
|
+
self._stop = threading.Event()
|
|
54
|
+
|
|
55
|
+
def _ws_url(self) -> str:
|
|
56
|
+
base = self.control_url.replace("https://", "wss://").replace("http://", "ws://")
|
|
57
|
+
sep = "&" if "?" in base else "?"
|
|
58
|
+
return f"{base}{sep}runtime=1&role=agent&token={self.token}"
|
|
59
|
+
|
|
60
|
+
def _on_open(self, ws) -> None:
|
|
61
|
+
# Announce we're live and which camera channel we publish, then start streaming frames.
|
|
62
|
+
ws.send(json.dumps({"type": "status", "status": "live"}))
|
|
63
|
+
ws.send(json.dumps({"type": "channels", "channels": [{"channelId": self._channel, "label": "Camera", "kind": "camera"}]}))
|
|
64
|
+
threading.Thread(target=self._frame_loop, args=(ws,), daemon=True).start()
|
|
65
|
+
|
|
66
|
+
def _frame_loop(self, ws) -> None:
|
|
67
|
+
interval = 1.0 / max(0.5, self._fps)
|
|
68
|
+
while not self._stop.is_set():
|
|
69
|
+
try:
|
|
70
|
+
frame = self._camera()
|
|
71
|
+
if frame:
|
|
72
|
+
ws.send(json.dumps({"type": "frame", "channelId": self._channel, "url": _data_url(frame)}))
|
|
73
|
+
except Exception as e: # keep streaming; surface the error in chat
|
|
74
|
+
try:
|
|
75
|
+
ws.send(json.dumps({"type": "agent_message", "text": f"[camera error] {e}"}))
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
time.sleep(interval)
|
|
79
|
+
|
|
80
|
+
def _on_message(self, _ws, raw: str) -> None:
|
|
81
|
+
try:
|
|
82
|
+
m = json.loads(raw)
|
|
83
|
+
except (ValueError, TypeError):
|
|
84
|
+
return
|
|
85
|
+
if m.get("type") == "control" and self._on_action:
|
|
86
|
+
try:
|
|
87
|
+
self._on_action(m.get("action", ""), m.get("payload") or {})
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def run(
|
|
92
|
+
self,
|
|
93
|
+
camera: Callable[[], bytes],
|
|
94
|
+
on_action: Callable[[str, dict], None],
|
|
95
|
+
*,
|
|
96
|
+
fps: float = 10.0,
|
|
97
|
+
channel: str = "cam-head",
|
|
98
|
+
block: bool = True,
|
|
99
|
+
) -> "RobotBridge":
|
|
100
|
+
"""Start streaming. ``camera()`` returns the latest frame as JPEG/PNG bytes; ``on_action(action,
|
|
101
|
+
payload)`` is called for each control message (e.g. ``("move", {"speed": 0.8})``). Blocks until
|
|
102
|
+
interrupted unless ``block=False``."""
|
|
103
|
+
self._camera = camera
|
|
104
|
+
self._on_action = on_action
|
|
105
|
+
self._fps = fps
|
|
106
|
+
self._channel = channel
|
|
107
|
+
self._ws = websocket.WebSocketApp(self._ws_url(), on_open=self._on_open, on_message=self._on_message)
|
|
108
|
+
run = lambda: self._ws.run_forever(ping_interval=20, ping_timeout=10, reconnect=3)
|
|
109
|
+
if block:
|
|
110
|
+
try:
|
|
111
|
+
run()
|
|
112
|
+
except KeyboardInterrupt:
|
|
113
|
+
pass
|
|
114
|
+
finally:
|
|
115
|
+
self.close()
|
|
116
|
+
else:
|
|
117
|
+
threading.Thread(target=run, daemon=True).start()
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def stop(self) -> None:
|
|
121
|
+
"""Stop streaming (without releasing the robot's thread)."""
|
|
122
|
+
self._stop.set()
|
|
123
|
+
if self._ws:
|
|
124
|
+
try:
|
|
125
|
+
self._ws.close()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
"""Stop streaming and release the thread (the robot goes offline)."""
|
|
131
|
+
self.stop()
|
|
132
|
+
if self._client:
|
|
133
|
+
self._client._stop(self.thread_id)
|