roboapi 0.4.2__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.
Files changed (94) hide show
  1. roboapi/__init__.py +2 -0
  2. roboapi/calibration/__init__.py +6 -0
  3. roboapi/calibration/profile.py +139 -0
  4. roboapi/calibration/session.py +350 -0
  5. roboapi/cli.py +215 -0
  6. roboapi/config/__init__.py +22 -0
  7. roboapi/config/loader.py +119 -0
  8. roboapi/config/models.py +191 -0
  9. roboapi/embodiment.py +251 -0
  10. roboapi/errors.py +37 -0
  11. roboapi/geometry.py +337 -0
  12. roboapi/hardware/__init__.py +5 -0
  13. roboapi/hardware/base.py +113 -0
  14. roboapi/hardware/camera.py +167 -0
  15. roboapi/hardware/franka/__init__.py +5 -0
  16. roboapi/hardware/franka/arm.py +476 -0
  17. roboapi/hardware/franka/desk.py +555 -0
  18. roboapi/hardware/franka/feedback.py +196 -0
  19. roboapi/hardware/franka/guiding.py +104 -0
  20. roboapi/hardware/franka/ik.py +543 -0
  21. roboapi/hardware/franka/limits.py +110 -0
  22. roboapi/hardware/franka/settings.py +116 -0
  23. roboapi/hardware/robotiq.py +350 -0
  24. roboapi/hardware/ur/__init__.py +16 -0
  25. roboapi/hardware/ur/build.py +92 -0
  26. roboapi/hardware/ur/compliant.py +128 -0
  27. roboapi/hardware/ur/dashboard.py +103 -0
  28. roboapi/hardware/ur/feedback.py +159 -0
  29. roboapi/hardware/ur/ipc.py +206 -0
  30. roboapi/hardware/ur/native/CMakeLists.txt +37 -0
  31. roboapi/hardware/ur/native/dependencies.lock.json +13 -0
  32. roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
  33. roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
  34. roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
  35. roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
  36. roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
  37. roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
  38. roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
  39. roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
  40. roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
  41. roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
  42. roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
  43. roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
  44. roboapi/hardware/ur/native/worker.cpp +147 -0
  45. roboapi/hardware/ur/position.py +253 -0
  46. roboapi/hardware/ur/settings.py +95 -0
  47. roboapi/images.py +16 -0
  48. roboapi/initialize.py +137 -0
  49. roboapi/mcp.py +497 -0
  50. roboapi/motion.py +125 -0
  51. roboapi/naming.py +7 -0
  52. roboapi/operations.py +45 -0
  53. roboapi/policy/__init__.py +7 -0
  54. roboapi/policy/client.py +277 -0
  55. roboapi/policy/protocol.py +166 -0
  56. roboapi/policy/settings.py +79 -0
  57. roboapi/product-names.json +12 -0
  58. roboapi/records.py +149 -0
  59. roboapi/runtime.py +911 -0
  60. roboapi/semantics.py +6 -0
  61. roboapi/tasks.py +337 -0
  62. roboapi/web/__init__.py +5 -0
  63. roboapi/web/presets.py +44 -0
  64. roboapi/web/server.py +419 -0
  65. roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
  66. roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
  67. roboapi/web/static/assets/Inter-OFL.txt +93 -0
  68. roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
  69. roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
  70. roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
  71. roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
  72. roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
  73. roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
  74. roboapi/web/static/assets/React-LICENSE.txt +23 -0
  75. roboapi/web/static/assets/Three-LICENSE.txt +23 -0
  76. roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
  77. roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
  78. roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
  79. roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
  80. roboapi/web/static/index.html +15 -0
  81. roboapi-0.4.2.dist-info/METADATA +205 -0
  82. roboapi-0.4.2.dist-info/RECORD +94 -0
  83. roboapi-0.4.2.dist-info/WHEEL +5 -0
  84. roboapi-0.4.2.dist-info/entry_points.txt +2 -0
  85. roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
  86. roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
  87. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
  88. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
  89. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
  90. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
  91. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
  92. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
  93. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
  94. roboapi-0.4.2.dist-info/top_level.txt +1 -0
