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
@@ -2,11 +2,30 @@
2
2
  * Gemini function declarations and tool execution. Same ROS2 tool set as Claude Code.
3
3
  */
4
4
 
5
- import type { AgenticROSConfig } from "@agenticros/core";
5
+ import type {
6
+ AgenticROSConfig,
7
+ Capability,
8
+ CapabilityToolBindings,
9
+ Mission,
10
+ MissionToolDispatcher,
11
+ } from "@agenticros/core";
6
12
  import {
7
13
  resolveCameraSubscribeTopic,
8
14
  resolveMemoryNamespace,
9
15
  toNamespacedTopic,
16
+ listAllCapabilities,
17
+ runMission,
18
+ listRobots,
19
+ getActiveRobotId,
20
+ resolveRobotFromArgs,
21
+ discoverRobots,
22
+ findRobotsFor,
23
+ type ResolvedRobot,
24
+ generateMissionId,
25
+ createMemoryTranscriptSink,
26
+ missionTranscriptNamespace,
27
+ MissionRegistry,
28
+ compileGoalToMission,
10
29
  } from "@agenticros/core";
11
30
  import {
12
31
  ROS_MSG_COMPRESSED_IMAGE,
@@ -17,7 +36,7 @@ import {
17
36
  } from "@agenticros/ros-camera";
18
37
  import type { FunctionDeclaration, FunctionResponsePart } from "@google/genai";
19
38
  import { createFunctionResponsePartFromBase64 } from "@google/genai";
20
- import { getTransport } from "./transport.js";
39
+ import { getTransportForRobot } from "./transport.js";
21
40
  import { checkPublishSafety } from "./safety.js";
22
41
  import { getDepthDistance } from "./depth.js";
23
42
  import { ensureMemory } from "./memory.js";
@@ -29,6 +48,100 @@ const MEMORY_TOOL_NAMES = new Set([
29
48
  "memory_status",
30
49
  ]);
31
50
 
51
+ /**
52
+ * Per-process mission registry — Phase 1.f. Each `run_mission`
53
+ * invocation registers a fresh mission_id; a sibling `mission_cancel`
54
+ * tool call looks it up and flips the cancellation token. Module scope
55
+ * so independent tool dispatches share state. Mirrors the singleton
56
+ * pattern used by the Claude Code MCP server and the OpenClaw plugin.
57
+ */
58
+ const MISSION_REGISTRY = new MissionRegistry();
59
+
60
+ /**
61
+ * Capability → MCP tool dispatch table for `run_mission` (Phase 1.c).
62
+ * Mirrors the table in packages/agenticros-claude-code/src/tools.ts and
63
+ * packages/agenticros/src/tools/ros2-mission.ts — all three adapters
64
+ * agree on what each capability does.
65
+ */
66
+ const MISSION_BINDINGS: CapabilityToolBindings = {
67
+ drive_base: {
68
+ tool: "ros2_publish",
69
+ buildArgs: (inputs) => {
70
+ const lx = Number(inputs.linear_x ?? 0) || 0;
71
+ const az = Number(inputs.angular_z ?? 0) || 0;
72
+ return {
73
+ topic: "/cmd_vel",
74
+ type: "geometry_msgs/msg/Twist",
75
+ message: {
76
+ linear: { x: lx, y: 0, z: 0 },
77
+ angular: { x: 0, y: 0, z: az },
78
+ },
79
+ };
80
+ },
81
+ },
82
+ take_snapshot: {
83
+ tool: "ros2_camera_snapshot",
84
+ buildArgs: (inputs) => {
85
+ const out: Record<string, unknown> = {};
86
+ if (typeof inputs.topic === "string") out.topic = inputs.topic;
87
+ if (typeof inputs.message_type === "string") out.message_type = inputs.message_type;
88
+ if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
89
+ return out;
90
+ },
91
+ },
92
+ measure_depth: {
93
+ tool: "ros2_depth_distance",
94
+ buildArgs: (inputs) => {
95
+ const out: Record<string, unknown> = {};
96
+ if (typeof inputs.topic === "string") out.topic = inputs.topic;
97
+ if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
98
+ return out;
99
+ },
100
+ },
101
+ list_topics: {
102
+ tool: "ros2_list_topics",
103
+ buildArgs: () => ({}),
104
+ },
105
+ publish_topic: {
106
+ tool: "ros2_publish",
107
+ buildArgs: (inputs) => ({
108
+ topic: String(inputs.topic ?? ""),
109
+ type: String(inputs.type ?? inputs.msg_type ?? ""),
110
+ message: inputs.message ?? inputs.msg ?? {},
111
+ }),
112
+ },
113
+ subscribe_once: {
114
+ tool: "ros2_subscribe_once",
115
+ buildArgs: (inputs) => {
116
+ const out: Record<string, unknown> = { topic: String(inputs.topic ?? "") };
117
+ if (typeof inputs.type === "string") out.type = inputs.type;
118
+ if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
119
+ return out;
120
+ },
121
+ },
122
+ follow_person: {
123
+ tool: "ros2_follow_me_start",
124
+ buildArgs: (inputs) => {
125
+ const out: Record<string, unknown> = {};
126
+ if (typeof inputs.target_distance === "number") out.target_distance = inputs.target_distance;
127
+ if (typeof inputs.mode === "string") out.mode = inputs.mode;
128
+ return out;
129
+ },
130
+ },
131
+ find_object: {
132
+ tool: "ros2_find_object",
133
+ buildArgs: (inputs) => {
134
+ const target = String(inputs.target ?? "");
135
+ const out: Record<string, unknown> = { target };
136
+ if (typeof inputs.angular_speed === "number") out.angular_speed = inputs.angular_speed;
137
+ if (typeof inputs.clockwise === "boolean") out.clockwise = inputs.clockwise;
138
+ if (typeof inputs.timeout_seconds === "number") out.timeout_seconds = inputs.timeout_seconds;
139
+ if (typeof inputs.min_confidence === "number") out.min_confidence = inputs.min_confidence;
140
+ return out;
141
+ },
142
+ },
143
+ };
144
+
32
145
  const DEFAULT_DEPTH_TOPIC = "/camera/camera/depth/image_rect_raw";
33
146
  const ENABLE_MULTIMODAL_FUNCTION_RESPONSE =
34
147
  (process.env.GEMINI_ENABLE_MULTIMODAL_TOOL_RESPONSE ?? "").toLowerCase() === "true";
@@ -43,92 +156,173 @@ function schemaFromProps(properties: Record<string, { type: string; description?
43
156
  return schema;
44
157
  }
45
158
 
159
+ /**
160
+ * Phase 1.d — schema fragment for an optional `robot_id` parameter,
161
+ * spread into every transport-bound tool's properties object. The
162
+ * description matches the claude-code and OpenClaw adapters so the
163
+ * agent gets a consistent contract across hosts.
164
+ */
165
+ const ROBOT_ID_PROP = {
166
+ robot_id: {
167
+ type: "string",
168
+ description:
169
+ "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used.",
170
+ },
171
+ } as const;
172
+
173
+ /**
174
+ * Resolve the target robot from a function-call's args. On success
175
+ * returns `{ robot }`; on unknown id returns `{ error }` already
176
+ * formatted as a Gemini tool result so the caller can pass it straight
177
+ * back. The error message already lists known ids and recommends
178
+ * ros2_list_robots, so the agent self-corrects.
179
+ */
180
+ function resolveRobotForTool(
181
+ config: AgenticROSConfig,
182
+ args: Record<string, unknown>,
183
+ ): { robot: ResolvedRobot } | { error: { output: string } } {
184
+ try {
185
+ return { robot: resolveRobotFromArgs(config, args) };
186
+ } catch (err) {
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ return { error: { output: msg } };
189
+ }
190
+ }
191
+
46
192
  export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
47
193
  {
48
194
  name: "ros2_list_topics",
49
195
  description: "List all available ROS2 topics and their message types. Use this to discover what data the robot publishes and what commands it accepts.",
50
196
  parametersJsonSchema: schemaFromProps({}),
51
197
  },
198
+ {
199
+ name: "ros2_list_capabilities",
200
+ description:
201
+ "List the high-level capabilities (named verbs) this robot can perform — built-in verbs like drive_base / take_snapshot / measure_depth plus every capability declared by installed AgenticROS skills (e.g. follow_person, find_object). PREFER this over ros2_list_topics for high-level planning: capabilities are agent-meaningful verbs with typed inputs/outputs, not raw topic names. Returns one structured response listing every capability the robot supports right now.",
202
+ parametersJsonSchema: schemaFromProps({ ...ROBOT_ID_PROP }),
203
+ },
204
+ {
205
+ name: "ros2_list_robots",
206
+ description:
207
+ "List the robots this gateway knows about (id, name, ROS2 namespace, default camera topic) and which one is the active default. Use this FIRST when the user mentions multiple robots, asks 'which robots can you see?', or names a specific robot you haven't heard of. The returned `id` is what other tools accept as a `robot_id` parameter.",
208
+ parametersJsonSchema: schemaFromProps({}),
209
+ },
210
+ {
211
+ name: "ros2_discover_robots",
212
+ description:
213
+ "Scan the ROS2 topic graph and report which robots are actually on the wire right now, classified against the gateway's configured robot list. Returns: (1) every namespace inferred from `<ns>/cmd_vel` topics, with topicCount per namespace, (2) configured_online — configured robots currently publishing, (3) configured_offline — configured robots that are silent, (4) unknown_detected — robots on the wire that aren't in config yet. Use this when the user asks 'which robots are online right now', 'is my robot connected', or wants to find a robot that isn't in ros2_list_robots. Requires the ROS transport to be connected.",
214
+ parametersJsonSchema: schemaFromProps({}),
215
+ },
216
+ {
217
+ name: "ros2_find_robots_for",
218
+ description:
219
+ "Find the robots in the configured fleet that match a capability + kind + online filter, ranked best-first. PREFER this over ros2_list_robots whenever the user names a verb ('which robot can find a chair', 'do I have an arm robot that can grasp', 'is there an AMR online that can follow a person'). Capability matches the verbs from ros2_list_capabilities — by default robots inherit the gateway-wide registry; declaring per-robot capabilities in config narrows it. Kind matches robot.kind exactly ('amr' | 'arm' | 'drone' | 'rover'). When online=true, only currently-reachable robots are returned (uses the same `<ns>/cmd_vel` heuristic as ros2_discover_robots and requires the transport). The result lists matched robots with id/name/namespace/kind/sensors/online flag, ranked so explicit capability declarations + online robots come first.",
220
+ parametersJsonSchema: schemaFromProps({
221
+ capability: {
222
+ type: "string",
223
+ description:
224
+ "Capability id to match (e.g. 'follow_person', 'find_object', 'drive_base'). Case-sensitive — use ros2_list_capabilities to get the exact list.",
225
+ },
226
+ kind: {
227
+ type: "string",
228
+ description:
229
+ "Robot kind filter (case-insensitive exact match). Common values: 'amr', 'arm', 'drone', 'rover'.",
230
+ },
231
+ online: {
232
+ type: "boolean",
233
+ description:
234
+ "When true, only return robots currently reachable on the ROS2 graph (requires the transport). When false, only return robots NOT reachable. When omitted, online status is annotated on every match but doesn't filter.",
235
+ },
236
+ }),
237
+ },
52
238
  {
53
239
  name: "ros2_publish",
54
- description: "Publish a message to a ROS2 topic. Use this to send commands to the robot (e.g., velocity commands to /cmd_vel, navigation goals).",
240
+ description: "Publish a message to a ROS2 topic. Use this to send commands to the robot (e.g., velocity commands to /cmd_vel, navigation goals). Pass robot_id (from ros2_list_robots) to target a specific robot.",
55
241
  parametersJsonSchema: schemaFromProps(
56
242
  {
57
243
  topic: { type: "string", description: "The ROS2 topic name (e.g., '/cmd_vel')" },
58
244
  type: { type: "string", description: "The ROS2 message type (e.g., 'geometry_msgs/msg/Twist')" },
59
245
  message: { type: "object", description: "The message payload matching the ROS2 message type schema" },
246
+ ...ROBOT_ID_PROP,
60
247
  },
61
248
  ["topic", "type", "message"],
62
249
  ),
63
250
  },
64
251
  {
65
252
  name: "ros2_subscribe_once",
66
- description: "Subscribe to a ROS2 topic and return the next message. Use this to read sensor data, check robot state, or get the current value of a topic.",
253
+ description: "Subscribe to a ROS2 topic and return the next message. Use this to read sensor data, check robot state, or get the current value of a topic. Pass robot_id to target a specific robot.",
67
254
  parametersJsonSchema: schemaFromProps({
68
255
  topic: { type: "string", description: "The ROS2 topic name (e.g., '/battery_state')" },
69
256
  type: { type: "string", description: "The ROS2 message type (optional)" },
70
257
  timeout: { type: "number", description: "Timeout in milliseconds (default: 5000)" },
258
+ ...ROBOT_ID_PROP,
71
259
  }, ["topic"]),
72
260
  },
73
261
  {
74
262
  name: "ros2_service_call",
75
- description: "Call a ROS2 service and return the response. Use for request/response operations like setting parameters or querying node state.",
263
+ description: "Call a ROS2 service and return the response. Use for request/response operations like setting parameters or querying node state. Pass robot_id to target a specific robot.",
76
264
  parametersJsonSchema: schemaFromProps({
77
265
  service: { type: "string", description: "The ROS2 service name (e.g., '/spawn_entity')" },
78
266
  type: { type: "string", description: "The ROS2 service type (optional)" },
79
267
  args: { type: "object", description: "The service request arguments" },
268
+ ...ROBOT_ID_PROP,
80
269
  }, ["service"]),
81
270
  },
82
271
  {
83
272
  name: "ros2_action_goal",
84
- description: "Send a goal to a ROS2 action server. Use for long-running operations like navigation or arm movements.",
273
+ description: "Send a goal to a ROS2 action server. Use for long-running operations like navigation or arm movements. Pass robot_id to target a specific robot.",
85
274
  parametersJsonSchema: schemaFromProps(
86
275
  {
87
276
  action: { type: "string", description: "The ROS2 action server name (e.g., '/navigate_to_pose')" },
88
277
  actionType: { type: "string", description: "The ROS2 action type (e.g., 'nav2_msgs/action/NavigateToPose')" },
89
278
  goal: { type: "object", description: "The action goal parameters" },
279
+ ...ROBOT_ID_PROP,
90
280
  },
91
281
  ["action", "actionType", "goal"],
92
282
  ),
93
283
  },
94
284
  {
95
285
  name: "ros2_param_get",
96
- description: "Get the value of a ROS2 parameter from a node. Use to check robot configuration values.",
286
+ description: "Get the value of a ROS2 parameter from a node. Use to check robot configuration values. Pass robot_id to target a specific robot.",
97
287
  parametersJsonSchema: schemaFromProps(
98
288
  {
99
289
  node: { type: "string", description: "The fully qualified node name (e.g., '/turtlebot3/controller')" },
100
290
  parameter: { type: "string", description: "The parameter name (e.g., 'max_velocity')" },
291
+ ...ROBOT_ID_PROP,
101
292
  },
102
293
  ["node", "parameter"],
103
294
  ),
104
295
  },
105
296
  {
106
297
  name: "ros2_param_set",
107
- description: "Set the value of a ROS2 parameter on a node. Use to change robot configuration at runtime.",
298
+ description: "Set the value of a ROS2 parameter on a node. Use to change robot configuration at runtime. Pass robot_id to target a specific robot.",
108
299
  parametersJsonSchema: schemaFromProps(
109
300
  {
110
301
  node: { type: "string", description: "The fully qualified node name" },
111
302
  parameter: { type: "string", description: "The parameter name" },
112
303
  value: { type: "object", description: "The new parameter value" },
304
+ ...ROBOT_ID_PROP,
113
305
  },
114
306
  ["node", "parameter", "value"],
115
307
  ),
116
308
  },
117
309
  {
118
310
  name: "ros2_camera_snapshot",
119
- description: "Capture a single image from a ROS2 camera topic. Use when the user asks what the robot sees or requests a photo. Supports CompressedImage and raw Image.",
311
+ description: "Capture a single image from a ROS2 camera topic. Use when the user asks what the robot sees or requests a photo. Supports CompressedImage and raw Image. Pass robot_id to capture from a specific robot's camera.",
120
312
  parametersJsonSchema: schemaFromProps({
121
- topic: { type: "string", description: "Camera image topic (default from config or /camera/camera/color/image_raw/compressed)" },
313
+ topic: { type: "string", description: "Camera image topic (default from the robot's cameraTopic or /camera/camera/color/image_raw/compressed)" },
122
314
  message_type: { type: "string", description: "'CompressedImage' or 'Image' (default: CompressedImage)" },
123
315
  timeout: { type: "number", description: "Timeout in milliseconds (default: 10000)" },
316
+ ...ROBOT_ID_PROP,
124
317
  }),
125
318
  },
126
319
  {
127
320
  name: "ros2_depth_distance",
128
- description: "Get distance in meters from the robot's depth camera. Samples the center of the depth image. Use when the user asks how far they are from the robot.",
321
+ description: "Get distance in meters from the robot's depth camera. Samples the center of the depth image. Use when the user asks how far they are from the robot. Pass robot_id to sample a specific robot's depth camera.",
129
322
  parametersJsonSchema: schemaFromProps({
130
323
  topic: { type: "string", description: `Depth image topic (default: ${DEFAULT_DEPTH_TOPIC})` },
131
324
  timeout: { type: "number", description: "Timeout in ms (default 5000)" },
325
+ ...ROBOT_ID_PROP,
132
326
  }),
133
327
  },
134
328
  {
@@ -176,6 +370,49 @@ export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
176
370
  namespace: { type: "string", description: "Optional namespace override; defaults to the robot namespace." },
177
371
  }),
178
372
  },
