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.
- package/dist/__tests__/robot-config.test.d.ts +2 -0
- package/dist/__tests__/robot-config.test.d.ts.map +1 -0
- package/dist/__tests__/robot-config.test.js +374 -0
- package/dist/__tests__/robot-config.test.js.map +1 -0
- package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
- package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
- package/dist/__tests__/transport-shorthand.test.js +88 -0
- package/dist/__tests__/transport-shorthand.test.js.map +1 -0
- package/dist/commands/robots.d.ts +57 -0
- package/dist/commands/robots.d.ts.map +1 -0
- package/dist/commands/robots.js +678 -0
- package/dist/commands/robots.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mcp-discovery.d.ts +57 -0
- package/dist/util/mcp-discovery.d.ts.map +1 -0
- package/dist/util/mcp-discovery.js +153 -0
- package/dist/util/mcp-discovery.js.map +1 -0
- package/dist/util/robot-config.d.ts +167 -0
- package/dist/util/robot-config.d.ts.map +1 -0
- package/dist/util/robot-config.js +375 -0
- package/dist/util/robot-config.js.map +1 -0
- package/dist/util/transport-shorthand.d.ts +66 -0
- package/dist/util/transport-shorthand.d.ts.map +1 -0
- package/dist/util/transport-shorthand.js +102 -0
- package/dist/util/transport-shorthand.js.map +1 -0
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -0
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
- package/runtime/packages/agenticros/package.json +2 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
- package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
- package/runtime/packages/agenticros/src/service.ts +120 -2
- package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
- package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
- package/runtime/packages/agenticros/src/tools/index.ts +49 -9
- package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
- package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
- package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
- package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
- package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
- package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
- package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
- package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
- package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
- package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
- package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
- package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
- package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
- package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
- package/runtime/packages/agenticros-gemini/package.json +1 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
- package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
- package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
- package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
- package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
- package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
- package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
- package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
- package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
- package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
- package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
- package/runtime/packages/core/src/capabilities.ts +340 -0
- package/runtime/packages/core/src/config.ts +220 -38
- package/runtime/packages/core/src/discovery.ts +193 -0
- package/runtime/packages/core/src/find-robots-for.ts +164 -0
- package/runtime/packages/core/src/index.ts +67 -1
- package/runtime/packages/core/src/mission-registry.ts +142 -0
- package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
- package/runtime/packages/core/src/mission.ts +574 -0
- package/runtime/packages/core/src/planner/index.ts +564 -0
- package/runtime/packages/core/src/robots.ts +282 -0
- package/runtime/packages/core/src/topic-utils.ts +47 -9
- package/runtime/packages/core/src/transport-pool.ts +171 -0
- package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
- package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
- package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
- package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- 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
|
|
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(
|
|
58
|
+
export function toNamespacedTopic(target: AgenticROSConfig | string, topic: string): string {
|
|
29
59
|
const normalized = normalizeTopic(topic);
|
|
30
|
-
const ns = (
|
|
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
|
|
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(
|
|
80
|
+
export function toNamespacedTopicFull(target: AgenticROSConfig | string, topic: string): string {
|
|
48
81
|
const normalized = normalizeTopic(topic);
|
|
49
|
-
const ns = (
|
|
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
|
|
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(
|
|
119
|
+
export function resolveCameraSubscribeTopic(target: AgenticROSConfig | string, topic: string): string {
|
|
82
120
|
const normalized = normalizeTopic(topic);
|
|
83
|
-
const ns = (
|
|
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
|
+
}
|