agenticros 0.1.18 → 0.2.0

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 (164) 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/commands/skills.d.ts.map +1 -1
  14. package/dist/commands/skills.js +125 -2
  15. package/dist/commands/skills.js.map +1 -1
  16. package/dist/index.js +29 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/menu.js +22 -4
  19. package/dist/menu.js.map +1 -1
  20. package/dist/util/marketplace.d.ts +57 -0
  21. package/dist/util/marketplace.d.ts.map +1 -0
  22. package/dist/util/marketplace.js +144 -0
  23. package/dist/util/marketplace.js.map +1 -0
  24. package/dist/util/mcp-discovery.d.ts +57 -0
  25. package/dist/util/mcp-discovery.d.ts.map +1 -0
  26. package/dist/util/mcp-discovery.js +153 -0
  27. package/dist/util/mcp-discovery.js.map +1 -0
  28. package/dist/util/robot-config.d.ts +167 -0
  29. package/dist/util/robot-config.d.ts.map +1 -0
  30. package/dist/util/robot-config.js +375 -0
  31. package/dist/util/robot-config.js.map +1 -0
  32. package/dist/util/skills.d.ts +6 -6
  33. package/dist/util/skills.d.ts.map +1 -1
  34. package/dist/util/skills.js +14 -9
  35. package/dist/util/skills.js.map +1 -1
  36. package/dist/util/transport-shorthand.d.ts +66 -0
  37. package/dist/util/transport-shorthand.d.ts.map +1 -0
  38. package/dist/util/transport-shorthand.js +102 -0
  39. package/dist/util/transport-shorthand.js.map +1 -0
  40. package/package.json +12 -10
  41. package/runtime/BUNDLE.json +1 -1
  42. package/runtime/README.md +62 -1
  43. package/runtime/package.json +6 -1
  44. package/runtime/packages/agenticros/openclaw.plugin.json +7 -1
  45. package/runtime/packages/agenticros/package.json +2 -1
  46. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  47. package/runtime/packages/agenticros/src/config-page.ts +1 -1
  48. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  49. package/runtime/packages/agenticros/src/service.ts +120 -2
  50. package/runtime/packages/agenticros/src/skill-loader.ts +109 -30
  51. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  52. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  53. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  54. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  55. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  56. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  57. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  58. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  59. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  60. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  61. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  62. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  63. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  64. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  65. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  66. package/runtime/packages/agenticros-claude-code/README.md +52 -9
  67. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts +2 -0
  68. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts.map +1 -0
  69. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js +773 -0
  70. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js.map +1 -0
  71. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts +2 -0
  72. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts.map +1 -0
  73. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js +97 -0
  74. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js.map +1 -0
  75. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts +2 -2
  76. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts.map +1 -1
  77. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js +5 -5
  78. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js.map +1 -1
  79. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts +109 -0
  80. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts.map +1 -0
  81. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js +392 -0
  82. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js.map +1 -0
  83. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts +8 -3
  84. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts.map +1 -1
  85. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js +30 -12
  86. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js.map +1 -1
  87. package/runtime/packages/agenticros-claude-code/dist/index.js +5 -5
  88. package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
  89. package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts +16 -0
  90. package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts.map +1 -0
  91. package/runtime/packages/agenticros-claude-code/dist/mission-registry.js +19 -0
  92. package/runtime/packages/agenticros-claude-code/dist/mission-registry.js.map +1 -0
  93. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts +9 -0
  94. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  95. package/runtime/packages/agenticros-claude-code/dist/tools.js +700 -54
  96. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  97. package/runtime/packages/agenticros-claude-code/dist/transport.d.ts +28 -8
  98. package/runtime/packages/agenticros-claude-code/dist/transport.d.ts.map +1 -1
  99. package/runtime/packages/agenticros-claude-code/dist/transport.js +37 -34
  100. package/runtime/packages/agenticros-claude-code/dist/transport.js.map +1 -1
  101. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  102. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +965 -0
  103. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  104. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  105. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  106. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  107. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  108. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  109. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  110. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  111. package/runtime/packages/agenticros-gemini/package.json +1 -0
  112. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  113. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  114. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  115. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  116. package/runtime/packages/core/LICENSE +192 -0
  117. package/runtime/packages/core/README.md +88 -0
  118. package/runtime/packages/core/package.json +36 -3
  119. package/runtime/packages/core/src/__tests__/capabilities.test.ts +266 -0
  120. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  121. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  122. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  123. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  124. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  125. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  126. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  127. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  128. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  129. package/runtime/packages/core/src/capabilities.ts +314 -0
  130. package/runtime/packages/core/src/config.ts +221 -39
  131. package/runtime/packages/core/src/discovery.ts +193 -0
  132. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  133. package/runtime/packages/core/src/index.ts +67 -1
  134. package/runtime/packages/core/src/mission-registry.ts +142 -0
  135. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  136. package/runtime/packages/core/src/mission.ts +574 -0
  137. package/runtime/packages/core/src/planner/index.ts +564 -0
  138. package/runtime/packages/core/src/robots.ts +282 -0
  139. package/runtime/packages/core/src/topic-utils.ts +47 -9
  140. package/runtime/packages/core/src/transport-pool.ts +171 -0
  141. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  142. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  143. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  144. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  145. package/runtime/scripts/configure_agenticros.sh +0 -0
  146. package/runtime/scripts/configure_for_sim.sh +0 -0
  147. package/runtime/scripts/fix-openclaw-control-ui-path.sh +0 -0
  148. package/runtime/scripts/install_cli.sh +0 -0
  149. package/runtime/scripts/install_rosbridge_from_source.sh +0 -0
  150. package/runtime/scripts/onboard_robot.sh +0 -0
  151. package/runtime/scripts/pack-runtime.mjs +0 -0
  152. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  153. package/runtime/scripts/run_demo_native.sh +0 -0
  154. package/runtime/scripts/run_nemoclaw_host_stack.sh +0 -0
  155. package/runtime/scripts/run_robot_rosbridge.sh +0 -0
  156. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  157. package/runtime/scripts/setup_robot.sh +0 -0
  158. package/runtime/scripts/setup_workspace.sh +0 -0
  159. package/runtime/scripts/sim/run_sim.sh +0 -0
  160. package/runtime/scripts/smoke_test_nemoclaw.sh +0 -0
  161. package/runtime/scripts/start_demo.sh +0 -0
  162. package/runtime/scripts/sync-skill-tools.mjs +69 -12
  163. package/runtime/scripts/use-openclaw-2026.2.26.sh +0 -0
  164. package/runtime/scripts/use-openclaw-2026.3.11.sh +0 -0
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * MCP tool definitions and handler. Mirrors OpenClaw adapter tools.
3
3
  */
4
- import { resolveCameraSubscribeTopic, toNamespacedTopic, toNamespacedTopicFull } from "@agenticros/core";
4
+ import { resolveCameraSubscribeTopic, toNamespacedTopic, toNamespacedTopicFull, listAllCapabilities, runMission, listRobots, getActiveRobotId, resolveRobotFromArgs, discoverRobots, findRobotsFor, generateMissionId, createMemoryTranscriptSink, missionTranscriptNamespace, compileGoalToMission, } from "@agenticros/core";
5
+ import { getMissionRegistry } from "./mission-registry.js";
5
6
  import { ROS_MSG_COMPRESSED_IMAGE, ROS_MSG_IMAGE, cameraSnapshotFromPlainMessage, mimeTypeForSnapshotBase64, rosNumericField, } from "@agenticros/ros-camera";
