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.
@@ -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()