agenticros 0.1.19 → 0.2.1
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/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +114 -30
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +125 -2
- package/dist/commands/skills.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/menu.js +22 -4
- package/dist/menu.js.map +1 -1
- package/dist/util/env.d.ts +2 -0
- package/dist/util/env.d.ts.map +1 -1
- package/dist/util/env.js +2 -0
- package/dist/util/env.js.map +1 -1
- package/dist/util/marketplace.d.ts +57 -0
- package/dist/util/marketplace.d.ts.map +1 -0
- package/dist/util/marketplace.js +144 -0
- package/dist/util/marketplace.js.map +1 -0
- package/dist/util/skills.d.ts +6 -6
- package/dist/util/skills.d.ts.map +1 -1
- package/dist/util/skills.js +14 -9
- package/dist/util/skills.js.map +1 -1
- package/package.json +12 -11
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +59 -2
- package/runtime/packages/agenticros/openclaw.plugin.json +1 -1
- package/runtime/packages/agenticros/src/config-page.ts +1 -1
- package/runtime/packages/agenticros/src/skill-loader.ts +109 -38
- package/runtime/packages/agenticros-claude-code/README.md +52 -9
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts +2 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js +773 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts +2 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js +97 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts +2 -2
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js +5 -5
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts +109 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js +392 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts +8 -3
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js +30 -12
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/index.js +5 -5
- package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts +16 -0
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.js +19 -0
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts +9 -0
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.js +700 -54
- package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/transport.d.ts +28 -8
- package/runtime/packages/agenticros-claude-code/dist/transport.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/transport.js +37 -34
- package/runtime/packages/agenticros-claude-code/dist/transport.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +2 -1
- package/runtime/packages/core/LICENSE +192 -0
- package/runtime/packages/core/README.md +88 -0
- package/runtime/packages/core/package.json +36 -3
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +49 -44
- package/runtime/packages/core/src/capabilities.ts +19 -45
- package/runtime/packages/core/src/config.ts +1 -1
- package/runtime/scripts/configure_agenticros.sh +0 -0
- package/runtime/scripts/configure_for_sim.sh +0 -0
- package/runtime/scripts/fix-openclaw-control-ui-path.sh +0 -0
- package/runtime/scripts/install_cli.sh +0 -0
- package/runtime/scripts/install_rosbridge_from_source.sh +0 -0
- package/runtime/scripts/onboard_robot.sh +0 -0
- package/runtime/scripts/pack-runtime.mjs +9 -3
- package/runtime/scripts/refresh-skill-deps.mjs +0 -0
- package/runtime/scripts/run_demo_native.sh +0 -0
- package/runtime/scripts/run_nemoclaw_host_stack.sh +0 -0
- package/runtime/scripts/run_robot_rosbridge.sh +0 -0
- package/runtime/scripts/setup_gateway_plugin.sh +0 -0
- package/runtime/scripts/setup_robot.sh +0 -0
- package/runtime/scripts/setup_workspace.sh +0 -0
- package/runtime/scripts/sim/run_sim.sh +0 -0
- package/runtime/scripts/smoke_test_nemoclaw.sh +0 -0
- package/runtime/scripts/start_demo.sh +0 -0
- package/runtime/scripts/sync-skill-tools.mjs +2 -2
- package/runtime/scripts/use-openclaw-2026.2.26.sh +0 -0
- 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 {
|
|
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
|
|
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: '
|
|
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: "'
|
|
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'
|
|
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: "'
|
|
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: "'
|
|
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: "'
|
|
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: "'
|
|
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"] ?? "
|
|
274
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 = (
|
|
1055
|
+
const defaultTopic = (robot.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
|
|
449
1056
|
const rawTopic = args["topic"] ?? defaultTopic;
|
|
450
|
-
const topic = resolveCameraSubscribeTopic(
|
|
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(
|
|
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:
|
|
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(
|
|
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"],
|