6
7
  import { resolveMemoryNamespace } from "@agenticros/core";
7
- import { getTransport } from "./transport.js";
8
+ import { getTransportForRobot } from "./transport.js";
8
9
  import { checkPublishSafety } from "./safety.js";
9
10
  import { getDepthDistance } from "./depth.js";
10
11
  import { getFollowMeLocal } from "./follow-me/loop.js";
12
+ import { getFollowMeDepth } from "./follow-me/depth-loop.js";
11
13
  import { findObject } from "./find-object/find-object.js";
12
14
  import { ensureMemory } from "./memory.js";
13
15
  const DEFAULT_DEPTH_TOPIC = "/camera/camera/depth/image_rect_raw";
@@ -21,170 +23,252 @@ export const MEMORY_TOOL_NAMES = new Set([
21
23
  "memory_forget",
22
24
  "memory_status",
23
25
  ]);
26
+ /**
27
+ * Tools that read purely from local config / filesystem and don't need the
28
+ * ROS transport. The MCP server entry point uses this to skip
29
+ * `ensureConnected()` so these tools work even when zenohd is down.
30
+ *
31
+ * Memory tools are included because they're cross-adapter local storage.
32
+ * `ros2_list_capabilities` is included because it reads skill manifests.
33
+ */
34
+ export const NO_TRANSPORT_TOOL_NAMES = new Set([
35
+ ...MEMORY_TOOL_NAMES,
36
+ "ros2_list_capabilities",
37
+ "ros2_list_robots",
38
+ // ros2_find_robots_for can run config-only (no online filter); the
39
+ // tool falls back to a transport-driven discovery only when the caller
40
+ // passes online=true. See the handler for the precise branch.
41
+ "ros2_find_robots_for",
42
+ // mission_cancel only mutates the in-process MissionRegistry — it
43
+ // never publishes to ROS, so it must work even when zenohd is down.
44
+ "mission_cancel",
45
+ // run_mission's *outer* call doesn't need transport — its handler
46
+ // first compiles the goal (Phase 1.g, no transport) or validates
47
+ // the mission shape, then per-step calls go through the transport
48
+ // pool individually. Gating the outer call would block the planner
49
+ // from surfacing compile errors when the transport is down (e.g. a
50
+ // user asking "paint the wall" should get suggestions, not an
51
+ // ECONNREFUSED).
52
+ "run_mission",
53
+ ]);
24
54
  export const TOOLS = [
25
55
  {
26
56
  name: "ros2_list_topics",
27
57
  description: "List all available ROS2 topics and their message types. Use this to discover what data the robot publishes and what commands it accepts.",
28
58
  inputSchema: { type: "object", properties: {} },
29
59
  },
60
+ {
61
+ name: "ros2_list_capabilities",
62
+ description: "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.",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: {
66
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used. Today every robot exposes the same capabilities, but this parameter is accepted so per-robot capability declarations can extend the registry without a schema change." },
67
+ },
68
+ },
69
+ },
70
+ {
71
+ name: "ros2_list_robots",
72
+ description: "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 later tools (in upcoming iterations) will accept as a `robot_id` parameter — today there's a single active robot, but the field will scope per-tool calls in fleet deployments.",
73
+ inputSchema: { type: "object", properties: {} },
74
+ },
75
+ {
76
+ name: "ros2_discover_robots",
77
+ description: "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 a topicCount that says how many corroborating topics live under that 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 (candidates to add via the CLI / config UI). 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.",
78
+ inputSchema: { type: "object", properties: {} },
79
+ },
80
+ {
81
+ name: "ros2_find_robots_for",
82
+ description: "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.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ capability: {
87
+ type: "string",
88
+ description: "Capability id to match (e.g. 'follow_person', 'find_object', 'drive_base'). Case-sensitive — use ros2_list_capabilities to get the exact list.",
89
+ },
90
+ kind: {
91
+ type: "string",
92
+ description: "Robot kind filter (case-insensitive exact match). Common values: 'amr', 'arm', 'drone', 'rover'.",
93
+ },
94
+ online: {
95
+ type: "boolean",
96
+ description: "When true, only return robots currently reachable on the ROS2 graph (requires the transport to be connected). When false, only return robots NOT reachable. When omitted, online status is annotated on every match but doesn't filter the list.",
97
+ },
98
+ },
99
+ },
100
+ },
30
101
  {
31
102
  name: "ros2_publish",
32
- 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).",
103
+ 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 in a multi-robot deployment; omitted = active robot.",
33
104
  inputSchema: {
34
105
  type: "object",
35
106
  properties: {
36
107
  topic: { type: "string", description: "The ROS2 topic name (e.g., '/cmd_vel')" },
37
108
  type: { type: "string", description: "The ROS2 message type (e.g., 'geometry_msgs/msg/Twist')" },
38
109
  message: { type: "object", description: "The message payload matching the ROS2 message type schema" },
110
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
39
111
  },
40
112
  required: ["topic", "type", "message"],
41
113
  },
42
114
  },
43
115
  {
44
116
  name: "ros2_subscribe_once",
45
- 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.",
117
+ 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.",
46
118
  inputSchema: {
47
119
  type: "object",
48
120
  properties: {
49
121
  topic: { type: "string", description: "The ROS2 topic name (e.g., '/battery_state')" },
50
122
  type: { type: "string", description: "The ROS2 message type (optional)" },
51
123
  timeout: { type: "number", description: "Timeout in milliseconds (default: 5000)" },
124
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
52
125
  },
53
126
  required: ["topic"],
54
127
  },
55
128
  },
56
129
  {
57
130
  name: "ros2_service_call",
58
- description: "Call a ROS2 service and return the response. Use for request/response operations like setting parameters or querying node state.",
131
+ 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.",
59
132
  inputSchema: {
60
133
  type: "object",
61
134
  properties: {
62
135
  service: { type: "string", description: "The ROS2 service name (e.g., '/spawn_entity')" },
63
136
  type: { type: "string", description: "The ROS2 service type (optional)" },
64
137
  args: { type: "object", description: "The service request arguments" },
138
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
65
139
  },
66
140
  required: ["service"],
67
141
  },
68
142
  },
69
143
  {
70
144
  name: "ros2_action_goal",
71
- description: "Send a goal to a ROS2 action server. Use for long-running operations like navigation or arm movements.",
145
+ 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.",
72
146
  inputSchema: {
73
147
  type: "object",
74
148
  properties: {
75
149
  action: { type: "string", description: "The ROS2 action server name (e.g., '/navigate_to_pose')" },
76
150
  actionType: { type: "string", description: "The ROS2 action type (e.g., 'nav2_msgs/action/NavigateToPose')" },
77
151
  goal: { type: "object", description: "The action goal parameters" },
152
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
78
153
  },
79
154
  required: ["action", "actionType", "goal"],
80
155
  },
81
156
  },
82
157
  {
83
158
  name: "ros2_param_get",
84
- description: "Get the value of a ROS2 parameter from a node. Use to check robot configuration values.",
159
+ 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.",
85
160
  inputSchema: {
86
161
  type: "object",
87
162
  properties: {
88
163
  node: { type: "string", description: "The fully qualified node name (e.g., '/turtlebot3/controller')" },
89
164
  parameter: { type: "string", description: "The parameter name (e.g., 'max_velocity')" },
165
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
90
166
  },
91
167
  required: ["node", "parameter"],
92
168
  },
93
169
  },