roboapi/runtime.py ADDED
@@ -0,0 +1,911 @@
1
+ """Policy loop, manual motion and the shared operation gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, replace
6
+ import logging
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+ from . import geometry as geo
14
+ from . import config
15
+ from .config import Config
16
+ from .calibration import Calibration
17
+ from .semantics import ACTION_DT, HOME_POSE7, TRAINING_FRAME
18
+ from .motion import dispatch, wait_for_pose, wait_until, workspace_targets
19
+ from .operations import OperationGate, exclusive
20
+ from .records import RunRecords
21
+ from .tasks import TaskArchive
22
+ from .embodiment import Embodiment, build_embodiment
23
+ from .errors import CalibrationRequired, ControlNotReady, LoopRunning
24
+ from .policy import ActionChunk, OpenAIChatPolicyClient, PolicyUnavailable
25
+ from .policy.settings import (
26
+ policy_token_configured, validate_policy_endpoint, write_policy_token,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ IDLE, RUNNING, PAUSED = "idle", "running", "paused"
32
+
33
+
34
+ class Runtime:
35
+ """The one background loop, plus the guard that keeps manual motion off the arm
36
+ while it runs. Everything the MCP server and the CLI call goes through here."""
37
+
38
+ def __init__(self, emb: Embodiment, policy, *, config_path: str | Path | None = None) -> None:
39
+ self.emb = emb
40
+ self.cfg = emb.cfg
41
+ self.config_path = Path(config_path).expanduser().resolve() if config_path is not None else None
42
+ self.policy = policy
43
+ self.records = RunRecords(self.cfg.runtime)
44
+ self.tasks = TaskArchive(Path(self.cfg.runtime.log_dir).expanduser() / "tasks")
45
+ self.emb.camera.on_event = self.records.event
46
+ self._observation_after = None
47
+ self._control_trace = []
48
+ self._lock = threading.RLock()
49
+ self.operations = OperationGate()
50
+ self._mode = IDLE
51
+ previous_task = self.tasks.current()
52
+ self._instruction: str | None = previous_task["instruction"] if previous_task else None
53
+ self._applied_instruction: str | None = None
54
+ self._camera_failures = 0
55
+ self._run_source: str | None = None
56
+ self._abort = threading.Event()
57
+ # Aborts a BLOCKING tool (move_waypoints) rather than the loop. Separate from _abort
58
+ # because those run with mode == IDLE, where the loop and its abort do not exist.
59
+ self._thread: threading.Thread | None = None
60
+ self._started_at = 0.0
61
+ self._chunk_started_at = 0.0
62
+ self._chunks = 0
63
+ self._late = 0
64
+ self._stop_reason: str | None = None
65
+ self._stop_requested: str | None = None
66
+ self._arm_fault_exit = False
67
+ self._direction_check: str | None = None
68
+ self.calibration = Calibration(self)
69
+
70
+ # -- introspection -----------------------------------------------------
71
+ @property
72
+ def mode(self) -> str:
73
+ return self._mode
74
+
75
+ def status(self) -> dict:
76
+ """One call must be worth polling for: mode, instruction, progress, health, why it stopped."""
77
+ with self._lock:
78
+ mode, instruction, run_source = self._mode, self._instruction, self._run_source
79
+ applied_instruction = self._applied_instruction
80
+ running = mode in (RUNNING, PAUSED)
81
+ task = self.tasks.current()
82
+ return {
83
+ "timestamp": time.time(),
84
+ "mode": mode,
85
+ "instruction": instruction,
86
+ "instruction_pending": running and instruction != applied_instruction,
87
+ "run_source": run_source,
88
+ "task_id": task["id"] if task else None,
89
+ "task_elapsed_s": task["elapsed_s"] if task else 0.0,
90
+ "chunk_elapsed_time": round(time.monotonic() - self._chunk_started_at, 3) if running else 0.0,
91
+ "elapsed_s": round(time.monotonic() - self._started_at, 2) if running else 0.0,
92
+ "chunks_done": self._chunks,
93
+ "late_frames": self._late,
94
+ "stop_reason": self._stop_reason,
95
+ "policy_ready": self.policy.ready,
96
+ "control_ready": self.emb.control_ready,
97
+ "inter_chunk_pause_s": self.cfg.runtime.inter_chunk_pause_s,
98
+ "workspace": self.workspace_config(),
99
+ **self.emb.status(),
100
+ "calibration": self.calibration.status(),
101
+ **self.records.snapshot(),
102
+ "session": self.policy.session_status,
103
+ }
104
+
105
+ @exclusive
106
+ def clear_monitor_errors(self) -> dict:
107
+ """Dismiss previous run errors without changing a live hardware fault or moving the arm."""
108
+ with self._lock:
109
+ if self._mode != IDLE:
110
+ raise LoopRunning("stop the policy before clearing previous errors")
111
+ self._stop_reason = None
112
+ self.records.clear_errors()
113
+ return self.status()
114
+
115
+
116
+ def workspace_config(self) -> dict:
117
+ return {**asdict(self.cfg.workspace), "home_xyz_m": list(HOME_POSE7[:3])}
118
+
119
+ @exclusive
120
+ def configure_workspace(self, *, enabled, min_xyz_m, max_xyz_m) -> dict:
121
+ """Save idle installation limits without connecting or moving any device."""
122
+ if self.mode != IDLE or self.calibration.phase not in {"acquire", "complete"}:
123
+ raise LoopRunning("Stop and finish calibration before changing the workspace")
124
+ if self.config_path is None:
125
+ raise ValueError("Workspace settings require a local installation configuration path")
126
+ workspace = config.WorkspaceConfig(enabled, min_xyz_m, max_xyz_m)
127
+ workspace.validate()
128
+ config.save(replace(self.cfg, workspace=workspace), self.config_path)
129
+ self.cfg.workspace = workspace
130
+ self.records.event("Workspace settings saved")
131
+ return self.workspace_config()
132
+
133
+ def _require_workspace_start(self) -> None:
134
+ workspace = self.cfg.workspace
135
+ # A frame round-trip must not turn a point on the face into an outside start.
136
+ if workspace.enabled and not workspace.contains(self.emb.arm.get_eef_pose()[:3], atol=1e-9):
137
+ raise ControlNotReady("Robot is outside the workspace. Return Home before starting motion.")
138
+
139
+ def policy_config(self) -> dict:
140
+ """Safe policy metadata; never returns the credential or a prefix of it."""
141
+ cfg = self.cfg.policy
142
+ token_file = str(Path(cfg.token_file).expanduser()) if cfg.token_file else None
143
+ return {
144
+ "type": cfg.type,
145
+ "url": cfg.url or None,
146
+ "model": cfg.model or None,
147
+ "token_file": token_file,
148
+ "api_key_configured": bool(cfg.token_file and policy_token_configured(cfg.token_file)),
149
+ "policy_ready": self.policy.ready,
150
+ "inter_chunk_pause_s": self.cfg.runtime.inter_chunk_pause_s,
151
+ }
152
+
153
+ @exclusive
154
+ def configure_policy(self, *, url: str, model: str, inter_chunk_pause_s: float,
155
+ api_key: str | None = None) -> dict:
156
+ """Verify and save the idle model connection and run settings together."""
157
+ cfg = self.cfg.policy
158
+ if cfg.type != "openai":
159
+ raise ValueError("policy connection setup requires policy.type='openai'")
160
+ if not cfg.token_file:
161
+ raise ValueError("policy.token_file is not configured")
162
+ if self.config_path is None:
163
+ raise ValueError("policy settings require a local installation configuration path")
164
+ url, model = validate_policy_endpoint(url, model)
165
+ updated = replace(self.cfg, policy=replace(cfg, url=url, model=model),
166
+ runtime=replace(self.cfg.runtime, inter_chunk_pause_s=inter_chunk_pause_s))
167
+ updated.validate()
168
+ with self._lock:
169
+ if self._mode != IDLE:
170
+ raise LoopRunning("stop the policy before changing its settings")
171
+ path = Path(cfg.token_file).expanduser()
172
+ previous = path.read_bytes() if api_key is not None and path.exists() else None
173
+ previous_mode = path.stat().st_mode & 0o777 if previous is not None else None
174
+ if api_key is not None:
175
+ write_policy_token(cfg.token_file, api_key)
176
+ candidate = None
177
+ try:
178
+ candidate = OpenAIChatPolicyClient(
179
+ url, cfg.token_file, model,
180
+ image_height=cfg.image_height,
181
+ timeout_s=cfg.timeout_s,
182
+ use_env_proxy=cfg.use_env_proxy,
183
+ )
184
+ candidate.connect()
185
+ config.save(updated, self.config_path)
186
+ except Exception:
187
+ if candidate is not None:
188
+ try:
189
+ candidate.close()
190
+ except Exception:
191
+ logger.warning("rejected policy connection did not close cleanly")
192
+ if api_key is not None:
193
+ if previous is None:
194
+ path.unlink(missing_ok=True)
195
+ else:
196
+ path.write_bytes(previous)
197
+ path.chmod(previous_mode or 0o600)
198
+ raise
199
+ previous_client = self.policy
200
+ cfg.url, cfg.model = url, model
201
+ self.cfg.runtime.inter_chunk_pause_s = inter_chunk_pause_s
202
+ self.policy = candidate
203
+ try:
204
+ previous_client.close()
205
+ except Exception:
206
+ logger.warning("previous policy connection did not close cleanly")
207
+ self.records.event("Model settings saved")
208
+ return {"ok": True, **self.policy_config()}
209
+
210
+ def observation(self, with_image: bool = True) -> tuple[dict, np.ndarray | None]:
211
+ """Status + the arm/gripper reading + optionally the frame, sampled together.
212
+
213
+ Two separate calls would hand the agent a picture from one instant and a pose from
214
+ another, with no way to see the skew. `image_age_ms` reports the residual.
215
+ """
216
+ obs = self.status()
217
+ if not self.emb.connected:
218
+ if self.emb.feedback is not None and self.emb.feedback.live and self.emb.calibrated:
219
+ pose = self.emb.arm.adapter.to_task(self.emb.feedback.snapshot()["pose"])
220
+ values = geo.pose7_to_xyzrpy(pose)
221
+ obs.update(eef_xyz_m=values[:3].round(4).tolist(),
222
+ eef_rpy_deg=values[3:].round(2).tolist(), eef_frame=TRAINING_FRAME)
223
+ return obs, self.emb.get_camera()[0] if with_image and obs["camera_connected"] else None
224
+ # Split and unit-named on purpose. A bare 7-vector invites an LLM to send radians,
225
+ # or a quaternion it cannot author; xyz-in-metres and rpy-in-degrees it can both read
226
+ # back and write. pose7d stays the internal representation everywhere else.
227
+ pose = geo.pose7_to_xyzrpy(self.emb.arm.get_eef_pose())
228
+ obs["eef_xyz_m"] = pose[0:3].round(4).tolist()
229
+ obs["eef_rpy_deg"] = pose[3:6].round(2).tolist()
230
+ obs["eef_frame"] = TRAINING_FRAME
231
+ obs["gripper_state"] = round(float(self.emb.gripper.get_ratio()), 4)
232
+ image = None
233
+ if with_image:
234
+ image, age = self.emb.camera.read_after(
235
+ self._observation_after, self.cfg.camera.stale_after_s,
236
+ )
237
+ obs["image_age_ms"] = round(age * 1e3, 1)
238
+ return obs, image
239
+
240
+ # -- loop control ------------------------------------------------------
241
+ @exclusive
242
+ def start(self, instruction: str, *, source: str = "mcp", new_task: bool = False) -> dict:
243
+ if source not in {"mcp", "web"}:
244
+ raise ValueError("run source must be mcp or web")
245
+ self._validate_instruction(instruction)
246
+ with self._lock:
247
+ if self._mode != IDLE:
248
+ raise LoopRunning(f"loop_running: already {self._mode}. Call stop() first.")
249
+ self._stop_requested = None
250
+ self._abort.clear()
251
+ self.emb.require_alive()
252
+ self._require_workspace_start()
253
+ self.policy.prepare_session()
254
+ self._instruction = instruction
255
+ self._applied_instruction = None
256
+ self._camera_failures = getattr(self.emb.camera, "failure_count", 0)
257
+ self._run_source = source
258
+ self._stop_reason = None
259
+ self._arm_fault_exit = False
260
+ self._chunks = 0
261
+ self._late = 0
262
+ self._started_at = time.monotonic()
263
+ self._chunk_started_at = self._started_at
264
+ self._mode = RUNNING
265
+ self._begin_task(instruction, new_task=new_task)
266
+ self.records.event("Run started")
267
+ self._thread = threading.Thread(target=self._run, name="policy-loop", daemon=True)
268
+ self._thread.start()
269
+ return self.status()
270
+
271
+ @staticmethod
272
+ def _validate_instruction(instruction: str) -> None:
273
+ if not isinstance(instruction, str) or not instruction.strip():
274
+ raise ValueError("instruction must not be empty")
275
+
276
+ def set_instruction(self, instruction: str, *, source: str = "mcp") -> dict:
277
+ """Takes effect at the NEXT inference: the loop reads it once per iteration.
278
+
279
+ Latency includes the rest of the complete chunk, frame waits, and one
280
+ inference. The instruction does not truncate a chunk. Use stop() to interrupt execution.
281
+
282
+ Meant for a running loop: start() takes the instruction itself, and overwrites
283
+ anything set beforehand.
284
+ """
285
+ self._validate_instruction(instruction)
286
+ with self._lock:
287
+ if self._mode not in (RUNNING, PAUSED):
288
+ raise LoopRunning("loop_not_running: start the policy before changing its instruction")
289
+ self._require_source(source)
290
+ self._instruction = instruction
291
+ self.records.event("Instruction submitted")
292
+ return self.status()
293
+
294
+ def _require_source(self, source: str) -> None:
295
+ if source not in {"mcp", "web"}:
296
+ raise ValueError("run source must be mcp or web")
297
+ if self._mode != IDLE and source != self._run_source:
298
+ raise LoopRunning(f"policy started through {self._run_source}; stop it before using {source}")
299
+
300
+ def _begin_task(self, instruction: str, *, new_task: bool = False) -> None:
301
+ try:
302
+ self.tasks.begin(instruction, self.cfg.policy.model, self._run_source,
303
+ self.emb.camera, new_task=new_task)
304
+ except Exception:
305
+ logger.exception("task recording could not start")
306
+ self.records.event("Task recording unavailable; robot control remains active", "warning")
307
+
308
+ def _finish_task(self, reason: str) -> None:
309
+ try:
310
+ events = self.records.snapshot()["inference_events"]
311
+ infer_ms = events[-1]["infer_ms"] if events else None
312
+ self.tasks.finish(reason, infer_ms=infer_ms)
313
+ except Exception:
314
+ logger.exception("task recording could not finish")
315
+ self.records.event("Could not save task recording", "warning")
316
+
317
+ def pause(self, *, source: str = "mcp") -> dict:
318
+ with self._lock:
319
+ self._require_source(source)
320
+ if self._mode != RUNNING:
321
+ return self.status()
322
+ self._mode = PAUSED
323
+ self.policy.record_lifecycle("pause")
324
+ self.records.event("Run paused")
325
+ self._hold_measured()
326
+ with self._lock:
327
+ if self._mode == PAUSED:
328
+ self._finish_task("paused")
329
+ return self.status()
330
+
331
+ @exclusive
332
+ def resume(self, *, source: str = "mcp") -> dict:
333
+ self.emb.require_control()
334
+ with self._lock:
335
+ self._require_source(source)
336
+ if self._mode != PAUSED:
337
+ return self.status()
338
+ self._require_workspace_start()
339
+ self._mode = RUNNING
340
+ self._begin_task(self._instruction)
341
+ self.policy.record_lifecycle("resume")
342
+ self.records.event("Run resumed")
343
+ return self.status()
344
+
345
+ def stop(self, reason: str = "stopped") -> dict:
346
+ """Stop whatever is driving the arm: the policy loop, or a blocking move.
347
+
348
+ The blocking-move abort signal is set BEFORE the idle early-return, because a
349
+ calibration Home runs with mode == IDLE. Drivers that support vendor cancellation
350
+ poll this signal and return to measured-state hold before the operation unwinds.
351
+ """
352
+ self.operations.interrupt()
353
+ # Hold and signal before waiting on metadata or model setup under _lock.
354
+ self._stop_requested = reason
355
+ self._abort.set()
356
+ self._hold_measured()
357
+ thread = self._thread
358
+ with self._lock:
359
+ if self._mode == IDLE:
360
+ return self.status()
361
+ self._stop_reason = reason
362
+ self._arm_fault_exit = False
363
+ self._abort.set()
364
+ self._finish_task(reason)
365
+ if thread is not None and thread is not threading.current_thread():
366
+ thread.join(timeout=5.0)
367
+ self._hold_measured()
368
+ return self.status()
369
+
370
+ def _hold_measured(self) -> None:
371
+ self.emb.hold()
372
+
373
+ def _run(self) -> None:
374
+ try:
375
+ if not self._abort.is_set():
376
+ self.policy.begin_session()
377
+ while not self._abort.is_set():
378
+ if self._interrupted() and self._abort.is_set():
379
+ break
380
+ if self._mode == PAUSED:
381
+ # Hold: stop feeding new targets. The arm stays on the last one -- on
382
+ # Franka this must NOT be done by ending the joint session, which
383
+ # ramps to damping-only and lets the arm sag.
384
+ time.sleep(0.02)
385
+ continue
386
+ self._chunk_started_at = time.monotonic()
387
+ step_started = time.perf_counter()
388
+ step_at = time.time()
389
+ self.records.begin_inference(step_at, step_started)
390
+ try:
391
+ result = self.step(None, should_stop=self._interrupted,
392
+ on_phase=self.records.set_phase)
393
+ except PolicyUnavailable as exc:
394
+ self.records.record_inference(step_at, step_started, exc=exc)
395
+ with self._lock:
396
+ if not self._abort.is_set():
397
+ self._stop_reason = f"policy_unavailable: {exc}"
398
+ self._arm_fault_exit = False
399
+ break
400
+ except Exception as exc:
401
+ self.records.record_inference(step_at, step_started, exc=exc)
402
+ # A driver can fail mid-chunk. If the rig is not alive that is a hardware
403
+ # fault and takes the same path as one seen at the top of the loop;
404
+ # anything else is a real bug and must not be swallowed as a fault.
405
+ with self._lock:
406
+ # A step can fail while an earlier stop is unwinding it.
407
+ # Preserve that cause and collect native errors only for arm exits.
408
+ if self._abort.is_set():
409
+ break
410
+ if self.emb.alive:
411
+ raise
412
+ camera_failed = getattr(self.emb.camera, "failure_count", 0) != self._camera_failures
413
+ self._arm_fault_exit = (
414
+ not camera_failed and not self.emb.arm.live and bool(self.emb.arm.last_error)
415
+ )
416
+ detail = (getattr(self.emb.camera, "last_error", None) or "camera disconnected"
417
+ if camera_failed else self.emb.last_error or str(exc))
418
+ self._stop_reason = f"not_alive: {detail}"
419
+ break
420
+ self.records.record_inference(step_at, step_started, result=result)
421
+ if result["chunk_len"]:
422
+ self._chunks += 1
423
+ self._late += result["late_frames"]
424
+ except PolicyUnavailable as exc:
425
+ with self._lock:
426
+ if not self._abort.is_set():
427
+ self._stop_reason = f"policy_unavailable: {exc}"
428
+ self._arm_fault_exit = False
429
+ except Exception as exc: # never let the loop thread die silently
430
+ logger.exception("policy loop failed")
431
+ with self._lock:
432
+ if not self._abort.is_set():
433
+ self._stop_reason = f"error: {exc}"
434
+ self._arm_fault_exit = False
435
+ finally:
436
+ # Local hold always precedes server cleanup, including a transport
437
+ # failure or stop that interrupted an action partway through.
438
+ try:
439
+ self._hold_measured()
440
+ except Exception:
441
+ logger.warning("local hold could not be confirmed at policy exit")
442
+ if self._arm_fault_exit:
443
+ wait_for_fault = getattr(self.emb.arm, "wait_for_fault", None)
444
+ if wait_for_fault is not None:
445
+ try:
446
+ # The native exception can arrive while the driver's worker
447
+ # unwinds. Finish that collection before recording this run.
448
+ error = wait_for_fault()
449
+ with self._lock:
450
+ if self._arm_fault_exit and error:
451
+ self._stop_reason = f"not_alive: {error}"
452
+ except Exception:
453
+ logger.warning("Could not collect the final arm fault", exc_info=True)
454
+ try:
455
+ if self._stop_requested is not None:
456
+ self._stop_reason = self._stop_requested
457
+ self._finish_task(self._stop_reason or "stopped")
458
+ self.policy.end_session(self._stop_reason or "stopped")
459
+ finally:
460
+ with self._lock:
461
+ self._mode = IDLE
462
+ self._run_source = None
463
+ if self._stop_reason is None:
464
+ self._stop_reason = "stopped"
465
+ self.records.event(f"Run stopped: {self._stop_reason}",
466
+ "error" if self._stop_reason.startswith(("error:", "not_alive:", "policy_unavailable:")) else "info")
467
+
468
+ def _interrupted(self) -> bool:
469
+ """Interrupt frames and waits on STOP or pause.
470
+
471
+ After a pause-abort the loop parks at the top and, on resume, re-infers from a fresh
472
+ observation rather than replaying a plan made before the world was held still.
473
+ """
474
+ with self._lock:
475
+ if self._abort.is_set():
476
+ return True
477
+ camera_failed = getattr(self.emb.camera, "failure_count", 0) != self._camera_failures
478
+ if camera_failed or not self.emb.alive:
479
+ detail = (getattr(self.emb.camera, "last_error", None) or "camera disconnected"
480
+ if camera_failed else self.emb.last_error or "device unavailable")
481
+ self._arm_fault_exit = (
482
+ not camera_failed and not self.emb.arm.live and bool(self.emb.arm.last_error)
483
+ )
484
+ self._stop_reason = f"not_alive: {detail}"
485
+ self._abort.set()
486
+ return self._abort.is_set() or self._mode == PAUSED
487
+
488
+ # -- guarded manual-motion surface -------------------------------------
489
+ def _require_idle(self) -> None:
490
+ self.emb.require_control()
491
+ if self._mode != IDLE:
492
+ raise LoopRunning(f"loop_running: the policy loop is {self._mode}. Call stop() first.")
493
+
494
+ _WAYPOINT_KEYS = frozenset({"x", "y", "z", "roll", "pitch", "yaw", "gripper", "relative", "blend"})
495
+
496
+ @exclusive
497
+ def move_waypoints(self, waypoints: list[dict]) -> dict:
498
+ """Agent waypoints -> absolute targets, chained: each is completed from the one before
499
+ it (the first from the arm), so omitted axes hold and `relative` offsets accumulate.
500
+ Validation is here, at the trust boundary, before anything moves."""
501
+ self._require_idle()
502
+ if not waypoints:
503
+ raise ValueError("waypoints must not be empty")
504
+ pose = self.emb.arm.get_eef_pose()
505
+ grip = float(self.emb.gripper.get_ratio())
506
+ targets, grips, blends = [], [], []
507
+ for i, wp in enumerate(waypoints):
508
+ unknown = set(wp) - self._WAYPOINT_KEYS
509
+ if unknown:
510
+ raise ValueError(f"waypoint {i}: unknown fields {sorted(unknown)}; "
511
+ f"allowed: {sorted(self._WAYPOINT_KEYS)}")
512
+ axes = {k: wp.get(k) for k in ("x", "y", "z", "roll", "pitch", "yaw")}
513
+ pose = geo.target_pose(pose, **axes, relative=bool(wp.get("relative", False)))
514
+ # Relative waypoints start from the previous effective target, including at a wall.
515
+ pose = workspace_targets([pose], self.cfg.workspace)[0]
516
+ if wp.get("gripper") is not None:
517
+ grip = float(wp["gripper"])
518
+ if not 0.0 <= grip <= 1.0:
519
+ raise ValueError(f"waypoint {i}: gripper must be an opening ratio in [0, 1], got {grip}")
520
+ blend = self.cfg.arm.blend_radius if wp.get("blend") is None else float(wp["blend"])
521
+ if blend < 0.0:
522
+ raise ValueError(f"waypoint {i}: blend must be >= 0 m, got {blend}")
523
+ targets.append(pose)
524
+ grips.append(grip)
525
+ blends.append(blend)
526
+ targets = np.array(targets)
527
+ # Cleared on entry, so a stop() from before this call does not kill it; a stop() from
528
+ # here on is caught by the per-frame check inside the dispatch.
529
+ self.operations.reset_abort()
530
+ return self._move_path(targets, np.array(grips), np.array(blends),
531
+ should_stop=self.operations.abort.is_set)
532
+
533
+ def move_to_pose(self, gripper=None, **kwargs) -> dict:
534
+ """One waypoint: the simple form of move_waypoints, on the same path."""
535
+ out = self.move_waypoints([{**kwargs, "gripper": gripper}])
536
+ wp = out["waypoints"][0]
537
+ return {**out, "target": wp["target"], "distance_m": out["path_m"],
538
+ "gripper": None if gripper is None else wp["gripper"]}
539
+
540
+ def _at_direction_home(self) -> bool:
541
+ position, angle = geo.pose_delta(HOME_POSE7, self.emb.arm.get_eef_pose())
542
+ # Keep each short gesture close to its recorded starting pose.
543
+ return bool(position <= 0.005 and angle <= np.deg2rad(2))
544
+
545
+ def direction_check_status(self) -> dict:
546
+ """Read current Home readiness without connecting or moving hardware."""
547
+ at_home = False
548
+ try:
549
+ if (self.mode == IDLE and self.calibration.phase == "complete"
550
+ and self.emb.calibrated and self.emb.control_ready and self.emb.alive):
551
+ at_home = self._at_direction_home()
552
+ except Exception:
553
+ # Disconnected/stale feedback is normal while the device page polls.
554
+ pass
555
+ return {"at_home": at_home, "active": self._direction_check}
556
+
557
+ @exclusive
558
+ def check_direction(self, direction: str) -> dict:
559
+ """Check task translation and rotation using the policy's full-pose executor.
560
+
561
+ Each gesture moves 5 cm and turns 30 degrees, then returns to calibrated
562
+ Home, keeping the measured gripper opening. Horizontal gestures tilt the
563
+ downward-facing tool toward travel; the upward gesture turns about task Z.
564
+ An explicit 'home' action returns without a gesture.
565
+ Stop interrupts the single path, including all remaining return targets.
566
+ """
567
+ gestures = {"forward": {"y": -0.05, "roll": -30},
568
+ "left": {"x": 0.05, "pitch": -30},
569
+ "up": {"z": 0.05, "yaw": 30}}
570
+ if direction not in (*gestures, "home"):
571
+ raise ValueError("direction must be forward, left, up or home")
572
+ self.operations.reset_abort()
573
+ self._require_idle()
574
+ if not self.emb.calibrated or self.calibration.phase != "complete":
575
+ raise CalibrationRequired("Save coordinate calibration before checking directions")
576
+ self.emb.require_alive()
577
+ self.emb.arm.check_motion_ready()
578
+ if direction != "home" and not self._at_direction_home():
579
+ raise ControlNotReady("Return to Home before checking a direction")
580
+ targets = [HOME_POSE7]
581
+ if direction != "home":
582
+ targets.insert(0, geo.target_pose(HOME_POSE7, **gestures[direction], relative=True))
583
+ grip = float(self.emb.gripper.get_ratio())
584
+ self._direction_check = direction
585
+ try:
586
+ result = self._move_path(targets, [grip] * len(targets), [0.0] * len(targets),
587
+ should_stop=self.operations.abort.is_set, position_only=False,
588
+ workspace_entry=direction == "home")
589
+ return {**result, "direction": direction,
590
+ "at_home": self.direction_check_status()["at_home"]}
591
+ except BaseException:
592
+ try:
593
+ self.emb.hold()
594
+ except Exception:
595
+ logger.exception("Could not hold after a failed direction check")
596
+ raise
597
+ finally:
598
+ self._direction_check = None
599
+
600
+ @exclusive
601
+ def reset(self) -> dict:
602
+ self._require_idle()
603
+ self.operations.reset_abort()
604
+ result = self._return_home(should_stop=self.operations.abort.is_set)
605
+ self._clear_resolved_errors()
606
+ self.records.event("Returned Home")
607
+ return result
608
+
609
+ @exclusive
610
+ def recover(self) -> dict:
611
+ self.emb.require_control()
612
+ self.stop("recover")
613
+ self.operations.reset_abort()
614
+ result = self._recover_and_home(should_stop=self.operations.abort.is_set)
615
+ self._clear_resolved_errors()
616
+ self.records.event("Devices recovered and returned Home")
617
+ return result
618
+
619
+ def _clear_resolved_errors(self) -> None:
620
+ if self.emb.alive and not self.emb.last_error and not self.operations.abort.is_set():
621
+ with self._lock:
622
+ self._stop_reason = None
623
+ self.records.clear_errors()
624
+
625
+ @exclusive
626
+ def preview(self) -> dict:
627
+ if self.mode != IDLE or self.calibration.phase not in {"acquire", "complete"}:
628
+ raise LoopRunning("finish the current operation before connecting preview")
629
+ self.emb.connect_preview()
630
+ for name, error in self.emb.preview_errors.items():
631
+ self.records.event(f"{name.capitalize()} preview unavailable: {error}", "error")
632
+ for name, notice in self.emb.preview_notices.items():
633
+ self.records.event(f"{name.capitalize()}: {notice}")
634
+ state = self.emb.status()
635
+ self.records.event("Preview connected" if state["arm_connected"] or state["camera_connected"] else "Preview unavailable")
636
+ return self.status()
637
+
638
+ @exclusive
639
+ def release(self) -> dict:
640
+ if self.mode != IDLE:
641
+ raise LoopRunning("finish the current operation before releasing devices")
642
+ calibration = self.calibration
643
+ calibration.close()
644
+ calibration.home_pose = calibration.draft = calibration.device = None
645
+ calibration.plane_poses = []
646
+ calibration.guiding_axis = None
647
+ calibration.phase = "complete" if calibration.profile else "acquire"
648
+ calibration.error = calibration.notice = None
649
+ self.emb.control_ready = False
650
+ self.disconnect()
651
+ self.records.event("Devices disconnected")
652
+ return self.status()
653
+
654
+ def connect(self) -> None:
655
+ if self.emb.connected:
656
+ return
657
+ self.records.open()
658
+ try:
659
+ self.policy.connect()
660
+ except PolicyUnavailable as exc:
661
+ self._close_policy()
662
+ logger.warning("policy is not ready; MCP setup remains available: %s", exc)
663
+ except Exception:
664
+ self._close_policy()
665
+ raise
666
+ try:
667
+ self.emb.connect()
668
+ except Exception:
669
+ self._close_policy()
670
+ raise
671
+
672
+ def _close_policy(self) -> None:
673
+ try:
674
+ self.policy.close()
675
+ except Exception:
676
+ logger.warning("policy connection did not close cleanly", exc_info=True)
677
+
678
+ def disconnect(self) -> None:
679
+ try:
680
+ self.emb.disconnect()
681
+ finally:
682
+ self._close_policy()
683
+
684
+ def close(self) -> None:
685
+ try:
686
+ self.stop("shutdown")
687
+ finally:
688
+ try:
689
+ self.calibration.close()
690
+ finally:
691
+ try:
692
+ self.disconnect()
693
+ finally:
694
+ self.tasks.close()
695
+
696
+ def _record_action(self, chunk, result, image_age, image):
697
+ self.records.write(chunk, result, image_age, image,
698
+ state=self.emb.get_state(), control_trace=self._control_trace)
699
+ self._control_trace = []
700
+
701
+
702
+ def call_model(self, instruction: str | None, image: np.ndarray | None = None) -> ActionChunk:
703
+ self.emb.require_alive()
704
+ if image is None:
705
+ image, _ = self.emb.camera.read()
706
+ pose, grip = self.emb.arm.get_eef_pose(), self.emb.gripper.get_ratio()
707
+ if instruction is None:
708
+ with self._lock:
709
+ instruction = self._instruction or ""
710
+ if instruction != self._applied_instruction:
711
+ task = self.tasks.current()
712
+ if self._mode == RUNNING and task and task["instruction"] != instruction.strip():
713
+ self._finish_task("instruction changed")
714
+ self._begin_task(instruction)
715
+ self._applied_instruction = instruction
716
+ self.records.event("Instruction applied")
717
+ return self.policy.infer(pose, grip, image, instruction)
718
+
719
+ def apply_action(self, chunk: ActionChunk, should_stop=None, *, position_only=False,
720
+ workspace_entry=False) -> dict:
721
+ """Dispatch the complete chunk on monotonic ABSOLUTE deadlines.
722
+
723
+ Deadline i is ``t0 + i*dt``, so scheduling error never accumulates; a late frame runs
724
+ immediately rather than being skipped. Skipping would jump over intermediate waypoints
725
+ and the inner loop would accelerate to catch up -- exactly the lunge we do not want.
726
+ `late` is counted and surfaced to diagnose persistent dispatch delays on hardware.
727
+
728
+ `should_stop` is checked per frame, so stop() and pause() both take effect within one
729
+ frame instead of waiting out a chunk. Both are human interventions; making the operator
730
+ watch 1.67 s of motion after asking for a hold is the wrong answer.
731
+ """
732
+ executed = 0
733
+ result = None
734
+
735
+ def progress(count):
736
+ nonlocal executed
737
+ executed = count
738
+
739
+ try:
740
+ self.emb.require_alive()
741
+ if self.mode == IDLE and not workspace_entry:
742
+ self._require_workspace_start()
743
+ result = self._dispatch(chunk.arm, chunk.gripper, should_stop,
744
+ on_executed=progress, position_only=position_only,
745
+ workspace_entry=workspace_entry)
746
+ return result
747
+ finally:
748
+ if chunk.session_id is not None:
749
+ self.policy.record_execution(chunk, executed,
750
+ completed=result is not None and not result["aborted"])
751
+
752
+ def _dispatch(self, poses, grips, should_stop=None, *, on_executed=None, position_only=False,
753
+ workspace_entry=False) -> dict:
754
+ self.emb.arm.drain_trace()
755
+ try:
756
+ result = dispatch(self.emb.arm, poses, grips, gripper=self.emb.gripper,
757
+ should_stop=should_stop, on_executed=on_executed,
758
+ command_lock=self.emb.command_lock, position_only=position_only,
759
+ workspace=None if workspace_entry else self.cfg.workspace)
760
+ self._observation_after = result["action_period_completed_at_monotonic"] or time.monotonic()
761
+ return result
762
+ finally:
763
+ self._control_trace = self.emb.arm.drain_trace()
764
+
765
+ def _move_path(self, targets7, grips, blends, should_stop=None, *, position_only=False,
766
+ workspace_entry=False) -> dict:
767
+ """Drive through absolute pose7d waypoints as ONE motion, compiled to a chunk.
768
+
769
+ Deliberately NOT one `servo_pose` per waypoint: `servoL` assumes small increments at
770
+ rate, so a far target makes the UR sprint at its velocity ceiling; on Franka it would
771
+ saturate the bounded IK and discard the intended path and timing. Streaming an
772
+ interpolated path instead keeps the whole move inside the compliant control loop.
773
+ That matters more here than
774
+ anywhere else: this is the one path where a pose comes straight from a language model.
775
+
776
+ The track is an `ActionChunk` and goes through `apply_action` and the jsonl log, so an
777
+ agent-authored motion and a policy chunk are the same thing to everything downstream.
778
+ """
779
+ self.emb.require_alive()
780
+ cfg = self.cfg
781
+ start = self.emb.arm.get_eef_pose()
782
+ targets7 = workspace_targets(targets7, cfg.workspace)
783
+ image, image_age = self.emb.camera.read()
784
+ plan = geo.plan_path(
785
+ start, targets7, self.emb.gripper.get_ratio(), grips, blends,
786
+ speed=cfg.arm.cartesian_speed, angular_speed=cfg.arm.cartesian_angular_speed,
787
+ gripper_travel_s=cfg.gripper.full_travel_s, dt=ACTION_DT,
788
+ )
789
+ chunk = ActionChunk(plan.poses, plan.grips, 0.0, "waypoints")
790
+ result = self.apply_action(chunk, should_stop=should_stop, position_only=position_only,
791
+ workspace_entry=workspace_entry)
792
+ done = result["executed"]
793
+ out = {
794
+ **result,
795
+ "from": geo.pose7_to_xyzrpy(start).round(4).tolist(),
796
+ "waypoints": [
797
+ {"target": geo.pose7_to_xyzrpy(t).round(4).tolist(),
798
+ "gripper": round(float(g), 4), "blend": round(float(r), 4)}
799
+ for t, g, r in zip(targets7, grips, plan.blends)
800
+ ],
801
+ "reached": int(plan.passed[done - 1]) if done else 0,
802
+ "path_m": round(plan.path_m, 4),
803
+ "planned_s": round(plan.planned_s, 2),
804
+ }
805
+ self._record_action(chunk, out, image_age, image)
806
+ return out
807
+
808
+ def step(self, instruction: str | None, should_stop=None, on_phase=None) -> dict:
809
+ """One serial iteration: infer, execute, then hold before the next fresh sample."""
810
+ observation = self.emb.camera.read_after(
811
+ self._observation_after, self.cfg.camera.stale_after_s, should_stop,
812
+ )
813
+ if observation is None:
814
+ return {"executed": 0, "late_frames": 0, "aborted": True, "elapsed_s": 0.0,
815
+ "infer_ms": 0.0, "chunk_len": 0}
816
+ image, image_age = observation
817
+ if on_phase is not None:
818
+ on_phase("inference")
819
+ chunk = self.call_model(instruction, image)
820
+ try:
821
+ output = chunk.summary(self.emb.arm.get_eef_pose(), self.emb.gripper.get_ratio())
822
+ if on_phase is not None:
823
+ on_phase("execution", request_ms=chunk.request_ms)
824
+ except Exception:
825
+ if chunk.session_id is not None:
826
+ self.policy.record_execution(chunk, 0, completed=False)
827
+ raise
828
+ action_started = time.perf_counter()
829
+ result = self.apply_action(chunk, should_stop=should_stop)
830
+ action_ms = (time.perf_counter() - action_started) * 1e3
831
+ pause_s = self.cfg.runtime.inter_chunk_pause_s
832
+ if result["executed"] and not result["aborted"] and pause_s:
833
+ if on_phase is not None:
834
+ on_phase("inter_chunk_pause")
835
+ deadline = self._observation_after + pause_s
836
+ wait_until(deadline, should_stop)
837
+ # Reject frames begun during the pause. call_model samples arm/gripper only
838
+ # after this boundary's new camera frame arrives on the next iteration.
839
+ self._observation_after = time.monotonic()
840
+ self._record_action(chunk, result, image_age, image)
841
+ return {**result, "infer_ms": chunk.infer_ms, "chunk_len": len(chunk),
842
+ "_monitor": {"action_ms": round(action_ms, 1),
843
+ "request_ms": round(chunk.request_ms, 1) if chunk.request_ms is not None else None,
844
+ "image_age_ms": round(image_age * 1e3, 1),
845
+ "request_id": chunk.request_id, "output": output}}
846
+
847
+ def _return_home(self, should_stop=None) -> dict:
848
+ """Open the gripper and return along the Cartesian path to training Home."""
849
+ self.emb.require_control()
850
+ self.emb.arm.check_motion_ready()
851
+ try:
852
+ self.emb.gripper.move_blocking(self.cfg.arm.home_gripper, should_stop=should_stop)
853
+ result = self._move_path([HOME_POSE7], [self.cfg.arm.home_gripper], [0.0],
854
+ should_stop=should_stop, position_only=True, workspace_entry=True)
855
+ if result["aborted"]:
856
+ raise RuntimeError("Return Home was stopped")
857
+ wait_for_pose(self.emb.arm, HOME_POSE7, should_stop)
858
+ except BaseException:
859
+ try:
860
+ self.emb.hold()
861
+ except Exception:
862
+ logger.exception("Could not confirm the stop after a failed Home return")
863
+ raise
864
+ self._observation_after = time.monotonic()
865
+ result = {"home_pose7": list(HOME_POSE7), "eef_frame": TRAINING_FRAME,
866
+ "gripper": self.cfg.arm.home_gripper}
867
+ if (should_stop is None or not should_stop()) and (zero_ft := getattr(self.emb.arm, "zero_ft", None)):
868
+ try:
869
+ zero_ft()
870
+ except Exception as exc:
871
+ result["warning"] = f"Home reached; force sensor zeroing failed: {exc}"
872
+ self.records.event(result["warning"], "warning")
873
+ return result
874
+
875
+ def _recover_and_home(self, should_stop=None) -> dict:
876
+ """Recover the driver, then follow the Cartesian path to training Home.
877
+
878
+ Reconnect all components when control was released, so camera and policy
879
+ resources and the embodiment lifecycle are restored together.
880
+ """
881
+ self.emb.require_control()
882
+ if should_stop is not None and should_stop():
883
+ raise RuntimeError("recovery_cancelled")
884
+ policy_ready = self.policy.ready
885
+ if not self.emb.connected:
886
+ self.connect()
887
+ elif not self.emb.camera.live or not policy_ready:
888
+ # A stale camera/policy is part of the transaction, not an isolated arm fault.
889
+ # Reconnect all parts in the same order used at startup and roll back together.
890
+ self.disconnect()
891
+ self.connect()
892
+ else:
893
+ if self.cfg.arm.type == "ur":
894
+ self.emb.arm.recover(should_stop=should_stop)
895
+ else:
896
+ self.emb.arm.recover()
897
+ if should_stop is not None and should_stop():
898
+ raise RuntimeError("recovery_cancelled")
899
+ self.emb.gripper.recover()
900
+ result = self._return_home(should_stop=should_stop)
901
+ return {**result, "alive": self.emb.alive, "last_error": self.emb.last_error}
902
+
903
+
904
+ def build(cfg: Config, *, config_path: str | Path | None = None) -> Runtime:
905
+ emb = build_embodiment(cfg)
906
+ policy = OpenAIChatPolicyClient(
907
+ cfg.policy.url, cfg.policy.token_file, cfg.policy.model,
908
+ image_height=cfg.policy.image_height, timeout_s=cfg.policy.timeout_s,
909
+ use_env_proxy=cfg.policy.use_env_proxy,
910
+ )
911
+ return Runtime(emb, policy, config_path=config_path)