agenticros 0.1.18 → 0.1.19

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 (96) hide show
  1. package/dist/__tests__/robot-config.test.d.ts +2 -0
  2. package/dist/__tests__/robot-config.test.d.ts.map +1 -0
  3. package/dist/__tests__/robot-config.test.js +374 -0
  4. package/dist/__tests__/robot-config.test.js.map +1 -0
  5. package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
  6. package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
  7. package/dist/__tests__/transport-shorthand.test.js +88 -0
  8. package/dist/__tests__/transport-shorthand.test.js.map +1 -0
  9. package/dist/commands/robots.d.ts +57 -0
  10. package/dist/commands/robots.d.ts.map +1 -0
  11. package/dist/commands/robots.js +678 -0
  12. package/dist/commands/robots.js.map +1 -0
  13. package/dist/index.js +28 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/util/mcp-discovery.d.ts +57 -0
  16. package/dist/util/mcp-discovery.d.ts.map +1 -0
  17. package/dist/util/mcp-discovery.js +153 -0
  18. package/dist/util/mcp-discovery.js.map +1 -0
  19. package/dist/util/robot-config.d.ts +167 -0
  20. package/dist/util/robot-config.d.ts.map +1 -0
  21. package/dist/util/robot-config.js +375 -0
  22. package/dist/util/robot-config.js.map +1 -0
  23. package/dist/util/transport-shorthand.d.ts +66 -0
  24. package/dist/util/transport-shorthand.d.ts.map +1 -0
  25. package/dist/util/transport-shorthand.js +102 -0
  26. package/dist/util/transport-shorthand.js.map +1 -0
  27. package/package.json +2 -1
  28. package/runtime/BUNDLE.json +1 -1
  29. package/runtime/README.md +4 -0
  30. package/runtime/package.json +6 -1
  31. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  32. package/runtime/packages/agenticros/package.json +2 -1
  33. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  34. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  35. package/runtime/packages/agenticros/src/service.ts +120 -2
  36. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  37. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  38. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  39. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  40. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  41. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  42. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  45. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  47. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  48. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  49. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  51. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  52. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  53. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  54. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  55. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  56. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  57. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  58. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  59. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  60. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  61. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  62. package/runtime/packages/agenticros-gemini/package.json +1 -0
  63. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  64. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  65. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  66. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  67. package/runtime/packages/core/package.json +1 -1
  68. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  69. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  70. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  71. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  72. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  73. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  74. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  75. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  76. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  77. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  78. package/runtime/packages/core/src/capabilities.ts +340 -0
  79. package/runtime/packages/core/src/config.ts +220 -38
  80. package/runtime/packages/core/src/discovery.ts +193 -0
  81. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  82. package/runtime/packages/core/src/index.ts +67 -1
  83. package/runtime/packages/core/src/mission-registry.ts +142 -0
  84. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  85. package/runtime/packages/core/src/mission.ts +574 -0
  86. package/runtime/packages/core/src/planner/index.ts +564 -0
  87. package/runtime/packages/core/src/robots.ts +282 -0
  88. package/runtime/packages/core/src/topic-utils.ts +47 -9
  89. package/runtime/packages/core/src/transport-pool.ts +171 -0
  90. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  91. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  92. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  93. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  94. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  95. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  96. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Multi-robot support — Phase 1.d of the AgenticROS strategy.