94
170
  {
95
171
  name: "ros2_param_set",
96
- description: "Set the value of a ROS2 parameter on a node. Use to change robot configuration at runtime.",
172
+ 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.",
97
173
  inputSchema: {
98
174
  type: "object",
99
175
  properties: {
100
176
  node: { type: "string", description: "The fully qualified node name" },
101
177
  parameter: { type: "string", description: "The parameter name" },
102
178
  value: { type: "object", description: "The new parameter value" },
179
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
103
180
  },
104
181
  required: ["node", "parameter", "value"],
105
182
  },
106
183
  },
107
184
  {
108
185
  name: "ros2_camera_snapshot",
109
- 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.",
186
+ 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; the per-robot default cameraTopic is used when no topic is given.",
110
187
  inputSchema: {
111
188
  type: "object",
112
189
  properties: {
113
- topic: { type: "string", description: "Camera image topic (default from robot.cameraTopic in ~/.agenticros/config.json). Run ros2 topic list and match your driver." },
190
+ topic: { type: "string", description: "Camera image topic (default from the robot's cameraTopic in ~/.agenticros/config.json). Run ros2 topic list and match your driver." },
114
191
  message_type: { type: "string", description: "'CompressedImage' (JPEG topics, names often contain /compressed) or 'Image' for raw sensor_msgs/Image—required if there is no compressed topic." },
115
192
  timeout: { type: "number", description: "Timeout in milliseconds (default: 10000)" },
193
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
116
194
  },
117
195
  },
118
196
  },
119
197
  {
120
198
  name: "ros2_depth_distance",
121
- 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.",
199
+ 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.",
122
200
  inputSchema: {
123
201
  type: "object",
124
202
  properties: {
125
203
  topic: { type: "string", description: `Depth image topic (default: ${DEFAULT_DEPTH_TOPIC})` },
126
204
  timeout: { type: "number", description: "Timeout in ms (default 5000)" },
205
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
127
206
  },
128
207
  },
129
208
  },
130
209
  {
131
210
  name: "ros2_follow_me_start",
132
- description: "Start the follow-me skill — the robot follows a person. Optional target description to lock onto a specific person; otherwise follows the closest. Modes: 'node' (default) sends a command to the agenticros_follow_me ROS2 node running on the robot; 'local' runs an in-process YOLOv8n loop in the MCP server (no ROS2 node required, ~8 Hz).",
211
+ description: "Start the follow-me skill — the robot follows a person. Optional target description to lock onto a specific person; otherwise follows the closest. Modes: 'depth' (default) runs an in-process depth-only loop in the MCP server (no neural net, no model file, just RealSense depth — drives toward the closest blob in [0.5, 4.0] m); 'node' sends a command to the agenticros_follow_me ROS2 node running on the robot; 'local' runs an in-process YOLOv8n loop (requires yolov8n.onnx, ~8 Hz). Pass robot_id (from ros2_list_robots) to start follow-me on a specific robot in a multi-robot deployment; each robot keeps its own independent follow loop.",
133
212
  inputSchema: {
134
213
  type: "object",
135
214
  properties: {
136
215
  mode: {
137
216
  type: "string",
138
- description: "'node' (default) for the ROS2 node, or 'local' for the in-process MCP loop.",
217
+ description: "'depth' (default) for the in-process depth-only loop, 'node' for the ROS2 node, or 'local' for the in-process YOLO loop.",
139
218
  },
140
219
  target_description: {
141
220
  type: "string",
142
- description: "Optional description of the person to follow (e.g., 'person in red shirt'). Empty = follow closest.",
221
+ description: "Optional description of the person to follow (e.g., 'person in red shirt'). Empty = follow closest. Note: depth mode ignores this — it always follows the closest object.",
143
222
  },
223
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
144
224
  },
145
225
  },
146
226
  },
147
227
  {
148
228
  name: "ros2_follow_me_stop",
149
- description: "Stop the follow-me skill. Robot will stop sending follow velocity commands. Pass mode='local' to stop the in-process loop.",
229
+ description: "Stop the follow-me skill. Robot will stop sending follow velocity commands. Pass mode='local' for the YOLO loop or mode='depth' for the depth-only loop. Pass robot_id to stop a specific robot; omitted = active robot.",
150
230
  inputSchema: {
151
231
  type: "object",
152
232
  properties: {
153
- mode: { type: "string", description: "'node' (default) or 'local'." },
233
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
234
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
154
235
  },
155
236
  },
156
237
  },
157
238
  {
158
239
  name: "ros2_follow_me_status",
159
- description: "Read the current follow-me status (enabled, tracking, target distance, persons detected). For mode='node' returns the latest message from follow_me/status; for mode='local' returns the in-process loop status.",
240
+ description: "Read the current follow-me status (enabled, tracking, target distance, persons detected). For mode='node' returns the latest message from follow_me/status; for mode='depth' or 'local' returns the in-process loop status. Pass robot_id to read a specific robot's status; omitted = active robot.",
160
241
  inputSchema: {
161
242
  type: "object",
162
243
  properties: {
163
- mode: { type: "string", description: "'node' (default) or 'local'." },
244
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
164
245
  timeout: { type: "number", description: "Timeout in milliseconds (default: 3000). Only used for mode='node'." },
246
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
165
247
  },
166
248
  },
167
249
  },
168
250
  {
169
251
  name: "ros2_follow_me_set_distance",
170
- description: "Set the follow-me target distance in meters. Clamped server-side to [0.2, 5.0].",
252
+ description: "Set the follow-me target distance in meters. Clamped server-side to [0.2, 5.0]. Pass robot_id to target a specific robot's loop; omitted = active robot.",
171
253
  inputSchema: {
172
254
  type: "object",
173
255
  properties: {
174
- mode: { type: "string", description: "'node' (default) or 'local'." },
256
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
175
257
  distance: { type: "number", description: "Target distance in meters (0.2 to 5.0)" },
258
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
176
259
  },
177
260
  required: ["distance"],
178
261
  },
179
262
  },