373
+ {
374
+ name: "run_mission",
375
+ description:
376
+ "Execute a multi-step mission by chaining capabilities (the verbs returned by ros2_list_capabilities). PASS EITHER a natural-language `goal` (recommended for simple verbs like 'find a chair', 'take a picture', 'follow me', 'find a chair and drive toward it') OR an explicit `mission.steps[]` plan when you need precise control. Steps run sequentially; each step's outputs are available to later steps via {{stepId.outputs.fieldName}} template references. Default on_fail behaviour is 'stop' (abort on first error). Returns a per-step result list, a summary line, a mission_id you can pass to mission_cancel to abort mid-run, and (when a goal was provided) the compiled plan + candidate match list so you can see what the planner did. When memory is enabled, every step is also written to the shared memory under namespace mission:<mission_id> so a second agent can recall the timeline via memory_recall. Today the runner supports: drive_base, take_snapshot, measure_depth, list_topics, publish_topic, subscribe_once, follow_person, find_object. Pass mission.robot_id (or top-level robot_id with goal) to target every step at one robot.",
377
+ parametersJsonSchema: {
378
+ type: "object",
379
+ properties: {
380
+ goal: {
381
+ type: "string",
382
+ description:
383
+ "Natural-language goal — the local planner compiles it into a mission against the capability registry. Examples: 'find a chair', 'find a chair and drive toward it', 'take a picture', 'follow me', 'measure depth', 'drive forward at 0.3 m/s', 'turn left', 'stop'. Either goal or mission must be provided; mission wins if both are set.",
384
+ },
385
+ robot_id: {
386
+ type: "string",
387
+ description: "Optional robot id (from ros2_list_robots) — used when 'goal' is provided to scope every compiled step to one robot.",
388
+ },
389
+ mission: {
390
+ type: "object",
391
+ description:
392
+ 'Declarative mission plan: { name?: string, goal?: string, robot_id?: string, steps: [{ id: string, capability: string, inputs?: object, on_fail?: "stop"|"continue" }] }. mission.robot_id (from ros2_list_robots) is the default robot for every step; individual steps can override via inputs.robot_id. Example: { "name": "find then approach chair", "robot_id": "robotA", "steps": [{ "id": "find", "capability": "find_object", "inputs": { "target": "chair" } }, { "id": "go", "capability": "drive_base", "inputs": { "linear_x": 0.2, "angular_z": "{{find.outputs.horizontal_offset}}" } }] }',
393
+ },
394
+ },
395
+ },
396
+ },
397
+ {
398
+ name: "mission_cancel",
399
+ description:
400
+ "Cancel a mission that's currently running in this Gemini CLI process. Pass the mission_id returned by run_mission. The mission runner stops at the next step boundary (the in-flight step finishes naturally), marks remaining steps as 'cancelled', and returns. If the mission has already finished (or the id is unknown), this is a no-op that returns found=false. Optional 'reason' is recorded in the cancelled step results for traceability.",
401
+ parametersJsonSchema: {
402
+ type: "object",
403
+ properties: {
404
+ mission_id: {
405
+ type: "string",
406
+ description: "The mission_id echoed by run_mission. Required.",
407
+ },
408
+ reason: {
409
+ type: "string",
410
+ description: "Optional free-text reason — surfaced in the cancelled mission result.",
411
+ },
412
+ },
413
+ required: ["mission_id"],
414
+ },
415
+ },
179
416
  ];
