commandagi 0.3.0__tar.gz
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-0.3.0/.gitignore +65 -0
- commandagi-0.3.0/PKG-INFO +159 -0
- commandagi-0.3.0/README.md +134 -0
- commandagi-0.3.0/commandagi/__init__.py +26 -0
- commandagi-0.3.0/commandagi/agent.py +172 -0
- commandagi-0.3.0/commandagi/bridge.py +133 -0
- commandagi-0.3.0/commandagi/client.py +504 -0
- commandagi-0.3.0/commandagi/gym_env.py +145 -0
- commandagi-0.3.0/commandagi/lerobot.py +211 -0
- commandagi-0.3.0/examples/sim_agent.py +44 -0
- commandagi-0.3.0/pyproject.toml +36 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# deps
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# build
|
|
6
|
+
dist/
|
|
7
|
+
.next/
|
|
8
|
+
out/
|
|
9
|
+
.turbo/
|
|
10
|
+
.vercel/
|
|
11
|
+
*.tsbuildinfo
|
|
12
|
+
|
|
13
|
+
# env
|
|
14
|
+
.env
|
|
15
|
+
.env.*
|
|
16
|
+
!.env.example
|
|
17
|
+
!*.example
|
|
18
|
+
.dev.vars
|
|
19
|
+
.wrangler/
|
|
20
|
+
|
|
21
|
+
# secrets that travel with the .env cascade but are never committed
|
|
22
|
+
# (e.g. the Sign in with Apple .p8 private key — see secrets/README or lib/auth.ts)
|
|
23
|
+
secrets/
|
|
24
|
+
*.p8
|
|
25
|
+
|
|
26
|
+
# misc
|
|
27
|
+
.DS_Store
|
|
28
|
+
*.log
|
|
29
|
+
coverage/
|
|
30
|
+
.idea/
|
|
31
|
+
.vscode/*
|
|
32
|
+
!.vscode/extensions.json
|
|
33
|
+
|
|
34
|
+
# OpenNext build output
|
|
35
|
+
.open-next/
|
|
36
|
+
|
|
37
|
+
# editor/python noise
|
|
38
|
+
*.swp
|
|
39
|
+
__pycache__/
|
|
40
|
+
|
|
41
|
+
# Claude Code scheduler runtime lock (machine-local, never commit)
|
|
42
|
+
scheduled_tasks.lock
|
|
43
|
+
.claude/scheduled_tasks.lock
|
|
44
|
+
.old-env/
|
|
45
|
+
|
|
46
|
+
# local prod backups (email/calendar migration 2026-07-03) — never commit
|
|
47
|
+
/backup/
|
|
48
|
+
|
|
49
|
+
# research scratch — machine-local instance-id markers, peek scripts, env captures
|
|
50
|
+
/.*_iid
|
|
51
|
+
/peek.sh
|
|
52
|
+
# ARC-AGI-3 game downloads. These belong to the model service and live in
|
|
53
|
+
# services/model/environment_files/ (ignored there too) — a copy at the REPO ROOT only ever appears
|
|
54
|
+
# when the harness is run from the wrong cwd, and one did: a stray tu93/ sat here duplicating the
|
|
55
|
+
# submodule's copy, differing solely in date_downloaded. Deleted 2026-08-13; the rule stays so the
|
|
56
|
+
# next wrong-cwd run cannot quietly repopulate the index.
|
|
57
|
+
/environment_files/
|
|
58
|
+
|
|
59
|
+
# staged asset-library manifests — regenerable output of scripts/stage-{editor,public}-library.mjs
|
|
60
|
+
scratch/editor-library/
|
|
61
|
+
scratch/public-library/
|
|
62
|
+
|
|
63
|
+
# Stager RUN ARTIFACTS — `--results` output, one per invocation. Not source: a machine that ran
|
|
64
|
+
# the stager should not be able to commit its log by accident.
|
|
65
|
+
*.results.json
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: commandagi
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for CommandAGI — launch cloud computers and 3D robot simulations and control them programmatically.
|
|
5
|
+
Project-URL: Homepage, https://commandagi.com
|
|
6
|
+
Project-URL: Documentation, https://github.com/CommandAGI/commandagi/blob/main/docs/ROBOT_DEVELOPER_API.md
|
|
7
|
+
Author: CommandAGI
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agents,computer-use,physics,reinforcement-learning,robotics,simulation
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: requests>=2.28
|
|
12
|
+
Requires-Dist: websocket-client>=1.5
|
|
13
|
+
Provides-Extra: gym
|
|
14
|
+
Requires-Dist: gymnasium>=0.29; extra == 'gym'
|
|
15
|
+
Requires-Dist: numpy>=1.23; extra == 'gym'
|
|
16
|
+
Requires-Dist: pillow>=9.0; extra == 'gym'
|
|
17
|
+
Provides-Extra: lerobot
|
|
18
|
+
Requires-Dist: numpy>=1.23; extra == 'lerobot'
|
|
19
|
+
Requires-Dist: pillow>=9.0; extra == 'lerobot'
|
|
20
|
+
Requires-Dist: pyarrow>=14.0; extra == 'lerobot'
|
|
21
|
+
Provides-Extra: vision
|
|
22
|
+
Requires-Dist: numpy>=1.23; extra == 'vision'
|
|
23
|
+
Requires-Dist: pillow>=9.0; extra == 'vision'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# CommandAGI Python SDK
|
|
27
|
+
|
|
28
|
+
Launch real cloud **computers** and **3D robot simulations** and control them from Python — stream
|
|
29
|
+
the robot's camera, send actions, run episodes. No agent required: you drive.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install commandagi # + `pip install commandagi[vision]` for numpy frames
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Robot testing in a 3D world
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from commandagi import CommandAGI
|
|
39
|
+
|
|
40
|
+
cagi = CommandAGI(api_key="cagi_...") # or set COMMANDAGI_API_KEY
|
|
41
|
+
|
|
42
|
+
with cagi.launch("simulation/warehouse") as world:
|
|
43
|
+
obs = world.observe() # JPEG bytes from the robot's head camera
|
|
44
|
+
for _ in range(20):
|
|
45
|
+
obs = world.step("turn", dir="left") # act, then get the next frame
|
|
46
|
+
world.reset() # robot back to the episode start
|
|
47
|
+
# leaving the block stops the world and releases the cloud VM
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`launch()` provisions a real GCE VM running a 3D physics world, waits until it's streaming, and gives
|
|
51
|
+
you a `World`. Built-in scenes: `simulation/warehouse`, `simulation/house-on-fire`,
|
|
52
|
+
`simulation/school` (a mobile robot in each).
|
|
53
|
+
|
|
54
|
+
### The control vocabulary
|
|
55
|
+
|
|
56
|
+
| World kind | actions |
|
|
57
|
+
| ----------- | ------------------------------------------------------------------------- |
|
|
58
|
+
| robot / sim | `move(speed)`, `back(speed)`, `turn(dir, rate)`, `stop`, `reset` |
|
|
59
|
+
| computer | `click(x, y)`, `type(text)`, `key(key)`, `move(x, y)`, `scroll(x, y, dy)` |
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
world.act("move", speed=0.8) # fire-and-forget
|
|
63
|
+
obs = world.step("move", speed=0.8) # act + return the next observation (settles 0.8s)
|
|
64
|
+
obs = world.observe(fresh=True) # wait for a frame newer than now
|
|
65
|
+
arr = world.observe_array() # HxWx3 uint8 numpy (needs commandagi[vision])
|
|
66
|
+
for frame in world.stream(): # live generator of frames
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Simulator instances (morphology-agnostic robots)
|
|
71
|
+
|
|
72
|
+
The simulator is **morphology-agnostic**: a robot is just a set of named actuators and sites, driven
|
|
73
|
+
by one small **generic** control vocabulary — no `drive`/`gripper`, just `ctrl` / `actuator` / `ik`
|
|
74
|
+
/ `trajectory` / `describe`. Spin up your own instance, choose who can watch or add robots, and
|
|
75
|
+
populate it with one or many robots on a single session.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from commandagi import CommandAGI
|
|
79
|
+
|
|
80
|
+
cagi = CommandAGI(api_key="cagi_...")
|
|
81
|
+
|
|
82
|
+
sim = cagi.launch_sim(scene="the-matrix", visibility="private", title="demo")
|
|
83
|
+
print("instance:", sim.id, "session:", sim.session_id)
|
|
84
|
+
|
|
85
|
+
# Who can do what:
|
|
86
|
+
sim.grant("user_teammate", capability="viewer") # may watch the stream
|
|
87
|
+
sim.grant("user_buddy", capability="operator") # may also launch robots into the world
|
|
88
|
+
|
|
89
|
+
# Add robots (each becomes a embodiment on sim.session_id):
|
|
90
|
+
rover = sim.join_robot(kind="rover") # -> {robotId, embodimentId, sessionId}
|
|
91
|
+
arm = sim.join_robot(kind="arm")
|
|
92
|
+
|
|
93
|
+
cagi.sims() # list instances visible to you
|
|
94
|
+
cagi.get_sim(sim.id) # rehydrate a SimInstance
|
|
95
|
+
sim.view() # instance metadata + attached embodiments
|
|
96
|
+
sim.stop() # release it (or use `with cagi.launch_sim(...) as sim:`)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Generic robot control
|
|
100
|
+
|
|
101
|
+
`World` exposes the morphology-agnostic vocabulary (address a specific robot in a multi-robot embodiment
|
|
102
|
+
with `robot_id`):
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
world = cagi.connect_world(sim.session_id, rover["embodimentId"], kind="robot")
|
|
106
|
+
|
|
107
|
+
desc = world.describe() # actuators, sites, objects (best-effort)
|
|
108
|
+
world.ctrl({"left_wheel": 1.0, "right_wheel": 1.0}) # set actuator targets directly
|
|
109
|
+
world.actuator("left_wheel", 0.0) # one named actuator
|
|
110
|
+
world.ik(target=[0.3, 0.0, 0.4], site="ee", relative=False) # inverse kinematics to a point
|
|
111
|
+
world.trajectory([{"left_wheel": 1.0}, {"left_wheel": 0.0}]) # follow waypoints
|
|
112
|
+
frame = world.observe() # camera frame, as before
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
> `describe()` is best-effort: the runtime answers a describe request over the session channel, but
|
|
116
|
+
> there is currently no synchronous describe HTTP endpoint — if nothing echoes back it returns `{}`,
|
|
117
|
+
> and the autonomous agent also obtains descriptions server-side via `/agent/robot-act`.
|
|
118
|
+
|
|
119
|
+
## Autonomous agents over many robots
|
|
120
|
+
|
|
121
|
+
One agent can drive **many** robots in a single session. `RobotAgent` loops perceive → reason → act:
|
|
122
|
+
each step it gathers every embodiment's description + a fresh camera frame, calls `/agent/robot-act` with
|
|
123
|
+
all embodiments, and applies the returned tool calls (`ctrl`/`actuator`/`ik`/`trajectory`) back to the
|
|
124
|
+
addressed embodiment — until a `done` call or `max_steps`.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from commandagi import CommandAGI
|
|
128
|
+
from commandagi.agent import RobotAgent, attach_robots
|
|
129
|
+
|
|
130
|
+
cagi = CommandAGI(api_key="cagi_...")
|
|
131
|
+
sim = cagi.launch_sim(scene="warehouse")
|
|
132
|
+
|
|
133
|
+
embodiments = attach_robots(cagi, sim, kinds=["rover", "arm"]) # two robots, one session
|
|
134
|
+
|
|
135
|
+
with RobotAgent(cagi, sim.session_id, embodiments, goal="bring the red box to the arm") as agent:
|
|
136
|
+
result = agent.run(max_steps=25) # blocks; prints reasoning + applied calls each step
|
|
137
|
+
print("done:", result["done"], "in", result["steps"], "steps")
|
|
138
|
+
|
|
139
|
+
sim.stop()
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A full runnable script lives in [`examples/sim_agent.py`](examples/sim_agent.py).
|
|
143
|
+
|
|
144
|
+
## Computers too
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
with cagi.launch("computer/software-engineer") as pc:
|
|
148
|
+
pc.act("type", text="hello")
|
|
149
|
+
pc.act("key", key="Return")
|
|
150
|
+
screenshot = pc.observe() # PNG bytes of the live Ubuntu desktop
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Auth
|
|
154
|
+
|
|
155
|
+
Create an API key with an `operator` scope (dashboard → API keys, or `POST /me/api-keys`). Pass it to
|
|
156
|
+
`CommandAGI(api_key=...)` or set `COMMANDAGI_API_KEY`. Point at another environment with
|
|
157
|
+
`COMMANDAGI_BASE_URL` (e.g. `https://api-dev.commandagi.com`).
|
|
158
|
+
|
|
159
|
+
Full HTTP + WebSocket reference (what the SDK wraps): [`docs/platform/ROBOT_DEVELOPER_API.md`](../../docs/platform/ROBOT_DEVELOPER_API.md).
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# CommandAGI Python SDK
|
|
2
|
+
|
|
3
|
+
Launch real cloud **computers** and **3D robot simulations** and control them from Python — stream
|
|
4
|
+
the robot's camera, send actions, run episodes. No agent required: you drive.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install commandagi # + `pip install commandagi[vision]` for numpy frames
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Robot testing in a 3D world
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from commandagi import CommandAGI
|
|
14
|
+
|
|
15
|
+
cagi = CommandAGI(api_key="cagi_...") # or set COMMANDAGI_API_KEY
|
|
16
|
+
|
|
17
|
+
with cagi.launch("simulation/warehouse") as world:
|
|
18
|
+
obs = world.observe() # JPEG bytes from the robot's head camera
|
|
19
|
+
for _ in range(20):
|
|
20
|
+
obs = world.step("turn", dir="left") # act, then get the next frame
|
|
21
|
+
world.reset() # robot back to the episode start
|
|
22
|
+
# leaving the block stops the world and releases the cloud VM
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`launch()` provisions a real GCE VM running a 3D physics world, waits until it's streaming, and gives
|
|
26
|
+
you a `World`. Built-in scenes: `simulation/warehouse`, `simulation/house-on-fire`,
|
|
27
|
+
`simulation/school` (a mobile robot in each).
|
|
28
|
+
|
|
29
|
+
### The control vocabulary
|
|
30
|
+
|
|
31
|
+
| World kind | actions |
|
|
32
|
+
| ----------- | ------------------------------------------------------------------------- |
|
|
33
|
+
| robot / sim | `move(speed)`, `back(speed)`, `turn(dir, rate)`, `stop`, `reset` |
|
|
34
|
+
| computer | `click(x, y)`, `type(text)`, `key(key)`, `move(x, y)`, `scroll(x, y, dy)` |
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
world.act("move", speed=0.8) # fire-and-forget
|
|
38
|
+
obs = world.step("move", speed=0.8) # act + return the next observation (settles 0.8s)
|
|
39
|
+
obs = world.observe(fresh=True) # wait for a frame newer than now
|
|
40
|
+
arr = world.observe_array() # HxWx3 uint8 numpy (needs commandagi[vision])
|
|
41
|
+
for frame in world.stream(): # live generator of frames
|
|
42
|
+
...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Simulator instances (morphology-agnostic robots)
|
|
46
|
+
|
|
47
|
+
The simulator is **morphology-agnostic**: a robot is just a set of named actuators and sites, driven
|
|
48
|
+
by one small **generic** control vocabulary — no `drive`/`gripper`, just `ctrl` / `actuator` / `ik`
|
|
49
|
+
/ `trajectory` / `describe`. Spin up your own instance, choose who can watch or add robots, and
|
|
50
|
+
populate it with one or many robots on a single session.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from commandagi import CommandAGI
|
|
54
|
+
|
|
55
|
+
cagi = CommandAGI(api_key="cagi_...")
|
|
56
|
+
|
|
57
|
+
sim = cagi.launch_sim(scene="the-matrix", visibility="private", title="demo")
|
|
58
|
+
print("instance:", sim.id, "session:", sim.session_id)
|
|
59
|
+
|
|
60
|
+
# Who can do what:
|
|
61
|
+
sim.grant("user_teammate", capability="viewer") # may watch the stream
|
|
62
|
+
sim.grant("user_buddy", capability="operator") # may also launch robots into the world
|
|
63
|
+
|
|
64
|
+
# Add robots (each becomes a embodiment on sim.session_id):
|
|
65
|
+
rover = sim.join_robot(kind="rover") # -> {robotId, embodimentId, sessionId}
|
|
66
|
+
arm = sim.join_robot(kind="arm")
|
|
67
|
+
|
|
68
|
+
cagi.sims() # list instances visible to you
|
|
69
|
+
cagi.get_sim(sim.id) # rehydrate a SimInstance
|
|
70
|
+
sim.view() # instance metadata + attached embodiments
|
|
71
|
+
sim.stop() # release it (or use `with cagi.launch_sim(...) as sim:`)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Generic robot control
|
|
75
|
+
|
|
76
|
+
`World` exposes the morphology-agnostic vocabulary (address a specific robot in a multi-robot embodiment
|
|
77
|
+
with `robot_id`):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
world = cagi.connect_world(sim.session_id, rover["embodimentId"], kind="robot")
|
|
81
|
+
|
|
82
|
+
desc = world.describe() # actuators, sites, objects (best-effort)
|
|
83
|
+
world.ctrl({"left_wheel": 1.0, "right_wheel": 1.0}) # set actuator targets directly
|
|
84
|
+
world.actuator("left_wheel", 0.0) # one named actuator
|
|
85
|
+
world.ik(target=[0.3, 0.0, 0.4], site="ee", relative=False) # inverse kinematics to a point
|
|
86
|
+
world.trajectory([{"left_wheel": 1.0}, {"left_wheel": 0.0}]) # follow waypoints
|
|
87
|
+
frame = world.observe() # camera frame, as before
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> `describe()` is best-effort: the runtime answers a describe request over the session channel, but
|
|
91
|
+
> there is currently no synchronous describe HTTP endpoint — if nothing echoes back it returns `{}`,
|
|
92
|
+
> and the autonomous agent also obtains descriptions server-side via `/agent/robot-act`.
|
|
93
|
+
|
|
94
|
+
## Autonomous agents over many robots
|
|
95
|
+
|
|
96
|
+
One agent can drive **many** robots in a single session. `RobotAgent` loops perceive → reason → act:
|
|
97
|
+
each step it gathers every embodiment's description + a fresh camera frame, calls `/agent/robot-act` with
|
|
98
|
+
all embodiments, and applies the returned tool calls (`ctrl`/`actuator`/`ik`/`trajectory`) back to the
|
|
99
|
+
addressed embodiment — until a `done` call or `max_steps`.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from commandagi import CommandAGI
|
|
103
|
+
from commandagi.agent import RobotAgent, attach_robots
|
|
104
|
+
|
|
105
|
+
cagi = CommandAGI(api_key="cagi_...")
|
|
106
|
+
sim = cagi.launch_sim(scene="warehouse")
|
|
107
|
+
|
|
108
|
+
embodiments = attach_robots(cagi, sim, kinds=["rover", "arm"]) # two robots, one session
|
|
109
|
+
|
|
110
|
+
with RobotAgent(cagi, sim.session_id, embodiments, goal="bring the red box to the arm") as agent:
|
|
111
|
+
result = agent.run(max_steps=25) # blocks; prints reasoning + applied calls each step
|
|
112
|
+
print("done:", result["done"], "in", result["steps"], "steps")
|
|
113
|
+
|
|
114
|
+
sim.stop()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A full runnable script lives in [`examples/sim_agent.py`](examples/sim_agent.py).
|
|
118
|
+
|
|
119
|
+
## Computers too
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
with cagi.launch("computer/software-engineer") as pc:
|
|
123
|
+
pc.act("type", text="hello")
|
|
124
|
+
pc.act("key", key="Return")
|
|
125
|
+
screenshot = pc.observe() # PNG bytes of the live Ubuntu desktop
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Auth
|
|
129
|
+
|
|
130
|
+
Create an API key with an `operator` scope (dashboard → API keys, or `POST /me/api-keys`). Pass it to
|
|
131
|
+
`CommandAGI(api_key=...)` or set `COMMANDAGI_API_KEY`. Point at another environment with
|
|
132
|
+
`COMMANDAGI_BASE_URL` (e.g. `https://api-dev.commandagi.com`).
|
|
133
|
+
|
|
134
|
+
Full HTTP + WebSocket reference (what the SDK wraps): [`docs/platform/ROBOT_DEVELOPER_API.md`](../../docs/platform/ROBOT_DEVELOPER_API.md).
|
|
@@ -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"
|
|
@@ -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()
|