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 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)