180
417
 
181
418
  /** Single tool object for Gemini (one item in config.tools array). */
@@ -213,7 +450,235 @@ export async function executeTool(
213
450
  if (MEMORY_TOOL_NAMES.has(name)) {
214
451
  return executeMemoryTool(name, args, config);
215
452
  }
216
- const transport = getTransport();
453
+ // ros2_list_capabilities reads skill manifests + intrinsic verbs from
454
+ // local config — no transport required. robot_id is accepted but
455
+ // doesn't change today's response (every robot exposes the same
456
+ // capabilities); we still validate it so unknown ids surface as a
457
+ // clean tool error.
458
+ if (name === "ros2_list_capabilities") {
459
+ const resolved = resolveRobotForTool(config, args);
460
+ if ("error" in resolved) return resolved.error;
461
+ const caps: Capability[] = listAllCapabilities(config);
462
+ const intrinsic = caps.filter((c) => c.source?.kind === "builtin").length;
463
+ const skill = caps.filter((c) => c.source?.kind === "skill").length;
464
+ return {
465
+ output: JSON.stringify({
466
+ success: true,
467
+ total: caps.length,
468
+ intrinsic_count: intrinsic,
469
+ skill_count: skill,
470
+ capabilities: caps,
471
+ }),
472
+ };
473
+ }
474
+ // ros2_list_robots reads the multi-robot section of the config (with
475
+ // legacy fallback) — no transport required either.
476
+ if (name === "ros2_list_robots") {
477
+ const robots = listRobots(config);
478
+ const active = getActiveRobotId(config);
479
+ return {
480
+ output: JSON.stringify({
481
+ success: true,
482
+ total: robots.length,
483
+ active_robot_id: active,
484
+ robots,
485
+ }),
486
+ };
487
+ }
488
+ // ros2_find_robots_for is config-driven by default. Only the
489
+ // online=true|false branch touches the transport (same heuristic as
490
+ // ros2_discover_robots). Stays above the unconditional transport
491
+ // resolution so static-fleet planning works offline.
492
+ if (name === "ros2_find_robots_for") {
493
+ const cap = typeof args["capability"] === "string" ? args["capability"] : undefined;
494
+ const kind = typeof args["kind"] === "string" ? args["kind"] : undefined;
495
+ const online = typeof args["online"] === "boolean" ? args["online"] : undefined;
496
+ let onlineIds: Set<string> | undefined;
497
+ if (online !== undefined) {
498
+ // Need any one live transport — use the active robot's. Errors
499
+ // here surface as `success: false` so the model can recover.
500
+ const resolved = resolveRobotForTool(config, {});
501
+ if ("error" in resolved) return resolved.error;
502
+ const transport = await getTransportForRobot(config, resolved.robot);
503
+ if (transport.getStatus() !== "connected") {
504
+ return {
505
+ output: JSON.stringify({
506
+ success: false,
507
+ error:
508
+ "online filter requires the ROS transport to be connected. Drop the 'online' arg to run config-only, or check that zenohd / rosbridge is up.",
509
+ }),
510
+ };
511
+ }
512
+ const topics = await transport.listTopics();
513
+ const disc = discoverRobots(topics, config);
514
+ onlineIds = new Set(disc.configured_online.map((r) => r.id));
515
+ }
516
+ try {
517
+ const result = findRobotsFor(config, { capability: cap, kind, online }, onlineIds);
518
+ return {
519
+ output: JSON.stringify({
520
+ success: true,
521
+ query: result.query,
522
+ total: result.total,
523
+ robots: result.robots.map((m) => ({
524
+ id: m.robot.id,
525
+ name: m.robot.name,
526
+ namespace: m.robot.namespace,
527
+ kind: m.robot.kind,
528
+ sensors: m.robot.sensors,
529
+ capabilities: m.robot.capabilities ?? null,
530
+ cameraTopic: m.robot.cameraTopic,
531
+ online: m.online,
532
+ matched_capability_explicitly: m.matched_capability_explicitly,
533
+ score: m.score,
534
+ })),
535
+ }),
536
+ };
537
+ } catch (e) {
538
+ return {
539
+ output: JSON.stringify({
540
+ success: false,
541
+ error: e instanceof Error ? e.message : String(e),
542
+ }),
543
+ };
544
+ }
545
+ }
546
+ // mission_cancel mutates only the in-process MissionRegistry and
547
+ // must work without a ROS transport — keep it above the
548
+ // unconditional transport resolution.
549
+ if (name === "mission_cancel") {
550
+ const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
551
+ if (!missionId) {
552
+ return {
553
+ output: JSON.stringify({
554
+ success: false,
555
+ error: "mission_cancel requires 'mission_id' (a non-empty string returned by run_mission).",
556
+ }),
557
+ };
558
+ }
559
+ const reason = typeof args["reason"] === "string" ? (args["reason"] as string) : undefined;
560
+ const outcome = MISSION_REGISTRY.cancel(missionId, reason);
561
+ return {
562
+ output: JSON.stringify({
563
+ success: true,
564
+ mission_id: missionId,
565
+ found: outcome.found,
566
+ already_cancelled: outcome.alreadyCancelled,
567
+ reason: reason ?? null,
568
+ }),
569
+ };
570
+ }
571
+ // run_mission dispatches to other tools recursively — let each sub-tool
572
+ // handle its own transport gating instead of requiring a connection up
573
+ // front. Validate the input shape here; defer execution to runMission().
574
+ if (name === "run_mission") {
575
+ const caps = listAllCapabilities(config);
576
+ const missionArg = args["mission"];
577
+ const goalArg = args["goal"];
578
+ const topLevelRobotId = typeof args["robot_id"] === "string" ? (args["robot_id"] as string) : undefined;
579
+
580
+ // Phase 1.g — accept either an explicit mission OR a natural-language
581
+ // goal. We surface the planner's candidates + suggestions in the
582
+ // response so the agent can self-correct without an extra round-trip.
583
+ let mission: Mission;
584
+ let plannerInfo:
585
+ | { compiled_from_goal: string; candidates: unknown[]; unmatched_verbs?: string[] }
586
+ | undefined;
587
+ if (missionArg && typeof missionArg === "object" && !Array.isArray(missionArg)) {
588
+ mission = missionArg as Mission;
589
+ if (!Array.isArray(mission.steps)) {
590
+ return { output: "mission.steps must be an array of step objects." };
591
+ }
592
+ } else if (typeof goalArg === "string" && goalArg.trim().length > 0) {
593
+ const planned = compileGoalToMission(goalArg, caps, { robot_id: topLevelRobotId });
594
+ if (!planned.mission) {
595
+ return {
596
+ output: JSON.stringify({
597
+ success: false,
598
+ error: planned.error,
599
+ goal: goalArg,
600
+ suggestions: planned.suggestions,
601
+ ...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
602
+ }),
603
+ };
604
+ }
605
+ mission = planned.mission;
606
+ plannerInfo = {
607
+ compiled_from_goal: goalArg,
608
+ candidates: planned.candidates,
609
+ ...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
610
+ };
611
+ } else {
612
+ return {
613
+ output:
614
+ 'run_mission requires either "mission" (object with steps[]) or "goal" (natural-language string). Pass at least one.',
615
+ };
616
+ }
617
+ // Validate mission.robot_id up-front so the agent gets a single
618
+ // clean error (with known ids) instead of one per step.
619
+ if (typeof mission.robot_id === "string" && mission.robot_id.trim().length > 0) {
620
+ const resolved = resolveRobotForTool(config, { robot_id: mission.robot_id });
621
+ if ("error" in resolved) return resolved.error;
622
+ }
623
+ const dispatcher: MissionToolDispatcher = async (toolName, toolArgs) => {
624
+ const sub = await executeTool(toolName, toolArgs, config);
625
+ return { text: sub.output };
626
+ };
627
+
628
+ // Phase 1.f — register a mission_id so a sibling mission_cancel
629
+ // call can flip the cancellation token; wire a transcript sink
630
+ // when memory is enabled so a second agent can recall the timeline.
631
+ const missionId = generateMissionId();
632
+ const { entry: regEntry, dispose: disposeRegistry } = MISSION_REGISTRY.register(
633
+ missionId,
634
+ { name: mission.name },
635
+ );
636
+ const memory = await ensureMemory(config);
637
+ const transcript = memory ? createMemoryTranscriptSink(memory, missionId) : undefined;
638
+
639
+ let result;
640
+ try {
641
+ result = await runMission(mission, caps, MISSION_BINDINGS, dispatcher, {
642
+ mission_id: missionId,
643
+ cancellation: regEntry.cancellation,
644
+ transcript,
645
+ adapter: "gemini",
646
+ });
647
+ } finally {
648
+ disposeRegistry();
649
+ }
650
+ const compact = {
651
+ status: result.status,
652
+ mission_id: result.mission_id,
653
+ ...(result.cancellation_reason ? { cancellation_reason: result.cancellation_reason } : {}),
654
+ ...(transcript ? { transcript_namespace: missionTranscriptNamespace(missionId) } : {}),
655
+ ...(plannerInfo ? { planner: plannerInfo } : {}),
656
+ steps_run: result.steps_run,
657
+ steps_total: result.steps_total,
658
+ duration_ms: result.duration_ms,
659
+ summary: result.summary,
660
+ steps: result.steps.map((s) => ({
661
+ id: s.id,
662
+ capability: s.capability,
663
+ status: s.status,
664
+ inputs: s.inputs,
665
+ outputs: s.outputs,
666
+ ...(s.error ? { error: s.error } : {}),
667
+ duration_ms: s.duration_ms,
668
+ })),
669
+ };
670
+ return { output: `${result.summary}\n${JSON.stringify(compact)}` };
671
+ }
672
+ // Resolve target robot once for every transport-bound tool. Unknown
673
+ // robot_id surfaces as a tool error (not a thrown exception).
674
+ const resolvedRobot = resolveRobotForTool(config, args);
675
+ if ("error" in resolvedRobot) return resolvedRobot.error;
676
+ const { robot } = resolvedRobot;
677
+
678
+ // Route through the per-robot pool: returns the shared `__global__`
679
+ // when this robot has no override (the common case), OR a dedicated
680
+ // transport on first use when the robot declares its own.
681
+ const transport = await getTransportForRobot(config, robot);
217
682
 
218
683
  switch (name) {
219
684
  case "ros2_list_topics": {
@@ -229,6 +694,24 @@ export async function executeTool(
229
694
  return { output: text };
230
695
  }
231
696
 
697
+ case "ros2_discover_robots": {
698
+ // Live discovery: detect /<ns>/cmd_vel namespaces, classify
699
+ // against config. Pure-function classifier in @agenticros/core
700
+ // so all three adapters return the same shape.
701
+ const topics = await transport.listTopics();
702
+ const result = discoverRobots(topics, config);
703
+ return {
704
+ output: JSON.stringify({
705
+ success: true,
706
+ total_topics: result.total_topics,
707
+ detected: result.detected,
708
+ configured_online: result.configured_online,
709
+ configured_offline: result.configured_offline,
710
+ unknown_detected: result.unknown_detected,
711
+ }),
712
+ };
713
+ }
714
+
232
715
  case "ros2_publish": {
233
716
  const rawTopicIn = String(args["topic"] ?? "").trim();
234
717
  if (process.stderr?.write) {
@@ -248,7 +731,7 @@ export async function executeTool(
248
731
  const topic =
249
732
  cmdVelMatch && !segment.toLowerCase().startsWith("robot")
250
733
  ? `/robot${segment.replace(/-/g, "")}/cmd_vel`
251
- : toNamespacedTopic(config, rawTopicIn);
734
+ : toNamespacedTopic(robot.namespace, rawTopicIn);
252
735
  if (process.stderr?.write) {
253
736
  process.stderr.write(`[AgenticROS] ros2_publish: → topic=${topic}\n`);
254
737
  }
@@ -270,7 +753,7 @@ export async function executeTool(
270
753
 
271
754
  case "ros2_subscribe_once": {
272
755
  const rawTopic = args["topic"] as string;
273
- const topic = toNamespacedTopic(config, rawTopic);
756
+ const topic = toNamespacedTopic(robot.namespace, rawTopic);
274
757
  let msgType = args["type"] as string | undefined;
275
758
  const timeout = (args["timeout"] as number | undefined) ?? 5000;
276
759
  if (!msgType && /\/?(camera|image|color|depth)/i.test(rawTopic)) {
@@ -305,7 +788,7 @@ export async function executeTool(
305
788
 
306
789
  case "ros2_service_call": {
307
790
  const rawService = args["service"] as string;
308
- const service = toNamespacedTopic(config, rawService);
791
+ const service = toNamespacedTopic(robot.namespace, rawService);
309
792
  const type = args["type"] as string | undefined;
310
793
  const reqArgs = args["args"] as Record<string, unknown> | undefined;
311
794
  const response = await transport.callService({ service, type, args: reqArgs });
@@ -319,7 +802,7 @@ export async function executeTool(
319
802
 
320
803
  case "ros2_action_goal": {
321
804
  const rawAction = args["action"] as string;
322
- const action = toNamespacedTopic(config, rawAction);
805
+ const action = toNamespacedTopic(robot.namespace, rawAction);
323
806
  const actionType = args["actionType"] as string;
324
807
  const goal = args["goal"] as Record<string, unknown>;
325
808
  const actionResult = await transport.sendActionGoal({ action, actionType, args: goal });
@@ -333,7 +816,7 @@ export async function executeTool(
333
816
 
334
817
  case "ros2_param_get": {
335
818
  const rawNode = args["node"] as string;
336
- const node = toNamespacedTopic(config, rawNode);
819
+ const node = toNamespacedTopic(robot.namespace, rawNode);
337
820
  const parameter = args["parameter"] as string;
338
821
  const response = await transport.callService({
339
822
  service: `${node}/get_parameters`,
@@ -351,7 +834,7 @@ export async function executeTool(
351
834
 
352
835
  case "ros2_param_set": {
353
836
  const rawNode = args["node"] as string;
354
- const node = toNamespacedTopic(config, rawNode);
837
+ const node = toNamespacedTopic(robot.namespace, rawNode);
355
838
  const parameter = args["parameter"] as string;
356
839
  const value = args["value"];
357
840
  const response = await transport.callService({
@@ -369,9 +852,9 @@ export async function executeTool(
369
852
 
370
853
  case "ros2_camera_snapshot": {
371
854
  const defaultTopic =
372
- (config.robot?.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
855
+ (robot.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
373
856
  const rawTopic = (args["topic"] as string | undefined) ?? defaultTopic;
374
- const topic = resolveCameraSubscribeTopic(config, rawTopic);
857
+ const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
375
858
  const rawMsgType = args["message_type"] as string | undefined;
376
859
  const messageType: "CompressedImage" | "Image" = rawMsgType === "Image" ? "Image" : "CompressedImage";
377
860
  const timeout = (args["timeout"] as number | undefined) ?? 10000;
@@ -445,7 +928,7 @@ export async function executeTool(
445
928
 
446
929
  case "ros2_depth_distance": {
447
930
  const rawTopic = (args["topic"] as string | undefined)?.trim() || DEFAULT_DEPTH_TOPIC;
448
- const topic = resolveCameraSubscribeTopic(config, rawTopic);
931
+ const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
449
932
  const timeout = (args["timeout"] as number | undefined) ?? 5000;
450
933
  try {
451
934
  const result = await getDepthDistance(transport, topic, timeout);