3
+ *
4
+ * For most deployments today the gateway talks to a single robot, so
5
+ * config.robot (a single object) is all that's used. Phase 1.d adds a
6
+ * `robots` array to the config so a single gateway / chat session can
7
+ * address multiple robots by id.
8
+ *
9
+ * This module is the source of truth for that resolution: from the raw
10
+ * AgenticROSConfig, what robots does the agent see, and which one is the
11
+ * active default? Adapters call into it from:
12
+ * - `ros2_list_robots` (this phase) — list everything the gateway knows about.
13
+ * - `ros2_list_capabilities(robot_id?)` (next iteration) — scope to one robot.
14
+ * - `run_mission` (next iteration) — `mission.robot_id` field.
15
+ * - per-tool `robot_id` (next iteration) — `ros2_publish`, `ros2_subscribe_once`,
16
+ * `ros2_camera_snapshot`, etc.
17
+ *
18
+ * Backwards compatibility: when `robots` is empty (i.e. nothing in
19
+ * config.robots), this module synthesises a one-entry list from the
20
+ * legacy `config.robot` object so old configs and the single-robot
21
+ * mental model keep working unchanged. The synthesised entry is marked
22
+ * default so it's selected by `getActiveRobotId()`.
23
+ */
24
+
25
+ import type { AgenticROSConfig } from "./config.js";
26
+ import { getTransportConfig } from "./config.js";
27
+ import type { TransportConfig } from "./transport/types.js";
28
+
29
+ /** Sensor/hardware tags on a robot (Phase 1.e). */
30
+ export interface RobotSensors {
31
+ /** Intel RealSense depth+RGB camera. */
32
+ has_realsense: boolean;
33
+ /** 2D or 3D LiDAR sensor. */
34
+ has_lidar: boolean;
35
+ /** Robotic manipulator (arm) attached. */
36
+ has_arm: boolean;
37
+ }
38
+
39
+ /** A resolved robot — what the agent + adapters operate on. */
40
+ export interface ResolvedRobot {
41
+ /** Stable, human-readable identifier. Falls back to namespace when not set. */
42
+ id: string;
43
+ /** Display name used by the agent in chat replies. */
44
+ name: string;
45
+ /** ROS2 topic namespace prefix (e.g. "robot3946b404..." → /robot3946.../cmd_vel). */
46
+ namespace: string;
47
+ /** Default camera topic for `ros2_camera_snapshot`. Empty string when unset. */
48
+ cameraTopic: string;
49
+ /**
50
+ * Phase 1.e robot kind ("amr" | "arm" | "drone" | "rover" | …).
51
+ * Free-form string. Defaults to "amr" for back-compat (the legacy
52
+ * fallback synthesises this too).
53
+ */
54
+ kind: string;
55
+ /** Phase 1.e sensor/hardware tags. Defaults to all-false. */
56
+ sensors: RobotSensors;
57
+ /**
58
+ * Phase 1.e optional per-robot capability allowlist. When `undefined`
59
+ * the robot inherits the global capability registry (the common
60
+ * case); when set it's the exact list `ros2_find_robots_for` will
61
+ * filter against for this robot.
62
+ */
63
+ capabilities?: string[];
64
+ /**
65
+ * Source tag — handy for diagnostics ("Why is this robot in the list?").
66
+ * - "config": from config.robots[]
67
+ * - "legacy": synthesised from the legacy single config.robot object
68
+ */
69
+ source: "config" | "legacy";
70
+ }
71
+
72
+ /**
73
+ * Read every robot the config knows about, in declaration order, with
74
+ * the legacy single-robot fallback applied when config.robots is empty.
75
+ *
76
+ * Never throws — an empty list is returned when neither config.robots
77
+ * nor config.robot.namespace is meaningfully set (and the synthesised
78
+ * fallback uses "default" as the id in that degenerate case).
79
+ */
80
+ /** All-false default for {@link RobotSensors}. */
81
+ const DEFAULT_SENSORS: RobotSensors = {
82
+ has_realsense: false,
83
+ has_lidar: false,
84
+ has_arm: false,
85
+ };
86
+
87
+ export function listRobots(config: AgenticROSConfig): ResolvedRobot[] {
88
+ const explicit = Array.isArray(config.robots) ? config.robots : [];
89
+ if (explicit.length > 0) {
90
+ return explicit.map((r) => ({
91
+ id: String(r.id),
92
+ name: r.name ?? "Robot",
93
+ namespace: r.namespace ?? "",
94
+ cameraTopic: r.cameraTopic ?? "",
95
+ kind: r.kind ?? "amr",
96
+ sensors: { ...DEFAULT_SENSORS, ...(r.sensors ?? {}) },
97
+ capabilities: r.capabilities,
98
+ source: "config" as const,
99
+ }));
100
+ }
101
+
102
+ // Legacy fallback — synthesise one entry from config.robot. The
103
+ // legacy schema doesn't carry kind/sensors/capabilities, so we
104
+ // default them: kind="amr" (every existing real-robot deployment we
105
+ // ship is an AMR with RealSense) and sensors all-false (which is
106
+ // overly conservative but won't surface a false positive in
107
+ // ros2_find_robots_for). Users on multi-robot deployments will have
108
+ // promoted into config.robots[] anyway, where the fields are
109
+ // explicit.
110
+ const legacy = config.robot ?? { name: "Robot", namespace: "", cameraTopic: "" };
111
+ const id = (legacy.namespace?.trim() || "default");
112
+ return [
113
+ {
114
+ id,
115
+ name: legacy.name ?? "Robot",
116
+ namespace: legacy.namespace ?? "",
117
+ cameraTopic: legacy.cameraTopic ?? "",
118
+ kind: "amr",
119
+ sensors: { ...DEFAULT_SENSORS },
120
+ source: "legacy",
121
+ },
122
+ ];
123
+ }
124
+
125
+ /**
126
+ * Pick the active robot's id according to these rules, in order:
127
+ * 1. `override` argument (when truthy) — usually a per-tool-call robot_id.
128
+ * 2. An entry in config.robots with `default: true`.
129
+ * 3. The first entry in the resolved list (which includes the legacy
130
+ * fallback when nothing else is configured).
131
+ *
132
+ * Throws when no robots exist at all — that should be impossible because
133
+ * the legacy fallback always produces at least one — but the guard is
134
+ * there for robustness against a corrupted config.
135
+ */
136
+ export function getActiveRobotId(config: AgenticROSConfig, override?: string): string {
137
+ if (override && override.trim().length > 0) return override.trim();
138
+ const explicit = Array.isArray(config.robots) ? config.robots : [];
139
+ const flagged = explicit.find((r) => r.default === true);
140
+ if (flagged) return flagged.id;
141
+ const robots = listRobots(config);
142
+ if (robots.length === 0) {
143
+ throw new Error("No robots configured (config.robots is empty and config.robot has no namespace).");
144
+ }
145
+ return robots[0].id;
146
+ }
147
+
148
+ /**
149
+ * Convenience wrapper for tool handlers: extract an optional `robot_id`
150
+ * (a string) from a tool-args record and call `resolveRobot`. Returns
151
+ * the active robot when `robot_id` is missing or not a string.
152
+ *
153
+ * Throws — with the known robot ids listed in the error — when
154
+ * `robot_id` is set to an unknown value. Adapters surface that as a
155
+ * tool error so the agent self-corrects via `ros2_list_robots`.
156
+ */
157
+ export function resolveRobotFromArgs(
158
+ config: AgenticROSConfig,
159
+ args: Record<string, unknown>,
160
+ ): ResolvedRobot {
161
+ const raw = args["robot_id"];
162
+ const robotId = typeof raw === "string" ? raw : undefined;
163
+ return resolveRobot(config, robotId);
164
+ }
165
+
166
+ /**
167
+ * Resolve a robot to its full record by id. When `robotId` is omitted or
168
+ * empty, the active robot is returned. When `robotId` is provided but
169
+ * doesn't match any configured robot, throws with the available ids in
170
+ * the message so the agent can correct itself.
171
+ */
172
+ export function resolveRobot(config: AgenticROSConfig, robotId?: string): ResolvedRobot {
173
+ const robots = listRobots(config);
174
+ if (!robotId || robotId.trim().length === 0) {
175
+ const activeId = getActiveRobotId(config);
176
+ const found = robots.find((r) => r.id === activeId);
177
+ if (!found) {
178
+ throw new Error(`Active robot id "${activeId}" is not in the resolved list. This is a bug.`);
179
+ }
180
+ return found;
181
+ }
182
+ const trimmed = robotId.trim();
183
+ const found = robots.find((r) => r.id === trimmed);
184
+ if (!found) {
185
+ const known = robots.map((r) => r.id).join(", ");
186
+ throw new Error(`Unknown robot_id "${trimmed}". Known ids: ${known || "(none)"}. Use ros2_list_robots to discover.`);
187
+ }
188
+ return found;
189
+ }
190
+
191
+ /**
192
+ * Phase 1.d-resolve helper: compute the effective `TransportConfig` for
193
+ * a given robot, honouring an optional per-robot override in
194
+ * `config.robots[i].transport`.
195
+ *
196
+ * Precedence (most specific wins):
197
+ * 1. The robot's `transport.<mode>` sub-section (e.g. the override's
198
+ * `zenoh: { routerEndpoint: ... }`).
199
+ * 2. The top-level `config.<mode>` section (e.g. the global
200
+ * `config.zenoh`).
201
+ *
202
+ * When the robot has no `transport` override at all, this returns the
203
+ * global transport from `getTransportConfig(config)` — so single-robot
204
+ * deployments behave exactly as before.
205
+ *
206
+ * Why this lives here (and not next to `createTransport`):
207
+ * - The resolver owns "which robot are we talking about?"; the
208
+ * transport factory owns "given a TransportConfig, build an
209
+ * instance". Mixing them couples a multi-robot decision into the
210
+ * low-level transport layer, which is wrong: adapters that don't
211
+ * care about per-robot transports keep using `createTransport` and
212
+ * never see this helper.
213
+ * - Callers that DO want per-robot pools call this to materialise the
214
+ * right `TransportConfig` per id, then hand it to `createTransport`.
215
+ *
216
+ * Throws when `robotId` is given but doesn't match any configured robot
217
+ * (delegated to `resolveRobot`'s error path).
218
+ */
219
+ export function getTransportConfigForRobot(
220
+ config: AgenticROSConfig,
221
+ robotId?: string,
222
+ ): TransportConfig {
223
+ // No explicit robots[] AND no override possible — fast path.
224
+ const explicit = Array.isArray(config.robots) ? config.robots : [];
225
+ if (explicit.length === 0) return getTransportConfig(config);
226
+
227
+ // Resolve the id to the *raw* entry (not the synthesised ResolvedRobot,
228
+ // because that strips the override). `resolveRobot` validates the id
229
+ // and throws on unknown — we reuse it for that side effect.
230
+ const robot = resolveRobot(config, robotId);
231
+ const raw = explicit.find((r) => r.id === robot.id);
232
+ if (!raw || !raw.transport) return getTransportConfig(config);
233
+
234
+ // Build a synthetic config whose top-level transport mirrors the
235
+ // override, then run it through the standard `getTransportConfig`
236
+ // path. This keeps the "what shape does TransportConfig take?" logic
237
+ // in one place — if a new transport mode is added, only
238
+ // getTransportConfig needs to change.
239
+ const override = raw.transport;
240
+ const synthetic: AgenticROSConfig = {
241
+ ...config,
242
+ transport: { mode: override.mode },
243
+ rosbridge:
244
+ override.mode === "rosbridge" && override.rosbridge
245
+ ? { ...config.rosbridge, ...override.rosbridge }
246
+ : config.rosbridge,
247
+ local:
248
+ override.mode === "local" && override.local
249
+ ? { ...config.local, ...override.local }
250
+ : config.local,
251
+ zenoh:
252
+ override.mode === "zenoh" && override.zenoh
253
+ ? { ...config.zenoh, ...override.zenoh }
254
+ : config.zenoh,
255
+ webrtc:
256
+ override.mode === "webrtc" && override.webrtc
257
+ ? { ...config.webrtc, ...override.webrtc }
258
+ : config.webrtc,
259
+ };
260
+ return getTransportConfig(synthetic);
261
+ }
262
+
263
+ /**
264
+ * True when the named robot (or active robot when `robotId` is omitted)
265
+ * has a per-robot transport override. Adapters use this as a cheap
266
+ * branch to decide between "reuse the global transport" (fast path) and
267
+ * "build a per-robot one via `getTransportConfigForRobot`" (pool path).
268
+ */
269
+ export function hasRobotTransportOverride(
270
+ config: AgenticROSConfig,
271
+ robotId?: string,
272
+ ): boolean {
273
+ const explicit = Array.isArray(config.robots) ? config.robots : [];
274
+ if (explicit.length === 0) return false;
275
+ try {
276
+ const robot = resolveRobot(config, robotId);
277
+ const raw = explicit.find((r) => r.id === robot.id);
278
+ return Boolean(raw?.transport);
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
@@ -1,5 +1,20 @@
1
1
  import type { AgenticROSConfig } from "./config.js";
2
2
 
3
+ /**
4
+ * Topic-utility module — the single source of truth for namespace
5
+ * prefixing across all adapters and the mission runner.
6
+ *
7
+ * Phase 1.d (multi-robot) extends every public helper to accept *either*
8
+ * the full AgenticROSConfig (uses config.robot.namespace, the legacy
9
+ * single-robot behaviour) *or* a bare namespace string. The string-arg
10
+ * form is what per-tool `robot_id` routing goes through: callers resolve
11
+ * a `ResolvedRobot` via `resolveRobot(config, robot_id)` and pass
12
+ * `robot.namespace` directly.
13
+ *
14
+ * Every existing call site continues to work unchanged because the
15
+ * config-arg form is preserved verbatim.
16
+ */
17
+
3
18
  /**
4
19
  * Normalize a ROS 2 topic name to a canonical form (leading slash, no trailing slash).
5
20
  */
@@ -16,18 +31,33 @@ function isRootLevelTopic(normalized: string): boolean {
16
31
  return withoutLeading.length > 0 && !withoutLeading.includes("/");
17
32
  }
18
33
 
34
+ /**
35
+ * Resolve a namespace from either a string or an AgenticROSConfig. The
36
+ * config form pulls from config.robot.namespace (legacy single-robot
37
+ * behaviour); the string form is taken verbatim. Empty/whitespace
38
+ * collapses to "" so the rest of the prefix logic short-circuits.
39
+ */
40
+ function resolveNamespace(target: AgenticROSConfig | string): string {
41
+ if (typeof target === "string") return target.trim();
42
+ return (target.robot?.namespace ?? "").trim();
43
+ }
44
+
19
45
  /**
20
46
  * Apply robot namespace to a topic (or service/action name) when configured.
21
- * If config.robot.namespace is set and the name is root-level (e.g. cmd_vel, battery_state),
47
+ * If the namespace is set and the name is root-level (e.g. cmd_vel, battery_state),
22
48
  * returns /<namespace>/<name>. Otherwise returns the normalized name as-is.
23
49
  *
50
+ * The first argument may be either an `AgenticROSConfig` (legacy single-
51
+ * robot path) or a bare namespace string (used by per-tool `robot_id`
52
+ * routing — pass `resolveRobot(config, robot_id).namespace`).
53
+ *
24
54
  * Example: namespace "robot-uuid", topic "/cmd_vel" -> "/robot-uuid/cmd_vel"
25
55
  * Example: namespace "", topic "/cmd_vel" -> "/cmd_vel"
26
56
  * Example: namespace "robot-uuid", topic "/robot-uuid/odom" -> "/robot-uuid/odom" (unchanged)
27
57
  */
28
- export function toNamespacedTopic(config: AgenticROSConfig, topic: string): string {
58
+ export function toNamespacedTopic(target: AgenticROSConfig | string, topic: string): string {
29
59
  const normalized = normalizeTopic(topic);
30
- const ns = (config.robot?.namespace ?? "").trim();
60
+ const ns = resolveNamespace(target);
31
61
  if (!ns) return normalized;
32
62
  if (!isRootLevelTopic(normalized)) return normalized;
33
63
  const segment = normalized.replace(/^\/+/, "");
@@ -37,16 +67,19 @@ export function toNamespacedTopic(config: AgenticROSConfig, topic: string): stri
37
67
  /**
38
68
  * Apply robot namespace to any topic when configured (for transport subscribe/publish).
39
69
  * Use this when the robot publishes/subscribes all topics under a namespace (e.g. Zenoh with
40
- * zenoh-bridge-ros2dds or rmw_zenoh). If config.robot.namespace is set, returns /<namespace>/<topic>
70
+ * zenoh-bridge-ros2dds or rmw_zenoh). If a namespace is set, returns /<namespace>/<topic>
41
71
  * unless the topic already starts with /<namespace>/.
42
72
  *
73
+ * The first argument may be either an `AgenticROSConfig` or a bare
74
+ * namespace string (per-robot routing).
75
+ *
43
76
  * Example: namespace "robot-uuid", topic "/cmd_vel" -> "/robot-uuid/cmd_vel"
44
77
  * Example: namespace "robot-uuid", topic "/camera/camera/color/image_raw/compressed" -> "/robot-uuid/camera/camera/color/image_raw/compressed"
45
78
  * Example: namespace "robot-uuid", topic "/robot-uuid/odom" -> "/robot-uuid/odom" (unchanged)
46
79
  */
47
- export function toNamespacedTopicFull(config: AgenticROSConfig, topic: string): string {
80
+ export function toNamespacedTopicFull(target: AgenticROSConfig | string, topic: string): string {
48
81
  const normalized = normalizeTopic(topic);
49
- const ns = (config.robot?.namespace ?? "").trim();
82
+ const ns = resolveNamespace(target);
50
83
  if (!ns) return normalized;
51
84
  const withoutLeading = normalized.replace(/^\/+/, "");
52
85
  if (!withoutLeading) return normalized;
@@ -57,6 +90,8 @@ export function toNamespacedTopicFull(config: AgenticROSConfig, topic: string):
57
90
  /**
58
91
  * Canonical topic string for teleop UI and ?topic= query params: leading slash, no robot namespace prefix.
59
92
  * Subscribe/publish still uses {@link toNamespacedTopicFull} on the server so Zenoh keys match the bridge.
93
+ *
94
+ * Teleop is single-robot today, so this still takes the full config.
60
95
  */
61
96
  export function toTeleopCameraTopicShort(config: AgenticROSConfig, topic: string): string {
62
97
  const normalized = normalizeTopic(topic);
@@ -75,12 +110,15 @@ export function toTeleopCameraTopicShort(config: AgenticROSConfig, topic: string
75
110
  /**
76
111
  * ROS topic to use when subscribing to camera streams (Zenoh / DDS).
77
112
  * Unlike {@link toNamespacedTopicFull}, common sensor topics stay at the graph root (`/camera/...`, `/zed/...`)
78
- * even when `robot.namespace` is set for cmd_vel. If the topic already starts with `/<namespace>/`, it is left as-is.
113
+ * even when the namespace is set for cmd_vel. If the topic already starts with `/<namespace>/`, it is left as-is.
79
114
  * Other multi-segment paths get the namespace prefix (same as Full) for odd layouts.
115
+ *
116
+ * The first argument may be either an `AgenticROSConfig` or a bare
117
+ * namespace string (per-robot routing).
80
118
  */
81
- export function resolveCameraSubscribeTopic(config: AgenticROSConfig, topic: string): string {
119
+ export function resolveCameraSubscribeTopic(target: AgenticROSConfig | string, topic: string): string {
82
120
  const normalized = normalizeTopic(topic);
83
- const ns = (config.robot?.namespace ?? "").trim();
121
+ const ns = resolveNamespace(target);
84
122
  if (!ns) return normalized;
85
123
  const withoutLeading = normalized.replace(/^\/+/, "");
86
124
  if (!withoutLeading) return normalized;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `TransportPool` — Phase 1.d shared multi-robot transport pool.
3
+ *
4
+ * Lives in `@agenticros/core` so every adapter (Claude Code, Gemini,
5
+ * future ones) can route per-robot transports through the same battle-
6
+ * tested implementation. OpenClaw's plugin uses a thinner add-on
7
+ * because its existing service has long-lived eager-connect / poll
8
+ * semantics that don't compose cleanly with a generic pool.
9
+ *
10
+ * Semantics:
11
+ *
12
+ * - Pool key is `__global__` when the robot has no per-robot
13
+ * `transport` override in config — every robot then shares one
14
+ * connection (the legacy / single-transport behaviour). Opting
15
+ * into `config.robots[]` is NOT a breaking change for that path.
16
+ *
17
+ * - Pool key is `robot.id` when the robot DOES declare an override.
18
+ * A distinct transport is materialised on first use and cached
19
+ * for subsequent tool calls.
20
+ *
21
+ * - Acquires are lazy — the constructor never opens a connection.
22
+ * The first call to `acquire()` for a given key triggers the
23
+ * create + connect dance, gated by `connectWithTimeout()`.
24
+ *
25
+ * - Concurrent first-acquires on the same key share one in-flight
26
+ * connect promise — important when an MCP server gets two
27
+ * near-simultaneous tool calls right after startup.
28
+ *
29
+ * - Cached entries that drop to a non-`connected` state are
30
+ * rebuilt on the next acquire (self-heal — matches the pre-pool
31
+ * `connect()` behaviour).
32
+ *
33
+ * - `disconnectAll()` drains every entry and is safe to call on
34
+ * SIGINT/SIGTERM. Errors on individual disconnects are swallowed
35
+ * so one misbehaving transport can't block the others from
36
+ * cleaning up.
37
+ *
38
+ * The default factory is `createTransport` from `./transport/factory`.
39
+ * Adapter shutdowns / tests can inject a fake factory by passing one
40
+ * to the constructor — see packages/core/src/__tests__/transport-pool.test.ts
41
+ * for the unit-test seam.
42
+ */
43
+
44
+ import type {
45
+ AgenticROSConfig,
46
+ ResolvedRobot,
47
+ RosTransport,
48
+ TransportConfig,
49
+ } from "./index.js";
50
+ import {
51
+ createTransport,
52
+ getTransportConfig,
53
+ getTransportConfigForRobot,
54
+ hasRobotTransportOverride,
55
+ resolveRobot,
56
+ } from "./index.js";
57
+
58
+ const CONNECT_TIMEOUT_MS = 15_000;
59
+ export const TRANSPORT_POOL_GLOBAL_KEY = "__global__";
60
+
61
+ export type TransportFactory = (cfg: TransportConfig) => Promise<RosTransport>;
62
+
63
+ export class TransportPool {
64
+ private readonly entries = new Map<string, RosTransport>();
65
+ /** Promises kept while a connection is in flight, so concurrent acquires share the same connect. */
66
+ private readonly inFlight = new Map<string, Promise<RosTransport>>();
67
+
68
+ constructor(private readonly factory: TransportFactory = createTransport) {}
69
+
70
+ /**
71
+ * Acquire (lazy-connect on first call) the transport for this robot.
72
+ * See the module docstring for the full key + caching contract.
73
+ */
74
+ async acquire(config: AgenticROSConfig, robot: ResolvedRobot): Promise<RosTransport> {
75
+ const key = hasRobotTransportOverride(config, robot.id)
76
+ ? robot.id
77
+ : TRANSPORT_POOL_GLOBAL_KEY;
78
+ return this.acquireByKey(key, () =>
79
+ key === TRANSPORT_POOL_GLOBAL_KEY
80
+ ? getTransportConfig(config)
81
+ : getTransportConfigForRobot(config, robot.id),
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Pre-warm the active robot's transport at server start — used by
87
+ * adapters that want to pay the connect-latency tax upfront instead
88
+ * of on the first tool call.
89
+ */
90
+ async connectActive(config: AgenticROSConfig): Promise<void> {
91
+ const robot = resolveRobot(config);
92
+ await this.acquire(config, robot);
93
+ }
94
+
95
+ /**
96
+ * Disconnect every transport in the pool. Safe to call multiple
97
+ * times. Errors on individual disconnects are swallowed.
98
+ */
99
+ async disconnectAll(): Promise<void> {
100
+ const pending: Promise<unknown>[] = [];
101
+ for (const t of this.entries.values()) {
102
+ pending.push(
103
+ t.disconnect().catch(() => {
104
+ /* best-effort drain */
105
+ }),
106
+ );
107
+ }
108
+ this.entries.clear();
109
+ this.inFlight.clear();
110
+ await Promise.all(pending);
111
+ }
112
+
113
+ /** Visible for tests: how many distinct transports are alive right now. */
114
+ get size(): number {
115
+ return this.entries.size;
116
+ }
117
+
118
+ /** Visible for tests: list of pool keys currently cached. */
119
+ keys(): string[] {
120
+ return Array.from(this.entries.keys());
121
+ }
122
+
123
+ private async acquireByKey(
124
+ key: string,
125
+ resolveCfg: () => TransportConfig,
126
+ ): Promise<RosTransport> {
127
+ const cached = this.entries.get(key);
128
+ if (cached && cached.getStatus() === "connected") return cached;
129
+
130
+ // A second concurrent acquire on the same key joins the in-flight
131
+ // connect rather than starting a duplicate.
132
+ const flight = this.inFlight.get(key);
133
+ if (flight) return flight;
134
+
135
+ if (cached) {
136
+ // Stale entry (e.g. router restarted under us). Drop it cleanly.
137
+ this.entries.delete(key);
138
+ cached.disconnect().catch(() => {});
139
+ }
140
+
141
+ const connectFlight = (async (): Promise<RosTransport> => {
142
+ const cfg = resolveCfg();
143
+ const t = await this.factory(cfg);
144
+ await connectWithTimeout(t);
145
+ this.entries.set(key, t);
146
+ return t;
147
+ })();
148
+ this.inFlight.set(key, connectFlight);
149
+ try {
150
+ return await connectFlight;
151
+ } finally {
152
+ this.inFlight.delete(key);
153
+ }
154
+ }
155
+ }
156
+
157
+ async function connectWithTimeout(t: RosTransport): Promise<void> {
158
+ const timeout = new Promise<never>((_, reject) => {
159
+ setTimeout(
160
+ () =>
161
+ reject(
162
+ new Error(
163
+ `Transport connection timed out after ${CONNECT_TIMEOUT_MS / 1000}s. ` +
164
+ "Is zenohd running? (e.g. ws://localhost:10000). Check config and adapter logs.",
165
+ ),
166
+ ),
167
+ CONNECT_TIMEOUT_MS,
168
+ );
169
+ });
170
+ await Promise.race([t.connect(), timeout]);
171
+ }