180
263
  {
181
264
  name: "ros2_follow_me_set_target",
182
- description: "Lock the follow-me tracker onto a person described by text. Locks onto the closest visible person and stores the description for future re-identification.",
265
+ description: "Lock the follow-me tracker onto a person described by text. Locks onto the closest visible person and stores the description for future re-identification. Pass robot_id to target a specific robot's loop; omitted = active robot.",
183
266
  inputSchema: {
184
267
  type: "object",
185
268
  properties: {
186
- mode: { type: "string", description: "'node' (default) or 'local'." },
269
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
187
270
  description: { type: "string", description: "Description of the person to follow" },
271
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
188
272
  },
189
273
  required: ["description"],
190
274
  },
@@ -240,7 +324,7 @@ export const TOOLS = [
240
324
  },
241
325
  {
242
326
  name: "ros2_find_object",
243
- description: "Rotate the robot in place (clockwise by default) until a target object is detected by YOLOv8n in the camera feed, then stop. Target must be a COCO class name (e.g., 'cell phone', 'chair', 'bottle', 'cup', 'laptop'). Returns whether the object was found, its confidence, bounding box, and horizontal offset from image center (-1=left edge, 0=center, +1=right edge).",
327
+ description: "Rotate the robot in place (clockwise by default) until a target object is detected by YOLOv8n in the camera feed, then stop. Target must be a COCO class name (e.g., 'cell phone', 'chair', 'bottle', 'cup', 'laptop'). Returns whether the object was found, its confidence, bounding box, and horizontal offset from image center (-1=left edge, 0=center, +1=right edge). Pass robot_id (from ros2_list_robots) to scan with a specific robot's camera; omitted = active robot.",
244
328
  inputSchema: {
245
329
  type: "object",
246
330
  properties: {
@@ -264,21 +348,68 @@ export const TOOLS = [
264
348
  type: "number",
265
349
  description: "Minimum detection confidence to accept (default 0.5).",
266
350
  },
351
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
267
352
  },
268
353
  required: ["target"],
269
354
  },
270
355
  },
356
+ {
357
+ name: "run_mission",
358
+ description: "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') AND/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. 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 robot_id at the top level when using goal) to target every step at one robot.",
359
+ inputSchema: {
360
+ type: "object",
361
+ properties: {
362
+ goal: {
363
+ type: "string",
364
+ description: "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; if both are given, mission takes precedence (goal is then ignored).",
365
+ },
366
+ mission: {
367
+ type: "object",
368
+ description: 'Declarative mission plan. Shape: { 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}}" } }] }',
369
+ },
370
+ robot_id: {
371
+ type: "string",
372
+ description: "Optional robot id (from ros2_list_robots) — used when 'goal' is provided to scope every compiled step to one robot.",
373
+ },
374
+ },
375
+ },
376
+ },
377
+ {
378
+ name: "mission_cancel",
379
+ description: "Cancel a mission that's currently running in this MCP server. 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.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ mission_id: {
384
+ type: "string",
385
+ description: "The mission_id echoed by run_mission. Required.",
386
+ },
387
+ reason: {
388
+ type: "string",
389
+ description: "Optional free-text reason — surfaced in the cancelled mission result.",
390
+ },
391
+ },
392
+ required: ["mission_id"],
393
+ },
394
+ },
271
395
  ];
272
396
  function followMeMode(args) {
273
- const raw = String(args["mode"] ?? "node").toLowerCase().trim();
274
- return raw === "local" ? "local" : "node";
397
+ const raw = String(args["mode"] ?? "depth").toLowerCase().trim();
398
+ if (raw === "local")
399
+ return "local";
400
+ if (raw === "depth")
401
+ return "depth";
402
+ return "node";
275
403
  }
