agenticros 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/robot-config.test.d.ts +2 -0
- package/dist/__tests__/robot-config.test.d.ts.map +1 -0
- package/dist/__tests__/robot-config.test.js +374 -0
- package/dist/__tests__/robot-config.test.js.map +1 -0
- package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
- package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
- package/dist/__tests__/transport-shorthand.test.js +88 -0
- package/dist/__tests__/transport-shorthand.test.js.map +1 -0
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +55 -3
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/robots.d.ts +57 -0
- package/dist/commands/robots.d.ts.map +1 -0
- package/dist/commands/robots.js +678 -0
- package/dist/commands/robots.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mcp-discovery.d.ts +57 -0
- package/dist/util/mcp-discovery.d.ts.map +1 -0
- package/dist/util/mcp-discovery.js +153 -0
- package/dist/util/mcp-discovery.js.map +1 -0
- package/dist/util/robot-config.d.ts +167 -0
- package/dist/util/robot-config.d.ts.map +1 -0
- package/dist/util/robot-config.js +375 -0
- package/dist/util/robot-config.js.map +1 -0
- package/dist/util/transport-shorthand.d.ts +66 -0
- package/dist/util/transport-shorthand.d.ts.map +1 -0
- package/dist/util/transport-shorthand.js +102 -0
- package/dist/util/transport-shorthand.js.map +1 -0
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -0
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
- package/runtime/packages/agenticros/package.json +2 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
- package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
- package/runtime/packages/agenticros/src/service.ts +120 -2
- package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
- package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
- package/runtime/packages/agenticros/src/tools/index.ts +49 -9
- package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
- package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
- package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
- package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
- package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
- package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
- package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
- package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
- package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
- package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
- package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
- package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
- package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
- package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
- package/runtime/packages/agenticros-gemini/package.json +1 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
- package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
- package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
- package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
- package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
- package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
- package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
- package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
- package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
- package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
- package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
- package/runtime/packages/core/src/capabilities.ts +340 -0
- package/runtime/packages/core/src/config.ts +220 -38
- package/runtime/packages/core/src/discovery.ts +193 -0
- package/runtime/packages/core/src/find-robots-for.ts +164 -0
- package/runtime/packages/core/src/index.ts +67 -1
- package/runtime/packages/core/src/mission-registry.ts +142 -0
- package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
- package/runtime/packages/core/src/mission.ts +574 -0
- package/runtime/packages/core/src/planner/index.ts +564 -0
- package/runtime/packages/core/src/robots.ts +282 -0
- package/runtime/packages/core/src/topic-utils.ts +47 -9
- package/runtime/packages/core/src/transport-pool.ts +171 -0
- package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
- package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
- package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
- package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- package/runtime/scripts/start_demo.sh +26 -3
- package/runtime/scripts/sync-skill-tools.mjs +67 -10
|
@@ -2,8 +2,32 @@
|
|
|
2
2
|
* MCP tool definitions and handler. Mirrors OpenClaw adapter tools.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
|
|
5
|
+
import type {
|
|
6
|
+
AgenticROSConfig,
|
|
7
|
+
Capability,
|
|
8
|
+
Mission,
|
|
9
|
+
MissionToolDispatcher,
|
|
10
|
+
CapabilityToolBindings,
|
|
11
|
+
ResolvedRobot,
|
|
12
|
+
} from "@agenticros/core";
|
|
13
|
+
import {
|
|
14
|
+
resolveCameraSubscribeTopic,
|
|
15
|
+
toNamespacedTopic,
|
|
16
|
+
toNamespacedTopicFull,
|
|
17
|
+
listAllCapabilities,
|
|
18
|
+
runMission,
|
|
19
|
+
listRobots,
|
|
20
|
+
getActiveRobotId,
|
|
21
|
+
resolveRobotFromArgs,
|
|
22
|
+
discoverRobots,
|
|
23
|
+
findRobotsFor,
|
|
24
|
+
type FindRobotsForResult,
|
|
25
|
+
generateMissionId,
|
|
26
|
+
createMemoryTranscriptSink,
|
|
27
|
+
missionTranscriptNamespace,
|
|
28
|
+
compileGoalToMission,
|
|
29
|
+
} from "@agenticros/core";
|
|
30
|
+
import { getMissionRegistry } from "./mission-registry.js";
|
|
7
31
|
import {
|
|
8
32
|
ROS_MSG_COMPRESSED_IMAGE,
|
|
9
33
|
ROS_MSG_IMAGE,
|
|
@@ -12,7 +36,7 @@ import {
|
|
|
12
36
|
rosNumericField,
|
|
13
37
|
} from "@agenticros/ros-camera";
|
|
14
38
|
import { resolveMemoryNamespace } from "@agenticros/core";
|
|
15
|
-
import {
|
|
39
|
+
import { getTransportForRobot } from "./transport.js";
|
|
16
40
|
import { checkPublishSafety } from "./safety.js";
|
|
17
41
|
import { getDepthDistance } from "./depth.js";
|
|
18
42
|
import { getFollowMeLocal } from "./follow-me/loop.js";
|
|
@@ -33,6 +57,35 @@ export const MEMORY_TOOL_NAMES = new Set<string>([
|
|
|
33
57
|
"memory_status",
|
|
34
58
|
]);
|
|
35
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Tools that read purely from local config / filesystem and don't need the
|
|
62
|
+
* ROS transport. The MCP server entry point uses this to skip
|
|
63
|
+
* `ensureConnected()` so these tools work even when zenohd is down.
|
|
64
|
+
*
|
|
65
|
+
* Memory tools are included because they're cross-adapter local storage.
|
|
66
|
+
* `ros2_list_capabilities` is included because it reads skill manifests.
|
|
67
|
+
*/
|
|
68
|
+
export const NO_TRANSPORT_TOOL_NAMES = new Set<string>([
|
|
69
|
+
...MEMORY_TOOL_NAMES,
|
|
70
|
+
"ros2_list_capabilities",
|
|
71
|
+
"ros2_list_robots",
|
|
72
|
+
// ros2_find_robots_for can run config-only (no online filter); the
|
|
73
|
+
// tool falls back to a transport-driven discovery only when the caller
|
|
74
|
+
// passes online=true. See the handler for the precise branch.
|
|
75
|
+
"ros2_find_robots_for",
|
|
76
|
+
// mission_cancel only mutates the in-process MissionRegistry — it
|
|
77
|
+
// never publishes to ROS, so it must work even when zenohd is down.
|
|
78
|
+
"mission_cancel",
|
|
79
|
+
// run_mission's *outer* call doesn't need transport — its handler
|
|
80
|
+
// first compiles the goal (Phase 1.g, no transport) or validates
|
|
81
|
+
// the mission shape, then per-step calls go through the transport
|
|
82
|
+
// pool individually. Gating the outer call would block the planner
|
|
83
|
+
// from surfacing compile errors when the transport is down (e.g. a
|
|
84
|
+
// user asking "paint the wall" should get suggestions, not an
|
|
85
|
+
// ECONNREFUSED).
|
|
86
|
+
"run_mission",
|
|
87
|
+
]);
|
|
88
|
+
|
|
36
89
|
export interface McpTool {
|
|
37
90
|
name: string;
|
|
38
91
|
description: string;
|
|
@@ -50,16 +103,65 @@ export const TOOLS: McpTool[] = [
|
|
|
50
103
|
"List all available ROS2 topics and their message types. Use this to discover what data the robot publishes and what commands it accepts.",
|
|
51
104
|
inputSchema: { type: "object", properties: {} },
|
|
52
105
|
},
|
|
106
|
+
{
|
|
107
|
+
name: "ros2_list_capabilities",
|
|
108
|
+
description:
|
|
109
|
+
"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.",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
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." },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "ros2_list_robots",
|
|
119
|
+
description:
|
|
120
|
+
"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.",
|
|
121
|
+
inputSchema: { type: "object", properties: {} },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "ros2_discover_robots",
|
|
125
|
+
description:
|
|
126
|
+
"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.",
|
|
127
|
+
inputSchema: { type: "object", properties: {} },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "ros2_find_robots_for",
|
|
131
|
+
description:
|
|
132
|
+
"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.",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
capability: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description:
|
|
139
|
+
"Capability id to match (e.g. 'follow_person', 'find_object', 'drive_base'). Case-sensitive — use ros2_list_capabilities to get the exact list.",
|
|
140
|
+
},
|
|
141
|
+
kind: {
|
|
142
|
+
type: "string",
|
|
143
|
+
description:
|
|
144
|
+
"Robot kind filter (case-insensitive exact match). Common values: 'amr', 'arm', 'drone', 'rover'.",
|
|
145
|
+
},
|
|
146
|
+
online: {
|
|
147
|
+
type: "boolean",
|
|
148
|
+
description:
|
|
149
|
+
"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.",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
53
154
|
{
|
|
54
155
|
name: "ros2_publish",
|
|
55
156
|
description:
|
|
56
|
-
"Publish a message to a ROS2 topic. Use this to send commands to the robot (e.g., velocity commands to /cmd_vel, navigation goals).",
|
|
157
|
+
"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.",
|
|
57
158
|
inputSchema: {
|
|
58
159
|
type: "object",
|
|
59
160
|
properties: {
|
|
60
161
|
topic: { type: "string", description: "The ROS2 topic name (e.g., '/cmd_vel')" },
|
|
61
162
|
type: { type: "string", description: "The ROS2 message type (e.g., 'geometry_msgs/msg/Twist')" },
|
|
62
163
|
message: { type: "object", description: "The message payload matching the ROS2 message type schema" },
|
|
164
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
63
165
|
},
|
|
64
166
|
required: ["topic", "type", "message"],
|
|
65
167
|
},
|
|
@@ -67,13 +169,14 @@ export const TOOLS: McpTool[] = [
|
|
|
67
169
|
{
|
|
68
170
|
name: "ros2_subscribe_once",
|
|
69
171
|
description:
|
|
70
|
-
"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.",
|
|
172
|
+
"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.",
|
|
71
173
|
inputSchema: {
|
|
72
174
|
type: "object",
|
|
73
175
|
properties: {
|
|
74
176
|
topic: { type: "string", description: "The ROS2 topic name (e.g., '/battery_state')" },
|
|
75
177
|
type: { type: "string", description: "The ROS2 message type (optional)" },
|
|
76
178
|
timeout: { type: "number", description: "Timeout in milliseconds (default: 5000)" },
|
|
179
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
77
180
|
},
|
|
78
181
|
required: ["topic"],
|
|
79
182
|
},
|
|
@@ -81,13 +184,14 @@ export const TOOLS: McpTool[] = [
|
|
|
81
184
|
{
|
|
82
185
|
name: "ros2_service_call",
|
|
83
186
|
description:
|
|
84
|
-
"Call a ROS2 service and return the response. Use for request/response operations like setting parameters or querying node state.",
|
|
187
|
+
"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.",
|
|
85
188
|
inputSchema: {
|
|
86
189
|
type: "object",
|
|
87
190
|
properties: {
|
|
88
191
|
service: { type: "string", description: "The ROS2 service name (e.g., '/spawn_entity')" },
|
|
89
192
|
type: { type: "string", description: "The ROS2 service type (optional)" },
|
|
90
193
|
args: { type: "object", description: "The service request arguments" },
|
|
194
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
91
195
|
},
|
|
92
196
|
required: ["service"],
|
|
93
197
|
},
|
|
@@ -95,38 +199,41 @@ export const TOOLS: McpTool[] = [
|
|
|
95
199
|
{
|
|
96
200
|
name: "ros2_action_goal",
|
|
97
201
|
description:
|
|
98
|
-
"Send a goal to a ROS2 action server. Use for long-running operations like navigation or arm movements.",
|
|
202
|
+
"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.",
|
|
99
203
|
inputSchema: {
|
|
100
204
|
type: "object",
|
|
101
205
|
properties: {
|
|
102
206
|
action: { type: "string", description: "The ROS2 action server name (e.g., '/navigate_to_pose')" },
|
|
103
207
|
actionType: { type: "string", description: "The ROS2 action type (e.g., 'nav2_msgs/action/NavigateToPose')" },
|
|
104
208
|
goal: { type: "object", description: "The action goal parameters" },
|
|
209
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
105
210
|
},
|
|
106
211
|
required: ["action", "actionType", "goal"],
|
|
107
212
|
},
|
|
108
213
|
},
|
|
109
214
|
{
|
|
110
215
|
name: "ros2_param_get",
|
|
111
|
-
description: "Get the value of a ROS2 parameter from a node. Use to check robot configuration values.",
|
|
216
|
+
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.",
|
|
112
217
|
inputSchema: {
|
|
113
218
|
type: "object",
|
|
114
219
|
properties: {
|
|
115
220
|
node: { type: "string", description: "The fully qualified node name (e.g., '/turtlebot3/controller')" },
|
|
116
221
|
parameter: { type: "string", description: "The parameter name (e.g., 'max_velocity')" },
|
|
222
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
117
223
|
},
|
|
118
224
|
required: ["node", "parameter"],
|
|
119
225
|
},
|
|
120
226
|
},
|
|
121
227
|
{
|
|
122
228
|
name: "ros2_param_set",
|
|
123
|
-
description: "Set the value of a ROS2 parameter on a node. Use to change robot configuration at runtime.",
|
|
229
|
+
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.",
|
|
124
230
|
inputSchema: {
|
|
125
231
|
type: "object",
|
|
126
232
|
properties: {
|
|
127
233
|
node: { type: "string", description: "The fully qualified node name" },
|
|
128
234
|
parameter: { type: "string", description: "The parameter name" },
|
|
129
235
|
value: { type: "object", description: "The new parameter value" },
|
|
236
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
130
237
|
},
|
|
131
238
|
required: ["node", "parameter", "value"],
|
|
132
239
|
},
|
|
@@ -134,32 +241,34 @@ export const TOOLS: McpTool[] = [
|
|
|
134
241
|
{
|
|
135
242
|
name: "ros2_camera_snapshot",
|
|
136
243
|
description:
|
|
137
|
-
"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.",
|
|
244
|
+
"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.",
|
|
138
245
|
inputSchema: {
|
|
139
246
|
type: "object",
|
|
140
247
|
properties: {
|
|
141
|
-
topic: { type: "string", description: "Camera image topic (default from robot
|
|
248
|
+
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." },
|
|
142
249
|
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." },
|
|
143
250
|
timeout: { type: "number", description: "Timeout in milliseconds (default: 10000)" },
|
|
251
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
144
252
|
},
|
|
145
253
|
},
|
|
146
254
|
},
|
|
147
255
|
{
|
|
148
256
|
name: "ros2_depth_distance",
|
|
149
257
|
description:
|
|
150
|
-
"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.",
|
|
258
|
+
"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.",
|
|
151
259
|
inputSchema: {
|
|
152
260
|
type: "object",
|
|
153
261
|
properties: {
|
|
154
262
|
topic: { type: "string", description: `Depth image topic (default: ${DEFAULT_DEPTH_TOPIC})` },
|
|
155
263
|
timeout: { type: "number", description: "Timeout in ms (default 5000)" },
|
|
264
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
156
265
|
},
|
|
157
266
|
},
|
|
158
267
|
},
|
|
159
268
|
{
|
|
160
269
|
name: "ros2_follow_me_start",
|
|
161
270
|
description:
|
|
162
|
-
"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).",
|
|
271
|
+
"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.",
|
|
163
272
|
inputSchema: {
|
|
164
273
|
type: "object",
|
|
165
274
|
properties: {
|
|
@@ -171,39 +280,43 @@ export const TOOLS: McpTool[] = [
|
|
|
171
280
|
type: "string",
|
|
172
281
|
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.",
|
|
173
282
|
},
|
|
283
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
174
284
|
},
|
|
175
285
|
},
|
|
176
286
|
},
|
|
177
287
|
{
|
|
178
288
|
name: "ros2_follow_me_stop",
|
|
179
|
-
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.",
|
|
289
|
+
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.",
|
|
180
290
|
inputSchema: {
|
|
181
291
|
type: "object",
|
|
182
292
|
properties: {
|
|
183
293
|
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
294
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
184
295
|
},
|
|
185
296
|
},
|
|
186
297
|
},
|
|
187
298
|
{
|
|
188
299
|
name: "ros2_follow_me_status",
|
|
189
300
|
description:
|
|
190
|
-
"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.",
|
|
301
|
+
"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.",
|
|
191
302
|
inputSchema: {
|
|
192
303
|
type: "object",
|
|
193
304
|
properties: {
|
|
194
305
|
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
195
306
|
timeout: { type: "number", description: "Timeout in milliseconds (default: 3000). Only used for mode='node'." },
|
|
307
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
196
308
|
},
|
|
197
309
|
},
|
|
198
310
|
},
|
|
199
311
|
{
|
|
200
312
|
name: "ros2_follow_me_set_distance",
|
|
201
|
-
description: "Set the follow-me target distance in meters. Clamped server-side to [0.2, 5.0].",
|
|
313
|
+
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.",
|
|
202
314
|
inputSchema: {
|
|
203
315
|
type: "object",
|
|
204
316
|
properties: {
|
|
205
317
|
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
206
318
|
distance: { type: "number", description: "Target distance in meters (0.2 to 5.0)" },
|
|
319
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
207
320
|
},
|
|
208
321
|
required: ["distance"],
|
|
209
322
|
},
|
|
@@ -211,12 +324,13 @@ export const TOOLS: McpTool[] = [
|
|
|
211
324
|
{
|
|
212
325
|
name: "ros2_follow_me_set_target",
|
|
213
326
|
description:
|
|
214
|
-
"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.",
|
|
327
|
+
"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.",
|
|
215
328
|
inputSchema: {
|
|
216
329
|
type: "object",
|
|
217
330
|
properties: {
|
|
218
331
|
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
219
332
|
description: { type: "string", description: "Description of the person to follow" },
|
|
333
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
220
334
|
},
|
|
221
335
|
required: ["description"],
|
|
222
336
|
},
|
|
@@ -277,7 +391,7 @@ export const TOOLS: McpTool[] = [
|
|
|
277
391
|
{
|
|
278
392
|
name: "ros2_find_object",
|
|
279
393
|
description:
|
|
280
|
-
"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).",
|
|
394
|
+
"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.",
|
|
281
395
|
inputSchema: {
|
|
282
396
|
type: "object",
|
|
283
397
|
properties: {
|
|
@@ -302,10 +416,54 @@ export const TOOLS: McpTool[] = [
|
|
|
302
416
|
type: "number",
|
|
303
417
|
description: "Minimum detection confidence to accept (default 0.5).",
|
|
304
418
|
},
|
|
419
|
+
robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
|
|
305
420
|
},
|
|
306
421
|
required: ["target"],
|
|
307
422
|
},
|
|
308
423
|
},
|
|
424
|
+
{
|
|
425
|
+
name: "run_mission",
|
|
426
|
+
description:
|
|
427
|
+
"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.",
|
|
428
|
+
inputSchema: {
|
|
429
|
+
type: "object",
|
|
430
|
+
properties: {
|
|
431
|
+
goal: {
|
|
432
|
+
type: "string",
|
|
433
|
+
description:
|
|
434
|
+
"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).",
|
|
435
|
+
},
|
|
436
|
+
mission: {
|
|
437
|
+
type: "object",
|
|
438
|
+
description:
|
|
439
|
+
'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}}" } }] }',
|
|
440
|
+
},
|
|
441
|
+
robot_id: {
|
|
442
|
+
type: "string",
|
|
443
|
+
description: "Optional robot id (from ros2_list_robots) — used when 'goal' is provided to scope every compiled step to one robot.",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "mission_cancel",
|
|
450
|
+
description:
|
|
451
|
+
"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.",
|
|
452
|
+
inputSchema: {
|
|
453
|
+
type: "object",
|
|
454
|
+
properties: {
|
|
455
|
+
mission_id: {
|
|
456
|
+
type: "string",
|
|
457
|
+
description: "The mission_id echoed by run_mission. Required.",
|
|
458
|
+
},
|
|
459
|
+
reason: {
|
|
460
|
+
type: "string",
|
|
461
|
+
description: "Optional free-text reason — surfaced in the cancelled mission result.",
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
required: ["mission_id"],
|
|
465
|
+
},
|
|
466
|
+
},
|
|
309
467
|
];
|
|
310
468
|
|
|
311
469
|
export type ToolContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
|
|
@@ -319,15 +477,19 @@ function followMeMode(args: Record<string, unknown>): "node" | "local" | "depth"
|
|
|
319
477
|
|
|
320
478
|
async function publishFollowMeCmd(
|
|
321
479
|
config: AgenticROSConfig,
|
|
480
|
+
robot: ResolvedRobot,
|
|
322
481
|
payload: Record<string, unknown>,
|
|
323
482
|
): Promise<{ topic: string; payload: Record<string, unknown> }> {
|
|
324
|
-
|
|
483
|
+
// Route through the per-robot pool so a robot with a custom transport
|
|
484
|
+
// override (e.g. zenoh-on-router) gets its own connection while
|
|
485
|
+
// single-transport deployments still share `__global__`.
|
|
486
|
+
const transport = await getTransportForRobot(config, robot);
|
|
325
487
|
if (transport.getStatus() !== "connected") {
|
|
326
488
|
throw new Error(
|
|
327
489
|
"Transport not connected. Check zenohd (ws://localhost:10000) and config in ~/.agenticros/config.json.",
|
|
328
490
|
);
|
|
329
491
|
}
|
|
330
|
-
const topic = toNamespacedTopicFull(
|
|
492
|
+
const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/cmd");
|
|
331
493
|
const data = JSON.stringify(payload);
|
|
332
494
|
const PUBLISH_TIMEOUT_MS = 5_000;
|
|
333
495
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
@@ -343,6 +505,332 @@ async function publishFollowMeCmd(
|
|
|
343
505
|
return { topic, payload };
|
|
344
506
|
}
|
|
345
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Format the capability list for tool responses — shared by claude-code,
|
|
510
|
+
* OpenClaw, and Gemini so every adapter returns the same JSON shape.
|
|
511
|
+
*/
|
|
512
|
+
function formatCapabilitiesResponse(caps: Capability[]): string {
|
|
513
|
+
const byKind = {
|
|
514
|
+
intrinsic: caps.filter((c) => c.source?.kind === "builtin").length,
|
|
515
|
+
skill: caps.filter((c) => c.source?.kind === "skill").length,
|
|
516
|
+
};
|
|
517
|
+
return JSON.stringify({
|
|
518
|
+
success: true,
|
|
519
|
+
total: caps.length,
|
|
520
|
+
intrinsic_count: byKind.intrinsic,
|
|
521
|
+
skill_count: byKind.skill,
|
|
522
|
+
capabilities: caps,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Convert an unknown-robot_id error from resolveRobotFromArgs into a
|
|
528
|
+
* tool-level error response. The error message already lists known ids
|
|
529
|
+
* and recommends ros2_list_robots, so the agent has everything it
|
|
530
|
+
* needs to recover without us re-formatting.
|
|
531
|
+
*/
|
|
532
|
+
function robotResolveError(err: unknown): { content: ToolContent[]; isError: true } {
|
|
533
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
534
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Format the robot list for tool responses — shared by claude-code,
|
|
539
|
+
* OpenClaw, and Gemini so every adapter returns the same JSON shape.
|
|
540
|
+
*/
|
|
541
|
+
function formatRobotsResponse(robots: ResolvedRobot[], activeId: string): string {
|
|
542
|
+
return JSON.stringify({
|
|
543
|
+
success: true,
|
|
544
|
+
total: robots.length,
|
|
545
|
+
active_robot_id: activeId,
|
|
546
|
+
robots,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Render the find_robots_for result as a single LLM-friendly JSON
|
|
552
|
+
* payload. We flatten each `FindRobotsForMatch` so the agent doesn't
|
|
553
|
+
* have to dig through `.robot.*` to read id/name/namespace — those are
|
|
554
|
+
* the fields it'll pass to the next tool call.
|
|
555
|
+
*/
|
|
556
|
+
function formatFindRobotsForResponse(result: FindRobotsForResult): string {
|
|
557
|
+
return JSON.stringify({
|
|
558
|
+
success: true,
|
|
559
|
+
query: result.query,
|
|
560
|
+
total: result.total,
|
|
561
|
+
robots: result.robots.map((m) => ({
|
|
562
|
+
id: m.robot.id,
|
|
563
|
+
name: m.robot.name,
|
|
564
|
+
namespace: m.robot.namespace,
|
|
565
|
+
kind: m.robot.kind,
|
|
566
|
+
sensors: m.robot.sensors,
|
|
567
|
+
capabilities: m.robot.capabilities ?? null,
|
|
568
|
+
cameraTopic: m.robot.cameraTopic,
|
|
569
|
+
online: m.online,
|
|
570
|
+
matched_capability_explicitly: m.matched_capability_explicitly,
|
|
571
|
+
score: m.score,
|
|
572
|
+
})),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Capability → MCP tool dispatch table used by `run_mission`.
|
|
578
|
+
*
|
|
579
|
+
* Every capability returned by `ros2_list_capabilities` that the mission
|
|
580
|
+
* runner can actually execute must appear here. If you add a new
|
|
581
|
+
* intrinsic capability to BUILTIN_CAPABILITIES, mirror it here. For new
|
|
582
|
+
* skill-declared capabilities, this is where you map them to the
|
|
583
|
+
* corresponding MCP tool. Steps that name a capability without an entry
|
|
584
|
+
* fail with an actionable "no mission-runner tool binding" error.
|
|
585
|
+
*/
|
|
586
|
+
const MISSION_BINDINGS: CapabilityToolBindings = {
|
|
587
|
+
drive_base: {
|
|
588
|
+
tool: "ros2_publish",
|
|
589
|
+
buildArgs: (inputs) => {
|
|
590
|
+
const lx = Number(inputs.linear_x ?? 0) || 0;
|
|
591
|
+
const az = Number(inputs.angular_z ?? 0) || 0;
|
|
592
|
+
return {
|
|
593
|
+
topic: "/cmd_vel",
|
|
594
|
+
type: "geometry_msgs/msg/Twist",
|
|
595
|
+
message: {
|
|
596
|
+
linear: { x: lx, y: 0, z: 0 },
|
|
597
|
+
angular: { x: 0, y: 0, z: az },
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
take_snapshot: {
|
|
603
|
+
tool: "ros2_camera_snapshot",
|
|
604
|
+
buildArgs: (inputs) => {
|
|
605
|
+
const out: Record<string, unknown> = {};
|
|
606
|
+
if (typeof inputs.topic === "string") out.topic = inputs.topic;
|
|
607
|
+
if (typeof inputs.message_type === "string") out.message_type = inputs.message_type;
|
|
608
|
+
if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
|
|
609
|
+
return out;
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
measure_depth: {
|
|
613
|
+
tool: "ros2_depth_distance",
|
|
614
|
+
buildArgs: (inputs) => {
|
|
615
|
+
const out: Record<string, unknown> = {};
|
|
616
|
+
if (typeof inputs.topic === "string") out.topic = inputs.topic;
|
|
617
|
+
if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
|
|
618
|
+
return out;
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
list_topics: {
|
|
622
|
+
tool: "ros2_list_topics",
|
|
623
|
+
buildArgs: () => ({}),
|
|
624
|
+
},
|
|
625
|
+
publish_topic: {
|
|
626
|
+
tool: "ros2_publish",
|
|
627
|
+
buildArgs: (inputs) => ({
|
|
628
|
+
topic: String(inputs.topic ?? ""),
|
|
629
|
+
type: String(inputs.type ?? inputs.msg_type ?? ""),
|
|
630
|
+
message: inputs.message ?? inputs.msg ?? {},
|
|
631
|
+
}),
|
|
632
|
+
},
|
|
633
|
+
subscribe_once: {
|
|
634
|
+
tool: "ros2_subscribe_once",
|
|
635
|
+
buildArgs: (inputs) => {
|
|
636
|
+
const out: Record<string, unknown> = { topic: String(inputs.topic ?? "") };
|
|
637
|
+
if (typeof inputs.type === "string") out.type = inputs.type;
|
|
638
|
+
if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
|
|
639
|
+
return out;
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
follow_person: {
|
|
643
|
+
tool: "ros2_follow_me_start",
|
|
644
|
+
buildArgs: (inputs) => {
|
|
645
|
+
const out: Record<string, unknown> = {};
|
|
646
|
+
if (typeof inputs.target_distance === "number") out.target_distance = inputs.target_distance;
|
|
647
|
+
if (typeof inputs.mode === "string") out.mode = inputs.mode;
|
|
648
|
+
return out;
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
find_object: {
|
|
652
|
+
tool: "ros2_find_object",
|
|
653
|
+
buildArgs: (inputs) => {
|
|
654
|
+
const target = String(inputs.target ?? "");
|
|
655
|
+
const out: Record<string, unknown> = { target };
|
|
656
|
+
if (typeof inputs.angular_speed === "number") out.angular_speed = inputs.angular_speed;
|
|
657
|
+
if (typeof inputs.clockwise === "boolean") out.clockwise = inputs.clockwise;
|
|
658
|
+
if (typeof inputs.timeout_seconds === "number") out.timeout_seconds = inputs.timeout_seconds;
|
|
659
|
+
if (typeof inputs.min_confidence === "number") out.min_confidence = inputs.min_confidence;
|
|
660
|
+
return out;
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Phase 1.f — `mission_cancel` handler. Lives at module scope so the
|
|
667
|
+
* outer `handleToolCall` can invoke it WITHOUT first resolving a
|
|
668
|
+
* transport (the cancel never touches ROS — see the registry note in
|
|
669
|
+
* `mission-registry.ts`).
|
|
670
|
+
*/
|
|
671
|
+
function handleMissionCancel(
|
|
672
|
+
args: Record<string, unknown>,
|
|
673
|
+
): { content: ToolContent[]; isError?: boolean } {
|
|
674
|
+
const missionId = typeof args["mission_id"] === "string" ? args["mission_id"].trim() : "";
|
|
675
|
+
if (!missionId) {
|
|
676
|
+
return {
|
|
677
|
+
content: [
|
|
678
|
+
{
|
|
679
|
+
type: "text",
|
|
680
|
+
text: "mission_cancel requires 'mission_id' (a non-empty string returned by run_mission).",
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
isError: true,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
const reason = typeof args["reason"] === "string" ? args["reason"] : undefined;
|
|
687
|
+
const outcome = getMissionRegistry().cancel(missionId, reason);
|
|
688
|
+
return {
|
|
689
|
+
content: [
|
|
690
|
+
{
|
|
691
|
+
type: "text",
|
|
692
|
+
text: JSON.stringify({
|
|
693
|
+
success: true,
|
|
694
|
+
mission_id: missionId,
|
|
695
|
+
found: outcome.found,
|
|
696
|
+
already_cancelled: outcome.alreadyCancelled,
|
|
697
|
+
reason: reason ?? null,
|
|
698
|
+
}),
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Phase 1.c + 1.f + 1.g — `run_mission` handler. Lives at module scope
|
|
706
|
+
* so the outer `handleToolCall` can short-circuit BEFORE
|
|
707
|
+
* `getTransportForRobot()` (the per-step dispatcher resolves transport
|
|
708
|
+
* tool-by-tool; the outer call must not). Accepts either an explicit
|
|
709
|
+
* `mission.steps[]` plan OR a natural-language `goal` (compiled via
|
|
710
|
+
* the local rule-based planner).
|
|
711
|
+
*/
|
|
712
|
+
async function handleRunMission(
|
|
713
|
+
args: Record<string, unknown>,
|
|
714
|
+
config: AgenticROSConfig,
|
|
715
|
+
): Promise<{ content: ToolContent[]; isError?: boolean }> {
|
|
716
|
+
const caps = listAllCapabilities(config);
|
|
717
|
+
const missionArg = args["mission"];
|
|
718
|
+
const goalArg = args["goal"];
|
|
719
|
+
const topLevelRobotId = typeof args["robot_id"] === "string" ? (args["robot_id"] as string) : undefined;
|
|
720
|
+
|
|
721
|
+
// Phase 1.g — when no explicit mission is provided, compile from
|
|
722
|
+
// the natural-language goal. We surface the planner's candidates
|
|
723
|
+
// + suggestions in the response so the agent can self-correct on
|
|
724
|
+
// a failed compile without a second round-trip.
|
|
725
|
+
let mission: Mission;
|
|
726
|
+
let plannerInfo:
|
|
727
|
+
| { compiled_from_goal: string; candidates: unknown[]; unmatched_verbs?: string[] }
|
|
728
|
+
| undefined;
|
|
729
|
+
if (missionArg && typeof missionArg === "object" && !Array.isArray(missionArg)) {
|
|
730
|
+
mission = missionArg as Mission;
|
|
731
|
+
if (!Array.isArray(mission.steps)) {
|
|
732
|
+
return {
|
|
733
|
+
content: [{ type: "text", text: "mission.steps must be an array of step objects." }],
|
|
734
|
+
isError: true,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
} else if (typeof goalArg === "string" && goalArg.trim().length > 0) {
|
|
738
|
+
const planned = compileGoalToMission(goalArg, caps, { robot_id: topLevelRobotId });
|
|
739
|
+
if (!planned.mission) {
|
|
740
|
+
return {
|
|
741
|
+
content: [
|
|
742
|
+
{
|
|
743
|
+
type: "text",
|
|
744
|
+
text: JSON.stringify({
|
|
745
|
+
success: false,
|
|
746
|
+
error: planned.error,
|
|
747
|
+
goal: goalArg,
|
|
748
|
+
suggestions: planned.suggestions,
|
|
749
|
+
...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
|
|
750
|
+
}),
|
|
751
|
+
},
|
|
752
|
+
],
|
|
753
|
+
isError: true,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
mission = planned.mission;
|
|
757
|
+
plannerInfo = {
|
|
758
|
+
compiled_from_goal: goalArg,
|
|
759
|
+
candidates: planned.candidates,
|
|
760
|
+
...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
|
|
761
|
+
};
|
|
762
|
+
} else {
|
|
763
|
+
return {
|
|
764
|
+
content: [
|
|
765
|
+
{
|
|
766
|
+
type: "text",
|
|
767
|
+
text: 'run_mission requires either "mission" (object with steps[]) or "goal" (natural-language string). Pass at least one.',
|
|
768
|
+
},
|
|
769
|
+
],
|
|
770
|
+
isError: true,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
if (typeof mission.robot_id === "string" && mission.robot_id.trim().length > 0) {
|
|
774
|
+
try {
|
|
775
|
+
resolveRobotFromArgs(config, { robot_id: mission.robot_id });
|
|
776
|
+
} catch (err) {
|
|
777
|
+
return robotResolveError(err);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const dispatcher: MissionToolDispatcher = async (toolName, toolArgs) => {
|
|
781
|
+
const res = await handleToolCall(toolName, toolArgs, config);
|
|
782
|
+
const text = res.content
|
|
783
|
+
.map((c) => (c.type === "text" ? c.text : `[image: ${c.mimeType}]`))
|
|
784
|
+
.join("\n");
|
|
785
|
+
return { text, isError: res.isError };
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const missionId = generateMissionId();
|
|
789
|
+
const registry = getMissionRegistry();
|
|
790
|
+
const { entry: regEntry, dispose: disposeRegistry } = registry.register(missionId, {
|
|
791
|
+
name: mission.name,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const memory = await ensureMemory(config);
|
|
795
|
+
const transcript = memory ? createMemoryTranscriptSink(memory, missionId) : undefined;
|
|
796
|
+
|
|
797
|
+
let result;
|
|
798
|
+
try {
|
|
799
|
+
result = await runMission(mission, caps, MISSION_BINDINGS, dispatcher, {
|
|
800
|
+
mission_id: missionId,
|
|
801
|
+
cancellation: regEntry.cancellation,
|
|
802
|
+
transcript,
|
|
803
|
+
adapter: "claude-code",
|
|
804
|
+
});
|
|
805
|
+
} finally {
|
|
806
|
+
disposeRegistry();
|
|
807
|
+
}
|
|
808
|
+
const compact = {
|
|
809
|
+
status: result.status,
|
|
810
|
+
mission_id: result.mission_id,
|
|
811
|
+
...(result.cancellation_reason ? { cancellation_reason: result.cancellation_reason } : {}),
|
|
812
|
+
...(transcript ? { transcript_namespace: missionTranscriptNamespace(missionId) } : {}),
|
|
813
|
+
...(plannerInfo ? { planner: plannerInfo } : {}),
|
|
814
|
+
steps_run: result.steps_run,
|
|
815
|
+
steps_total: result.steps_total,
|
|
816
|
+
duration_ms: result.duration_ms,
|
|
817
|
+
summary: result.summary,
|
|
818
|
+
steps: result.steps.map((s) => ({
|
|
819
|
+
id: s.id,
|
|
820
|
+
capability: s.capability,
|
|
821
|
+
status: s.status,
|
|
822
|
+
inputs: s.inputs,
|
|
823
|
+
outputs: s.outputs,
|
|
824
|
+
...(s.error ? { error: s.error } : {}),
|
|
825
|
+
duration_ms: s.duration_ms,
|
|
826
|
+
})),
|
|
827
|
+
};
|
|
828
|
+
return {
|
|
829
|
+
content: [{ type: "text", text: `${result.summary}\n${JSON.stringify(compact)}` }],
|
|
830
|
+
isError: result.status === "error",
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
346
834
|
export async function handleToolCall(
|
|
347
835
|
name: string,
|
|
348
836
|
args: Record<string, unknown>,
|
|
@@ -353,7 +841,106 @@ export async function handleToolCall(
|
|
|
353
841
|
if (MEMORY_TOOL_NAMES.has(name)) {
|
|
354
842
|
return handleMemoryToolCall(name, args, config);
|
|
355
843
|
}
|
|
356
|
-
|
|
844
|
+
// ros2_list_capabilities reads skill manifests + intrinsic verbs from
|
|
845
|
+
// local config — no transport required. robot_id is accepted but
|
|
846
|
+
// doesn't change today's response (every robot exposes the same
|
|
847
|
+
// capabilities); we still validate it so an unknown id surfaces as a
|
|
848
|
+
// tool error instead of being silently ignored.
|
|
849
|
+
if (name === "ros2_list_capabilities") {
|
|
850
|
+
try {
|
|
851
|
+
resolveRobotFromArgs(config, args);
|
|
852
|
+
} catch (err) {
|
|
853
|
+
return robotResolveError(err);
|
|
854
|
+
}
|
|
855
|
+
const caps = listAllCapabilities(config);
|
|
856
|
+
return { content: [{ type: "text", text: formatCapabilitiesResponse(caps) }] };
|
|
857
|
+
}
|
|
858
|
+
// ros2_list_robots reads the multi-robot section of the config (with
|
|
859
|
+
// legacy fallback) — no transport required either.
|
|
860
|
+
if (name === "ros2_list_robots") {
|
|
861
|
+
const robots = listRobots(config);
|
|
862
|
+
const active = getActiveRobotId(config);
|
|
863
|
+
return {
|
|
864
|
+
content: [{ type: "text", text: formatRobotsResponse(robots, active) }],
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
// ros2_find_robots_for is config-driven by default and only touches
|
|
868
|
+
// the transport when the caller filters by online status. Putting it
|
|
869
|
+
// here (above the unconditional transport resolution) lets agents
|
|
870
|
+
// call it offline — useful for static-fleet planning and for the
|
|
871
|
+
// CLI's `agenticros robots` family which reuses this MCP tool.
|
|
872
|
+
if (name === "ros2_find_robots_for") {
|
|
873
|
+
const cap = typeof args["capability"] === "string" ? args["capability"] : undefined;
|
|
874
|
+
const kind = typeof args["kind"] === "string" ? args["kind"] : undefined;
|
|
875
|
+
const online = typeof args["online"] === "boolean" ? args["online"] : undefined;
|
|
876
|
+
let onlineIds: Set<string> | undefined;
|
|
877
|
+
if (online !== undefined) {
|
|
878
|
+
// Online filtering needs ONE live transport to list topics with.
|
|
879
|
+
// The active robot's transport works fine here — discoverRobots()
|
|
880
|
+
// operates on the global topic graph and doesn't care which
|
|
881
|
+
// namespace we listed from.
|
|
882
|
+
let activeRobot;
|
|
883
|
+
try {
|
|
884
|
+
activeRobot = resolveRobotFromArgs(config, {});
|
|
885
|
+
} catch (err) {
|
|
886
|
+
return robotResolveError(err);
|
|
887
|
+
}
|
|
888
|
+
const transport = await getTransportForRobot(config, activeRobot);
|
|
889
|
+
if (transport.getStatus() !== "connected") {
|
|
890
|
+
return {
|
|
891
|
+
content: [
|
|
892
|
+
{
|
|
893
|
+
type: "text",
|
|
894
|
+
text: JSON.stringify({
|
|
895
|
+
success: false,
|
|
896
|
+
error:
|
|
897
|
+
"online filter requires the ROS transport to be connected. Drop the 'online' arg to run config-only, or check that zenohd / rosbridge is up.",
|
|
898
|
+
}),
|
|
899
|
+
},
|
|
900
|
+
],
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const topics = await transport.listTopics();
|
|
904
|
+
const disc = discoverRobots(topics, config);
|
|
905
|
+
onlineIds = new Set(disc.configured_online.map((r) => r.id));
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
const result = findRobotsFor(config, { capability: cap, kind, online }, onlineIds);
|
|
909
|
+
return { content: [{ type: "text", text: formatFindRobotsForResponse(result) }] };
|
|
910
|
+
} catch (e) {
|
|
911
|
+
return {
|
|
912
|
+
content: [
|
|
913
|
+
{ type: "text", text: JSON.stringify({ success: false, error: e instanceof Error ? e.message : String(e) }) },
|
|
914
|
+
],
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// mission_cancel + run_mission must NOT trigger a transport connect
|
|
919
|
+
// at this outer layer: mission_cancel only mutates the in-process
|
|
920
|
+
// MissionRegistry, and run_mission's handler does its OWN per-step
|
|
921
|
+
// dispatch (each step's inner `handleToolCall` connects as needed).
|
|
922
|
+
// Routing the outer call through `getTransportForRobot()` would
|
|
923
|
+
// hang or fail when an agent only wanted to compile / cancel.
|
|
924
|
+
if (name === "mission_cancel") {
|
|
925
|
+
return handleMissionCancel(args);
|
|
926
|
+
}
|
|
927
|
+
if (name === "run_mission") {
|
|
928
|
+
return handleRunMission(args, config);
|
|
929
|
+
}
|
|
930
|
+
// Resolve target robot once for every transport-bound tool. Unknown
|
|
931
|
+
// robot_id surfaces as a tool error (not a thrown exception) so the
|
|
932
|
+
// LLM sees a clean self-correctable response.
|
|
933
|
+
let robot;
|
|
934
|
+
try {
|
|
935
|
+
robot = resolveRobotFromArgs(config, args);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
return robotResolveError(err);
|
|
938
|
+
}
|
|
939
|
+
// Per-robot transport pool: returns the shared `__global__` entry
|
|
940
|
+
// when this robot has no override (the common case), OR materialises
|
|
941
|
+
// a dedicated transport on first use when the robot declares its own
|
|
942
|
+
// (e.g. one robot via local DDS + another via a zenoh router).
|
|
943
|
+
const transport = await getTransportForRobot(config, robot);
|
|
357
944
|
|
|
358
945
|
switch (name) {
|
|
359
946
|
case "ros2_list_topics": {
|
|
@@ -398,6 +985,31 @@ export async function handleToolCall(
|
|
|
398
985
|
return { content: [{ type: "text", text }] };
|
|
399
986
|
}
|
|
400
987
|
|
|
988
|
+
case "ros2_discover_robots": {
|
|
989
|
+
// Scan the live topic graph, then classify against the configured
|
|
990
|
+
// robot list using the shared pure-function in @agenticros/core.
|
|
991
|
+
// Discovery is read-only — nothing is written to config here. The
|
|
992
|
+
// agent (or the CLI / config UI on top) decides whether to
|
|
993
|
+
// promote an unknown_detected entry into config.robots[].
|
|
994
|
+
const topics = await transport.listTopics();
|
|
995
|
+
const result = discoverRobots(topics, config);
|
|
996
|
+
return {
|
|
997
|
+
content: [
|
|
998
|
+
{
|
|
999
|
+
type: "text",
|
|
1000
|
+
text: JSON.stringify({
|
|
1001
|
+
success: true,
|
|
1002
|
+
total_topics: result.total_topics,
|
|
1003
|
+
detected: result.detected,
|
|
1004
|
+
configured_online: result.configured_online,
|
|
1005
|
+
configured_offline: result.configured_offline,
|
|
1006
|
+
unknown_detected: result.unknown_detected,
|
|
1007
|
+
}),
|
|
1008
|
+
},
|
|
1009
|
+
],
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
401
1013
|
case "ros2_publish": {
|
|
402
1014
|
const rawTopicIn = String(args["topic"] ?? "").trim();
|
|
403
1015
|
if (process.stderr?.write) {
|
|
@@ -422,7 +1034,7 @@ export async function handleToolCall(
|
|
|
422
1034
|
const topic =
|
|
423
1035
|
cmdVelMatch && !segment.toLowerCase().startsWith("robot")
|
|
424
1036
|
? `/robot${segment.replace(/-/g, "")}/cmd_vel`
|
|
425
|
-
: toNamespacedTopic(
|
|
1037
|
+
: toNamespacedTopic(robot.namespace, rawTopicIn);
|
|
426
1038
|
if (process.stderr?.write) {
|
|
427
1039
|
process.stderr.write(`[AgenticROS] ros2_publish: → topic=${topic}\n`);
|
|
428
1040
|
}
|
|
@@ -444,7 +1056,7 @@ export async function handleToolCall(
|
|
|
444
1056
|
|
|
445
1057
|
case "ros2_subscribe_once": {
|
|
446
1058
|
const rawTopic = args["topic"] as string;
|
|
447
|
-
const topic = toNamespacedTopic(
|
|
1059
|
+
const topic = toNamespacedTopic(robot.namespace, rawTopic);
|
|
448
1060
|
let msgType = args["type"] as string | undefined;
|
|
449
1061
|
const timeout = (args["timeout"] as number | undefined) ?? 5000;
|
|
450
1062
|
if (!msgType && /\/?(camera|image|color|depth)/i.test(rawTopic)) {
|
|
@@ -479,7 +1091,7 @@ export async function handleToolCall(
|
|
|
479
1091
|
|
|
480
1092
|
case "ros2_service_call": {
|
|
481
1093
|
const rawService = args["service"] as string;
|
|
482
|
-
const service = toNamespacedTopic(
|
|
1094
|
+
const service = toNamespacedTopic(robot.namespace, rawService);
|
|
483
1095
|
const type = args["type"] as string | undefined;
|
|
484
1096
|
const reqArgs = args["args"] as Record<string, unknown> | undefined;
|
|
485
1097
|
const response = await transport.callService({ service, type, args: reqArgs });
|
|
@@ -493,7 +1105,7 @@ export async function handleToolCall(
|
|
|
493
1105
|
|
|
494
1106
|
case "ros2_action_goal": {
|
|
495
1107
|
const rawAction = args["action"] as string;
|
|
496
|
-
const action = toNamespacedTopic(
|
|
1108
|
+
const action = toNamespacedTopic(robot.namespace, rawAction);
|
|
497
1109
|
const actionType = args["actionType"] as string;
|
|
498
1110
|
const goal = args["goal"] as Record<string, unknown>;
|
|
499
1111
|
const actionResult = await transport.sendActionGoal({ action, actionType, args: goal });
|
|
@@ -507,7 +1119,7 @@ export async function handleToolCall(
|
|
|
507
1119
|
|
|
508
1120
|
case "ros2_param_get": {
|
|
509
1121
|
const rawNode = args["node"] as string;
|
|
510
|
-
const node = toNamespacedTopic(
|
|
1122
|
+
const node = toNamespacedTopic(robot.namespace, rawNode);
|
|
511
1123
|
const parameter = args["parameter"] as string;
|
|
512
1124
|
const response = await transport.callService({
|
|
513
1125
|
service: `${node}/get_parameters`,
|
|
@@ -525,7 +1137,7 @@ export async function handleToolCall(
|
|
|
525
1137
|
|
|
526
1138
|
case "ros2_param_set": {
|
|
527
1139
|
const rawNode = args["node"] as string;
|
|
528
|
-
const node = toNamespacedTopic(
|
|
1140
|
+
const node = toNamespacedTopic(robot.namespace, rawNode);
|
|
529
1141
|
const parameter = args["parameter"] as string;
|
|
530
1142
|
const value = args["value"];
|
|
531
1143
|
const response = await transport.callService({
|
|
@@ -543,9 +1155,9 @@ export async function handleToolCall(
|
|
|
543
1155
|
|
|
544
1156
|
case "ros2_camera_snapshot": {
|
|
545
1157
|
const defaultTopic =
|
|
546
|
-
(
|
|
1158
|
+
(robot.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
|
|
547
1159
|
const rawTopic = (args["topic"] as string | undefined) ?? defaultTopic;
|
|
548
|
-
const topic = resolveCameraSubscribeTopic(
|
|
1160
|
+
const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
|
|
549
1161
|
const rawMsgType = args["message_type"] as string | undefined;
|
|
550
1162
|
const messageType: "CompressedImage" | "Image" = rawMsgType === "Image" ? "Image" : "CompressedImage";
|
|
551
1163
|
const timeout = (args["timeout"] as number | undefined) ?? 10000;
|
|
@@ -617,7 +1229,7 @@ export async function handleToolCall(
|
|
|
617
1229
|
|
|
618
1230
|
case "ros2_depth_distance": {
|
|
619
1231
|
const rawTopic = (args["topic"] as string | undefined)?.trim() || DEFAULT_DEPTH_TOPIC;
|
|
620
|
-
const topic = resolveCameraSubscribeTopic(
|
|
1232
|
+
const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
|
|
621
1233
|
const timeout = (args["timeout"] as number | undefined) ?? 5000;
|
|
622
1234
|
try {
|
|
623
1235
|
const result = await getDepthDistance(transport, topic, timeout);
|
|
@@ -645,9 +1257,9 @@ export async function handleToolCall(
|
|
|
645
1257
|
};
|
|
646
1258
|
}
|
|
647
1259
|
try {
|
|
648
|
-
const loop = getFollowMeLocal(config, transport);
|
|
1260
|
+
const loop = getFollowMeLocal(robot, config, transport);
|
|
649
1261
|
await loop.start({ targetDescription: desc || undefined });
|
|
650
|
-
const text = `Follow-me (local) started${desc ? ` (target: ${desc})` : " (closest person)"}. Use ros2_follow_me_status with mode='local' to check tracking state.`;
|
|
1262
|
+
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.`;
|
|
651
1263
|
return { content: [{ type: "text", text }] };
|
|
652
1264
|
} catch (err) {
|
|
653
1265
|
return { content: [{ type: "text", text: `Follow-me local start failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
@@ -661,16 +1273,16 @@ export async function handleToolCall(
|
|
|
661
1273
|
};
|
|
662
1274
|
}
|
|
663
1275
|
try {
|
|
664
|
-
const loop = getFollowMeDepth(config, transport);
|
|
1276
|
+
const loop = getFollowMeDepth(robot, config, transport);
|
|
665
1277
|
await loop.start({ targetDescription: desc || undefined });
|
|
666
|
-
const text = `Follow-me (depth-only) started — 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.`;
|
|
1278
|
+
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.`;
|
|
667
1279
|
return { content: [{ type: "text", text }] };
|
|
668
1280
|
} catch (err) {
|
|
669
1281
|
return { content: [{ type: "text", text: `Follow-me depth start failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
670
1282
|
}
|
|
671
1283
|
}
|
|
672
1284
|
try {
|
|
673
|
-
const { topic } = await publishFollowMeCmd(config, {
|
|
1285
|
+
const { topic } = await publishFollowMeCmd(config, robot, {
|
|
674
1286
|
action: "start",
|
|
675
1287
|
...(desc ? { target: desc } : {}),
|
|
676
1288
|
});
|
|
@@ -685,24 +1297,24 @@ export async function handleToolCall(
|
|
|
685
1297
|
const mode = followMeMode(args);
|
|
686
1298
|
if (mode === "local") {
|
|
687
1299
|
try {
|
|
688
|
-
const loop = getFollowMeLocal(config, transport);
|
|
1300
|
+
const loop = getFollowMeLocal(robot, config, transport);
|
|
689
1301
|
await loop.stop();
|
|
690
|
-
return { content: [{ type: "text", text:
|
|
1302
|
+
return { content: [{ type: "text", text: `Follow-me (local) stopped on ${robot.id}. cmd_vel zeroed.` }] };
|
|
691
1303
|
} catch (err) {
|
|
692
1304
|
return { content: [{ type: "text", text: `Follow-me local stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
693
1305
|
}
|
|
694
1306
|
}
|
|
695
1307
|
if (mode === "depth") {
|
|
696
1308
|
try {
|
|
697
|
-
const loop = getFollowMeDepth(config, transport);
|
|
1309
|
+
const loop = getFollowMeDepth(robot, config, transport);
|
|
698
1310
|
await loop.stop();
|
|
699
|
-
return { content: [{ type: "text", text:
|
|
1311
|
+
return { content: [{ type: "text", text: `Follow-me (depth) stopped on ${robot.id}. cmd_vel zeroed.` }] };
|
|
700
1312
|
} catch (err) {
|
|
701
1313
|
return { content: [{ type: "text", text: `Follow-me depth stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
702
1314
|
}
|
|
703
1315
|
}
|
|
704
1316
|
try {
|
|
705
|
-
const { topic } = await publishFollowMeCmd(config, { action: "stop" });
|
|
1317
|
+
const { topic } = await publishFollowMeCmd(config, robot, { action: "stop" });
|
|
706
1318
|
return { content: [{ type: "text", text: `Follow-me stop sent to ${topic}.` }] };
|
|
707
1319
|
} catch (err) {
|
|
708
1320
|
return { content: [{ type: "text", text: `Follow-me stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
@@ -719,15 +1331,15 @@ export async function handleToolCall(
|
|
|
719
1331
|
}
|
|
720
1332
|
const mode = followMeMode(args);
|
|
721
1333
|
if (mode === "local") {
|
|
722
|
-
getFollowMeLocal(config, transport).setTargetDistance(distance);
|
|
723
|
-
return { content: [{ type: "text", text: `Follow-me (local) target distance set to ${distance} m.` }] };
|
|
1334
|
+
getFollowMeLocal(robot, config, transport).setTargetDistance(distance);
|
|
1335
|
+
return { content: [{ type: "text", text: `Follow-me (local) target distance on ${robot.id} set to ${distance} m.` }] };
|
|
724
1336
|
}
|
|
725
1337
|
if (mode === "depth") {
|
|
726
|
-
getFollowMeDepth(config, transport).setTargetDistance(distance);
|
|
727
|
-
return { content: [{ type: "text", text: `Follow-me (depth) target distance set to ${distance} m.` }] };
|
|
1338
|
+
getFollowMeDepth(robot, config, transport).setTargetDistance(distance);
|
|
1339
|
+
return { content: [{ type: "text", text: `Follow-me (depth) target distance on ${robot.id} set to ${distance} m.` }] };
|
|
728
1340
|
}
|
|
729
1341
|
try {
|
|
730
|
-
const { topic } = await publishFollowMeCmd(config, { action: "set_distance", distance });
|
|
1342
|
+
const { topic } = await publishFollowMeCmd(config, robot, { action: "set_distance", distance });
|
|
731
1343
|
return { content: [{ type: "text", text: `Follow-me set_distance=${distance} sent to ${topic}.` }] };
|
|
732
1344
|
} catch (err) {
|
|
733
1345
|
return { content: [{ type: "text", text: `Follow-me set_distance failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
@@ -741,15 +1353,15 @@ export async function handleToolCall(
|
|
|
741
1353
|
}
|
|
742
1354
|
const mode = followMeMode(args);
|
|
743
1355
|
if (mode === "local") {
|
|
744
|
-
getFollowMeLocal(config, transport).setTargetDescription(description);
|
|
745
|
-
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.)` }] };
|
|
1356
|
+
getFollowMeLocal(robot, config, transport).setTargetDescription(description);
|
|
1357
|
+
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.)` }] };
|
|
746
1358
|
}
|
|
747
1359
|
if (mode === "depth") {
|
|
748
|
-
getFollowMeDepth(config, transport).setTargetDescription(description);
|
|
749
|
-
return { content: [{ type: "text", text: `Follow-me (depth) target description recorded: ${description}. (Depth mode has no semantic recognition; it always follows the closest blob.)` }] };
|
|
1360
|
+
getFollowMeDepth(robot, config, transport).setTargetDescription(description);
|
|
1361
|
+
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.)` }] };
|
|
750
1362
|
}
|
|
751
1363
|
try {
|
|
752
|
-
const { topic } = await publishFollowMeCmd(config, { action: "set_target", description });
|
|
1364
|
+
const { topic } = await publishFollowMeCmd(config, robot, { action: "set_target", description });
|
|
753
1365
|
return { content: [{ type: "text", text: `Follow-me set_target sent to ${topic} (description: ${description}).` }] };
|
|
754
1366
|
} catch (err) {
|
|
755
1367
|
return { content: [{ type: "text", text: `Follow-me set_target failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
@@ -759,14 +1371,14 @@ export async function handleToolCall(
|
|
|
759
1371
|
case "ros2_follow_me_status": {
|
|
760
1372
|
const mode = followMeMode(args);
|
|
761
1373
|
if (mode === "local") {
|
|
762
|
-
const status = getFollowMeLocal(config, transport).status();
|
|
763
|
-
return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "local", status }) }] };
|
|
1374
|
+
const status = getFollowMeLocal(robot, config, transport).status();
|
|
1375
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "local", robot_id: robot.id, status }) }] };
|
|
764
1376
|
}
|
|
765
1377
|
if (mode === "depth") {
|
|
766
|
-
const status = getFollowMeDepth(config, transport).status();
|
|
767
|
-
return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "depth", status }) }] };
|
|
1378
|
+
const status = getFollowMeDepth(robot, config, transport).status();
|
|
1379
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "depth", robot_id: robot.id, status }) }] };
|
|
768
1380
|
}
|
|
769
|
-
const topic = toNamespacedTopicFull(
|
|
1381
|
+
const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/status");
|
|
770
1382
|
const timeout = (args["timeout"] as number | undefined) ?? 3000;
|
|
771
1383
|
try {
|
|
772
1384
|
const message = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
@@ -808,7 +1420,7 @@ export async function handleToolCall(
|
|
|
808
1420
|
if (!target) {
|
|
809
1421
|
return { content: [{ type: "text", text: "Missing required argument: target" }], isError: true };
|
|
810
1422
|
}
|
|
811
|
-
const result = await findObject(config, transport, {
|
|
1423
|
+
const result = await findObject(robot, config, transport, {
|
|
812
1424
|
target,
|
|
813
1425
|
angularSpeed: args["angular_speed"] as number | undefined,
|
|
814
1426
|
clockwise: args["clockwise"] as boolean | undefined,
|