276
- async function publishFollowMeCmd(config, payload) {
277
- const transport = getTransport();
404
+ async function publishFollowMeCmd(config, robot, payload) {
405
+ // Route through the per-robot pool so a robot with a custom transport
406
+ // override (e.g. zenoh-on-router) gets its own connection while
407
+ // single-transport deployments still share `__global__`.
408
+ const transport = await getTransportForRobot(config, robot);
278
409
  if (transport.getStatus() !== "connected") {
279
410
  throw new Error("Transport not connected. Check zenohd (ws://localhost:10000) and config in ~/.agenticros/config.json.");
280
411
  }
281
- const topic = toNamespacedTopicFull(config, "/follow_me/cmd");
412
+ const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/cmd");
282
413
  const data = JSON.stringify(payload);
283
414
  const PUBLISH_TIMEOUT_MS = 5_000;
284
415
  const timeoutPromise = new Promise((_, reject) => {
@@ -290,26 +421,502 @@ async function publishFollowMeCmd(config, payload) {
290
421
  ]);
291
422
  return { topic, payload };
292
423
  }
424
+ /**
425
+ * Format the capability list for tool responses — shared by claude-code,
426
+ * OpenClaw, and Gemini so every adapter returns the same JSON shape.
427
+ */
428
+ function formatCapabilitiesResponse(caps) {
429
+ const byKind = {
430
+ intrinsic: caps.filter((c) => c.source?.kind === "builtin").length,
431
+ skill: caps.filter((c) => c.source?.kind === "skill").length,
432
+ };
433
+ return JSON.stringify({
434
+ success: true,
435
+ total: caps.length,
436
+ intrinsic_count: byKind.intrinsic,
437
+ skill_count: byKind.skill,
438
+ capabilities: caps,
439
+ });
440
+ }
441
+ /**
442
+ * Convert an unknown-robot_id error from resolveRobotFromArgs into a
443
+ * tool-level error response. The error message already lists known ids
444
+ * and recommends ros2_list_robots, so the agent has everything it
445
+ * needs to recover without us re-formatting.
446
+ */
447
+ function robotResolveError(err) {
448
+ const message = err instanceof Error ? err.message : String(err);
449
+ return { content: [{ type: "text", text: message }], isError: true };
450
+ }
451
+ /**
452
+ * Format the robot list for tool responses — shared by claude-code,
453
+ * OpenClaw, and Gemini so every adapter returns the same JSON shape.
454
+ */
455
+ function formatRobotsResponse(robots, activeId) {
456
+ return JSON.stringify({
457
+ success: true,
458
+ total: robots.length,
459
+ active_robot_id: activeId,
460
+ robots,
461
+ });
462
+ }
463
+ /**
464
+ * Render the find_robots_for result as a single LLM-friendly JSON
465
+ * payload. We flatten each `FindRobotsForMatch` so the agent doesn't
466
+ * have to dig through `.robot.*` to read id/name/namespace — those are
467
+ * the fields it'll pass to the next tool call.
468
+ */
469
+ function formatFindRobotsForResponse(result) {
470
+ return JSON.stringify({
471
+ success: true,
472
+ query: result.query,
473
+ total: result.total,
474
+ robots: result.robots.map((m) => ({
475
+ id: m.robot.id,
476
+ name: m.robot.name,
477
+ namespace: m.robot.namespace,
478
+ kind: m.robot.kind,
479
+ sensors: m.robot.sensors,
480
+ capabilities: m.robot.capabilities ?? null,
481
+ cameraTopic: m.robot.cameraTopic,
482
+ online: m.online,
483
+ matched_capability_explicitly: m.matched_capability_explicitly,
484
+ score: m.score,
485
+ })),
486
+ });
487
+ }
488
+ /**
489
+ * Capability → MCP tool dispatch table used by `run_mission`.
490
+ *
491
+ * Every capability returned by `ros2_list_capabilities` that the mission
492
+ * runner can actually execute must appear here. If you add a new
493
+ * intrinsic capability to BUILTIN_CAPABILITIES, mirror it here. For new
494
+ * skill-declared capabilities, this is where you map them to the
495
+ * corresponding MCP tool. Steps that name a capability without an entry
496
+ * fail with an actionable "no mission-runner tool binding" error.
497
+ */
498
+ const MISSION_BINDINGS = {
499
+ drive_base: {
500
+ tool: "ros2_publish",
501
+ buildArgs: (inputs) => {
502
+ const lx = Number(inputs.linear_x ?? 0) || 0;
503
+ const az = Number(inputs.angular_z ?? 0) || 0;
504
+ return {
505
+ topic: "/cmd_vel",
506
+ type: "geometry_msgs/msg/Twist",
507
+ message: {
508
+ linear: { x: lx, y: 0, z: 0 },
509
+ angular: { x: 0, y: 0, z: az },
510
+ },
511
+ };
512
+ },
513
+ },
514
+ take_snapshot: {
515
+ tool: "ros2_camera_snapshot",
516
+ buildArgs: (inputs) => {
517
+ const out = {};
518
+ if (typeof inputs.topic === "string")
519
+ out.topic = inputs.topic;
520
+ if (typeof inputs.message_type === "string")
521
+ out.message_type = inputs.message_type;
522
+ if (typeof inputs.timeout === "number")
523
+ out.timeout = inputs.timeout;
524
+ return out;
525
+ },
526
+ },
527
+ measure_depth: {
528
+ tool: "ros2_depth_distance",
529
+ buildArgs: (inputs) => {
530
+ const out = {};
531
+ if (typeof inputs.topic === "string")
532
+ out.topic = inputs.topic;
533
+ if (typeof inputs.timeout === "number")
534
+ out.timeout = inputs.timeout;
535
+ return out;
536
+ },
537
+ },
538
+ list_topics: {
539
+ tool: "ros2_list_topics",
540
+ buildArgs: () => ({}),
541
+ },
542
+ publish_topic: {
543
+ tool: "ros2_publish",
544
+ buildArgs: (inputs) => ({
545
+ topic: String(inputs.topic ?? ""),
546
+ type: String(inputs.type ?? inputs.msg_type ?? ""),
547
+ message: inputs.message ?? inputs.msg ?? {},
548
+ }),
549
+ },
550
+ subscribe_once: {
551
+ tool: "ros2_subscribe_once",
552
+ buildArgs: (inputs) => {
553
+ const out = { topic: String(inputs.topic ?? "") };
554
+ if (typeof inputs.type === "string")
555
+ out.type = inputs.type;
556
+ if (typeof inputs.timeout === "number")
557
+ out.timeout = inputs.timeout;
558
+ return out;
559
+ },
560
+ },
561
+ follow_person: {
562
+ tool: "ros2_follow_me_start",
563
+ buildArgs: (inputs) => {
564
+ const out = {};
565
+ if (typeof inputs.target_distance === "number")
566
+ out.target_distance = inputs.target_distance;
567
+ if (typeof inputs.mode === "string")
568
+ out.mode = inputs.mode;
569
+ return out;
570
+ },
571
+ },
572
+ find_object: {
573
+ tool: "ros2_find_object",
574
+ buildArgs: (inputs) => {
575
+ const target = String(inputs.target ?? "");
576
+ const out = { target };
577
+ if (typeof inputs.angular_speed === "number")
578
+ out.angular_speed = inputs.angular_speed;
579
+ if (typeof inputs.clockwise === "boolean")
580
+ out.clockwise = inputs.clockwise;
581
+ if (typeof inputs.timeout_seconds === "number")
582
+ out.timeout_seconds = inputs.timeout_seconds;
583
+ if (typeof inputs.min_confidence === "number")
584
+ out.min_confidence = inputs.min_confidence;
585
+ return out;
586
+ },
587
+ },
588
+ };
589
+ /**
590
+ * Phase 1.f — `mission_cancel` handler. Lives at module scope so the
591
+ * outer `handleToolCall` can invoke it WITHOUT first resolving a
592
+ * transport (the cancel never touches ROS — see the registry note in
593
+ * `mission-registry.ts`).
594
+ */
595
+ function handleMissionCancel(args) {
596
+ const missionId = typeof args["mission_id"] === "string" ? args["mission_id"].trim() : "";
597
+ if (!missionId) {
598
+ return {
599
+ content: [
600
+ {
601
+ type: "text",
602
+ text: "mission_cancel requires 'mission_id' (a non-empty string returned by run_mission).",
603
+ },
604
+ ],
605
+ isError: true,
606
+ };
607
+ }
608
+ const reason = typeof args["reason"] === "string" ? args["reason"] : undefined;
609
+ const outcome = getMissionRegistry().cancel(missionId, reason);
610
+ return {
611
+ content: [
612
+ {
613
+ type: "text",
614
+ text: JSON.stringify({
615
+ success: true,
616
+ mission_id: missionId,
617
+ found: outcome.found,
618
+ already_cancelled: outcome.alreadyCancelled,
619
+ reason: reason ?? null,
620
+ }),
621
+ },
622
+ ],
623
+ };
624
+ }
625
+ /**
626
+ * Phase 1.c + 1.f + 1.g — `run_mission` handler. Lives at module scope
627
+ * so the outer `handleToolCall` can short-circuit BEFORE
628
+ * `getTransportForRobot()` (the per-step dispatcher resolves transport
629
+ * tool-by-tool; the outer call must not). Accepts either an explicit
630
+ * `mission.steps[]` plan OR a natural-language `goal` (compiled via
631
+ * the local rule-based planner).
632
+ */
633
+ async function handleRunMission(args, config) {
634
+ const caps = listAllCapabilities(config);
635
+ const missionArg = args["mission"];
636
+ const goalArg = args["goal"];
637
+ const topLevelRobotId = typeof args["robot_id"] === "string" ? args["robot_id"] : undefined;
638
+ // Phase 1.g — when no explicit mission is provided, compile from
639
+ // the natural-language goal. We surface the planner's candidates
640
+ // + suggestions in the response so the agent can self-correct on
641
+ // a failed compile without a second round-trip.
642
+ let mission;
643
+ let plannerInfo;
644
+ if (missionArg && typeof missionArg === "object" && !Array.isArray(missionArg)) {
645
+ mission = missionArg;
646
+ if (!Array.isArray(mission.steps)) {
647
+ return {
648
+ content: [{ type: "text", text: "mission.steps must be an array of step objects." }],
649
+ isError: true,
650
+ };
651
+ }
652
+ }
653
+ else if (typeof goalArg === "string" && goalArg.trim().length > 0) {
654
+ const planned = compileGoalToMission(goalArg, caps, { robot_id: topLevelRobotId });
655
+ if (!planned.mission) {
656
+ return {
657
+ content: [
658
+ {
659
+ type: "text",
660
+ text: JSON.stringify({
661
+ success: false,
662
+ error: planned.error,
663
+ goal: goalArg,
664
+ suggestions: planned.suggestions,
665
+ ...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
666
+ }),
667
+ },
668
+ ],
669
+ isError: true,
670
+ };
671
+ }
672
+ mission = planned.mission;
673
+ plannerInfo = {
674
+ compiled_from_goal: goalArg,
675
+ candidates: planned.candidates,
676
+ ...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
677
+ };
678
+ }
679
+ else {
680
+ return {
681
+ content: [
682
+ {
683
+ type: "text",
684
+ text: 'run_mission requires either "mission" (object with steps[]) or "goal" (natural-language string). Pass at least one.',
685
+ },
686
+ ],
687
+ isError: true,
688
+ };
689
+ }
690
+ if (typeof mission.robot_id === "string" && mission.robot_id.trim().length > 0) {
691
+ try {
692
+ resolveRobotFromArgs(config, { robot_id: mission.robot_id });
693
+ }
694
+ catch (err) {
695
+ return robotResolveError(err);
696
+ }
697
+ }
698
+ const dispatcher = async (toolName, toolArgs) => {
699
+ const res = await handleToolCall(toolName, toolArgs, config);
700
+ const text = res.content
701
+ .map((c) => (c.type === "text" ? c.text : `[image: ${c.mimeType}]`))
702
+ .join("\n");
703
+ return { text, isError: res.isError };
704
+ };
705
+ const missionId = generateMissionId();
706
+ const registry = getMissionRegistry();
707
+ const { entry: regEntry, dispose: disposeRegistry } = registry.register(missionId, {
708
+ name: mission.name,
709
+ });
710
+ const memory = await ensureMemory(config);
711
+ const transcript = memory ? createMemoryTranscriptSink(memory, missionId) : undefined;
712
+ let result;
713
+ try {
714
+ result = await runMission(mission, caps, MISSION_BINDINGS, dispatcher, {
715
+ mission_id: missionId,
716
+ cancellation: regEntry.cancellation,
717
+ transcript,
718
+ adapter: "claude-code",
719
+ });
720
+ }
721
+ finally {
722
+ disposeRegistry();
723
+ }
724
+ const compact = {
725
+ status: result.status,
726
+ mission_id: result.mission_id,
727
+ ...(result.cancellation_reason ? { cancellation_reason: result.cancellation_reason } : {}),
728
+ ...(transcript ? { transcript_namespace: missionTranscriptNamespace(missionId) } : {}),
729
+ ...(plannerInfo ? { planner: plannerInfo } : {}),
730
+ steps_run: result.steps_run,
731
+ steps_total: result.steps_total,
732
+ duration_ms: result.duration_ms,
733
+ summary: result.summary,
734
+ steps: result.steps.map((s) => ({
735
+ id: s.id,
736
+ capability: s.capability,
737
+ status: s.status,
738
+ inputs: s.inputs,
739
+ outputs: s.outputs,
740
+ ...(s.error ? { error: s.error } : {}),
741
+ duration_ms: s.duration_ms,
742
+ })),
743
+ };
744
+ return {
745
+ content: [{ type: "text", text: `${result.summary}\n${JSON.stringify(compact)}` }],
746
+ isError: result.status === "error",
747
+ };
748
+ }
293
749
  export async function handleToolCall(name, args, config) {
294
750
  // Memory tools are self-contained — they never touch the ROS transport, so
295
751
  // dispatch them before getTransport() (which throws when zenohd is down).
296
752
  if (MEMORY_TOOL_NAMES.has(name)) {
297
753
  return handleMemoryToolCall(name, args, config);
298
754
  }
299
- const transport = getTransport();
755
+ // ros2_list_capabilities reads skill manifests + intrinsic verbs from
756
+ // local config — no transport required. robot_id is accepted but
757
+ // doesn't change today's response (every robot exposes the same
758
+ // capabilities); we still validate it so an unknown id surfaces as a
759
+ // tool error instead of being silently ignored.
760
+ if (name === "ros2_list_capabilities") {
761
+ try {
762
+ resolveRobotFromArgs(config, args);
763
+ }
764
+ catch (err) {
765
+ return robotResolveError(err);
766
+ }
767
+ const caps = listAllCapabilities(config);
768
+ return { content: [{ type: "text", text: formatCapabilitiesResponse(caps) }] };
769
+ }
770
+ // ros2_list_robots reads the multi-robot section of the config (with
771
+ // legacy fallback) — no transport required either.
772
+ if (name === "ros2_list_robots") {
773
+ const robots = listRobots(config);
774
+ const active = getActiveRobotId(config);
775
+ return {
776
+ content: [{ type: "text", text: formatRobotsResponse(robots, active) }],
777
+ };
778
+ }
779
+ // ros2_find_robots_for is config-driven by default and only touches
780
+ // the transport when the caller filters by online status. Putting it
781
+ // here (above the unconditional transport resolution) lets agents
782
+ // call it offline — useful for static-fleet planning and for the
783
+ // CLI's `agenticros robots` family which reuses this MCP tool.
784
+ if (name === "ros2_find_robots_for") {
785
+ const cap = typeof args["capability"] === "string" ? args["capability"] : undefined;
786
+ const kind = typeof args["kind"] === "string" ? args["kind"] : undefined;
787
+ const online = typeof args["online"] === "boolean" ? args["online"] : undefined;
788
+ let onlineIds;
789
+ if (online !== undefined) {
790
+ // Online filtering needs ONE live transport to list topics with.
791
+ // The active robot's transport works fine here — discoverRobots()
792
+ // operates on the global topic graph and doesn't care which
793
+ // namespace we listed from.
794
+ let activeRobot;
795
+ try {
796
+ activeRobot = resolveRobotFromArgs(config, {});
797
+ }
798
+ catch (err) {
799
+ return robotResolveError(err);
800
+ }
801
+ const transport = await getTransportForRobot(config, activeRobot);
802
+ if (transport.getStatus() !== "connected") {
803
+ return {
804
+ content: [
805
+ {
806
+ type: "text",
807
+ text: JSON.stringify({
808
+ success: false,
809
+ error: "online filter requires the ROS transport to be connected. Drop the 'online' arg to run config-only, or check that zenohd / rosbridge is up.",
810
+ }),
811
+ },
812
+ ],
813
+ };
814
+ }
815
+ const topics = await transport.listTopics();
816
+ const disc = discoverRobots(topics, config);
817
+ onlineIds = new Set(disc.configured_online.map((r) => r.id));
818
+ }
819
+ try {
820
+ const result = findRobotsFor(config, { capability: cap, kind, online }, onlineIds);
821
+ return { content: [{ type: "text", text: formatFindRobotsForResponse(result) }] };
822
+ }
823
+ catch (e) {
824
+ return {
825
+ content: [
826
+ { type: "text", text: JSON.stringify({ success: false, error: e instanceof Error ? e.message : String(e) }) },
827
+ ],
828
+ };
829
+ }
830
+ }
831
+ // mission_cancel + run_mission must NOT trigger a transport connect
832
+ // at this outer layer: mission_cancel only mutates the in-process
833
+ // MissionRegistry, and run_mission's handler does its OWN per-step
834
+ // dispatch (each step's inner `handleToolCall` connects as needed).
835
+ // Routing the outer call through `getTransportForRobot()` would
836
+ // hang or fail when an agent only wanted to compile / cancel.
837
+ if (name === "mission_cancel") {
838
+ return handleMissionCancel(args);
839
+ }
840
+ if (name === "run_mission") {
841
+ return handleRunMission(args, config);
842
+ }
843
+ // Resolve target robot once for every transport-bound tool. Unknown
844
+ // robot_id surfaces as a tool error (not a thrown exception) so the
845
+ // LLM sees a clean self-correctable response.
846
+ let robot;
847
+ try {
848
+ robot = resolveRobotFromArgs(config, args);
849
+ }
850
+ catch (err) {
851
+ return robotResolveError(err);
852
+ }
853
+ // Per-robot transport pool: returns the shared `__global__` entry
854
+ // when this robot has no override (the common case), OR materialises
855
+ // a dedicated transport on first use when the robot declares its own
856
+ // (e.g. one robot via local DDS + another via a zenoh router).
857
+ const transport = await getTransportForRobot(config, robot);
300
858
  switch (name) {
301
859
  case "ros2_list_topics": {
302
860
  const topics = await transport.listTopics();
303
861
  const MAX = 50;
304
862
  const truncated = topics.length > MAX ? topics.slice(0, MAX) : topics;
863
+ // Surface task-specific control hints so the agent (and any LLM
864
+ // calling list_topics to figure out what's available) doesn't have to
865
+ // memorise the topic surface for each demo. Cheap to compute and only
866
+ // included when matching topics are actually present.
867
+ const hints = {};
868
+ const armJoints = topics
869
+ .filter((t) => /^\/arm\/[a-z0-9_]+\/cmd_pos$/i.test(t.name))
870
+ .map((t) => t.name.replace(/^\/arm\/(.+)\/cmd_pos$/, "$1"));
871
+ if (armJoints.length > 0) {
872
+ hints["arm"] =
873
+ `Detected the AgenticROS sim arm (joints: ${armJoints.join(", ")}). ` +
874
+ "To move a joint, call ros2_publish with topic '/arm/<joint>/cmd_pos', " +
875
+ "type 'std_msgs/msg/Float64', message {data: <radians>}. " +
876
+ "Example: rotate shoulder_pan 90 degrees left -> publish {data: 1.5707} " +
877
+ "to /arm/shoulder_pan/cmd_pos. Listen to /joint_states (sensor_msgs/msg/JointState) " +
878
+ "to read current positions.";
879
+ }
880
+ const baseTwistTopics = topics.filter((t) => /\/cmd_vel$/.test(t.name) && t.type === "geometry_msgs/msg/Twist");
881
+ if (baseTwistTopics.length > 0) {
882
+ hints["base"] =
883
+ "To drive the base, publish geometry_msgs/msg/Twist to a cmd_vel topic. " +
884
+ `Available cmd_vel topics: ${baseTwistTopics.map((t) => t.name).join(", ")}. ` +
885
+ "When in sim mode the unnamespaced /cmd_vel is the one the simulator listens on.";
886
+ }
305
887
  const text = JSON.stringify({
306
888
  success: true,
307
889
  topics: truncated,
308
890
  total: topics.length,
309
891
  truncated: topics.length > MAX,
892
+ ...(Object.keys(hints).length > 0 ? { hints } : {}),
310
893
  });
311
894
  return { content: [{ type: "text", text }] };
312
895
  }
896
+ case "ros2_discover_robots": {
897
+ // Scan the live topic graph, then classify against the configured
898
+ // robot list using the shared pure-function in @agenticros/core.
899
+ // Discovery is read-only — nothing is written to config here. The
900
+ // agent (or the CLI / config UI on top) decides whether to
901
+ // promote an unknown_detected entry into config.robots[].
902
+ const topics = await transport.listTopics();
903
+ const result = discoverRobots(topics, config);
904
+ return {
905
+ content: [
906
+ {
907
+ type: "text",
908
+ text: JSON.stringify({
909
+ success: true,
910
+ total_topics: result.total_topics,
911
+ detected: result.detected,
912
+ configured_online: result.configured_online,
913
+ configured_offline: result.configured_offline,
914
+ unknown_detected: result.unknown_detected,
915
+ }),
916
+ },
917
+ ],
918
+ };
919
+ }
313
920
  case "ros2_publish": {
314
921
  const rawTopicIn = String(args["topic"] ?? "").trim();
315
922
  if (process.stderr?.write) {
@@ -333,7 +940,7 @@ export async function handleToolCall(name, args, config) {
333
940
  const segment = cmdVelMatch?.[1] ?? "";
334
941
  const topic = cmdVelMatch && !segment.toLowerCase().startsWith("robot")
335
942
  ? `/robot${segment.replace(/-/g, "")}/cmd_vel`
336
- : toNamespacedTopic(config, rawTopicIn);
943
+ : toNamespacedTopic(robot.namespace, rawTopicIn);
337
944
  if (process.stderr?.write) {
338
945
  process.stderr.write(`[AgenticROS] ros2_publish: → topic=${topic}\n`);
339
946
  }
@@ -355,7 +962,7 @@ export async function handleToolCall(name, args, config) {
355
962
  }
356
963
  case "ros2_subscribe_once": {
357
964
  const rawTopic = args["topic"];
358
- const topic = toNamespacedTopic(config, rawTopic);
965
+ const topic = toNamespacedTopic(robot.namespace, rawTopic);
359
966
  let msgType = args["type"];
360
967
  const timeout = args["timeout"] ?? 5000;
361
968
  if (!msgType && /\/?(camera|image|color|depth)/i.test(rawTopic)) {
@@ -386,7 +993,7 @@ export async function handleToolCall(name, args, config) {
386
993
  }
387
994
  case "ros2_service_call": {
388
995
  const rawService = args["service"];
389
- const service = toNamespacedTopic(config, rawService);
996
+ const service = toNamespacedTopic(robot.namespace, rawService);
390
997
  const type = args["type"];
391
998
  const reqArgs = args["args"];
392
999
  const response = await transport.callService({ service, type, args: reqArgs });
@@ -399,7 +1006,7 @@ export async function handleToolCall(name, args, config) {
399
1006
  }
400
1007
  case "ros2_action_goal": {
401
1008
  const rawAction = args["action"];
402
- const action = toNamespacedTopic(config, rawAction);
1009
+ const action = toNamespacedTopic(robot.namespace, rawAction);
403
1010
  const actionType = args["actionType"];
404
1011
  const goal = args["goal"];
405
1012
  const actionResult = await transport.sendActionGoal({ action, actionType, args: goal });
@@ -412,7 +1019,7 @@ export async function handleToolCall(name, args, config) {
412
1019
  }
413
1020
  case "ros2_param_get": {
414
1021
  const rawNode = args["node"];
415
- const node = toNamespacedTopic(config, rawNode);
1022
+ const node = toNamespacedTopic(robot.namespace, rawNode);
416
1023
  const parameter = args["parameter"];
417
1024
  const response = await transport.callService({
418
1025
  service: `${node}/get_parameters`,
@@ -429,7 +1036,7 @@ export async function handleToolCall(name, args, config) {
429
1036
  }
430
1037
  case "ros2_param_set": {
431
1038
  const rawNode = args["node"];
432
- const node = toNamespacedTopic(config, rawNode);
1039
+ const node = toNamespacedTopic(robot.namespace, rawNode);
433
1040
  const parameter = args["parameter"];
434
1041
  const value = args["value"];
435
1042
  const response = await transport.callService({
@@ -445,9 +1052,9 @@ export async function handleToolCall(name, args, config) {
445
1052
  return { content: [{ type: "text", text }] };
446
1053
  }
447
1054
  case "ros2_camera_snapshot": {
448
- const defaultTopic = (config.robot?.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
1055
+ const defaultTopic = (robot.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
449
1056
  const rawTopic = args["topic"] ?? defaultTopic;
450
- const topic = resolveCameraSubscribeTopic(config, rawTopic);
1057
+ const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
451
1058
  const rawMsgType = args["message_type"];
452
1059
  const messageType = rawMsgType === "Image" ? "Image" : "CompressedImage";
453
1060
  const timeout = args["timeout"] ?? 10000;
@@ -516,7 +1123,7 @@ export async function handleToolCall(name, args, config) {
516
1123
  }
517
1124
  case "ros2_depth_distance": {
518
1125
  const rawTopic = args["topic"]?.trim() || DEFAULT_DEPTH_TOPIC;
519
- const topic = resolveCameraSubscribeTopic(config, rawTopic);
1126
+ const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
520
1127
  const timeout = args["timeout"] ?? 5000;
521
1128
  try {
522
1129
  const result = await getDepthDistance(transport, topic, timeout);
@@ -544,17 +1151,34 @@ export async function handleToolCall(name, args, config) {
544
1151
  };
545
1152
  }
546
1153
  try {
547
- const loop = getFollowMeLocal(config, transport);
1154
+ const loop = getFollowMeLocal(robot, config, transport);
548
1155
  await loop.start({ targetDescription: desc || undefined });
549
- const text = `Follow-me (local) started${desc ? ` (target: ${desc})` : " (closest person)"}. Use ros2_follow_me_status with mode='local' to check tracking state.`;
1156
+ const text = `Follow-me (local) started on ${robot.id}${desc ? ` (target: ${desc})` : " (closest person)"}. Use ros2_follow_me_status with mode='local' to check tracking state.`;
550
1157
  return { content: [{ type: "text", text }] };
551
1158
  }
552
1159
  catch (err) {
553
1160
  return { content: [{ type: "text", text: `Follow-me local start failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
554
1161
  }
555
1162
  }
1163
+ if (mode === "depth") {
1164
+ if (transport.getStatus() !== "connected") {
1165
+ return {
1166
+ content: [{ type: "text", text: "Transport not connected. Check ROS2/transport and config." }],
1167
+ isError: true,
1168
+ };
1169
+ }
1170
+ try {
1171
+ const loop = getFollowMeDepth(robot, config, transport);
1172
+ await loop.start({ targetDescription: desc || undefined });
1173
+ const text = `Follow-me (depth-only) started on ${robot.id} — driving toward the closest blob in [0.5, 4.0] m. No person recognition; will follow whatever object is closest. Use ros2_follow_me_status with mode='depth' to check tracking state.`;
1174
+ return { content: [{ type: "text", text }] };
1175
+ }
1176
+ catch (err) {
1177
+ return { content: [{ type: "text", text: `Follow-me depth start failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
1178
+ }
1179
+ }
556
1180
  try {
557
- const { topic } = await publishFollowMeCmd(config, {
1181
+ const { topic } = await publishFollowMeCmd(config, robot, {
558
1182
  action: "start",
559
1183
  ...(desc ? { target: desc } : {}),
560
1184
  });
@@ -569,16 +1193,26 @@ export async function handleToolCall(name, args, config) {
569
1193
  const mode = followMeMode(args);
570
1194
  if (mode === "local") {
571
1195
  try {
572
- const loop = getFollowMeLocal(config, transport);
1196
+ const loop = getFollowMeLocal(robot, config, transport);
573
1197
  await loop.stop();
574
- return { content: [{ type: "text", text: "Follow-me (local) stopped. cmd_vel zeroed." }] };
1198
+ return { content: [{ type: "text", text: `Follow-me (local) stopped on ${robot.id}. cmd_vel zeroed.` }] };
575
1199
  }
576
1200
  catch (err) {
577
1201
  return { content: [{ type: "text", text: `Follow-me local stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
578
1202
  }
579
1203
  }
1204
+ if (mode === "depth") {
1205
+ try {
1206
+ const loop = getFollowMeDepth(robot, config, transport);
1207
+ await loop.stop();
1208
+ return { content: [{ type: "text", text: `Follow-me (depth) stopped on ${robot.id}. cmd_vel zeroed.` }] };
1209
+ }
1210
+ catch (err) {
1211
+ return { content: [{ type: "text", text: `Follow-me depth stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
1212
+ }
1213
+ }
580
1214
  try {
581
- const { topic } = await publishFollowMeCmd(config, { action: "stop" });
1215
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "stop" });
582
1216
  return { content: [{ type: "text", text: `Follow-me stop sent to ${topic}.` }] };
583
1217
  }
584
1218
  catch (err) {
@@ -595,11 +1229,15 @@ export async function handleToolCall(name, args, config) {
595
1229
  }
596
1230
  const mode = followMeMode(args);
597
1231
  if (mode === "local") {
598
- getFollowMeLocal(config, transport).setTargetDistance(distance);
599
- return { content: [{ type: "text", text: `Follow-me (local) target distance set to ${distance} m.` }] };
1232
+ getFollowMeLocal(robot, config, transport).setTargetDistance(distance);
1233
+ return { content: [{ type: "text", text: `Follow-me (local) target distance on ${robot.id} set to ${distance} m.` }] };
1234
+ }
1235
+ if (mode === "depth") {
1236
+ getFollowMeDepth(robot, config, transport).setTargetDistance(distance);
1237
+ return { content: [{ type: "text", text: `Follow-me (depth) target distance on ${robot.id} set to ${distance} m.` }] };
600
1238
  }
601
1239
  try {
602
- const { topic } = await publishFollowMeCmd(config, { action: "set_distance", distance });
1240
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "set_distance", distance });
603
1241
  return { content: [{ type: "text", text: `Follow-me set_distance=${distance} sent to ${topic}.` }] };
604
1242
  }
605
1243
  catch (err) {
@@ -613,11 +1251,15 @@ export async function handleToolCall(name, args, config) {
613
1251
  }
614
1252
  const mode = followMeMode(args);
615
1253
  if (mode === "local") {
616
- getFollowMeLocal(config, transport).setTargetDescription(description);
617
- return { content: [{ type: "text", text: `Follow-me (local) target description set: ${description}. (Note: local mode currently follows the largest person; description is recorded but not yet used for re-id.)` }] };
1254
+ getFollowMeLocal(robot, config, transport).setTargetDescription(description);
1255
+ return { content: [{ type: "text", text: `Follow-me (local) target description on ${robot.id} set: ${description}. (Note: local mode currently follows the largest person; description is recorded but not yet used for re-id.)` }] };
1256
+ }
1257
+ if (mode === "depth") {
1258
+ getFollowMeDepth(robot, config, transport).setTargetDescription(description);
1259
+ return { content: [{ type: "text", text: `Follow-me (depth) target description on ${robot.id} recorded: ${description}. (Depth mode has no semantic recognition; it always follows the closest blob.)` }] };
618
1260
  }
619
1261
  try {
620
- const { topic } = await publishFollowMeCmd(config, { action: "set_target", description });
1262
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "set_target", description });
621
1263
  return { content: [{ type: "text", text: `Follow-me set_target sent to ${topic} (description: ${description}).` }] };
622
1264
  }
623
1265
  catch (err) {
@@ -627,10 +1269,14 @@ export async function handleToolCall(name, args, config) {
627
1269
  case "ros2_follow_me_status": {
628
1270
  const mode = followMeMode(args);
629
1271
  if (mode === "local") {
630
- const status = getFollowMeLocal(config, transport).status();
631
- return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "local", status }) }] };
1272
+ const status = getFollowMeLocal(robot, config, transport).status();
1273
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "local", robot_id: robot.id, status }) }] };
1274
+ }
1275
+ if (mode === "depth") {
1276
+ const status = getFollowMeDepth(robot, config, transport).status();
1277
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "depth", robot_id: robot.id, status }) }] };
632
1278
  }
633
- const topic = toNamespacedTopicFull(config, "/follow_me/status");
1279
+ const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/status");
634
1280
  const timeout = args["timeout"] ?? 3000;
635
1281
  try {
636
1282
  const message = await new Promise((resolve, reject) => {
@@ -670,7 +1316,7 @@ export async function handleToolCall(name, args, config) {
670
1316
  if (!target) {
671
1317
  return { content: [{ type: "text", text: "Missing required argument: target" }], isError: true };
672
1318
  }
673
- const result = await findObject(config, transport, {
1319
+ const result = await findObject(robot, config, transport, {
674
1320
  target,
675
1321
  angularSpeed: args["angular_speed"],
676
1322
  clockwise: args["clockwise"],