agenticros 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +34 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skills.d.ts +3 -5
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +21 -7
- package/dist/commands/skills.js.map +1 -1
- package/dist/util/skill-manifest.d.ts.map +1 -1
- package/dist/util/skill-manifest.js +22 -1
- package/dist/util/skill-manifest.js.map +1 -1
- package/dist/util/skills.d.ts +6 -0
- package/dist/util/skills.d.ts.map +1 -1
- package/dist/util/skills.js +69 -1
- package/dist/util/skills.js.map +1 -1
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +31 -6
- package/runtime/docs/cli.md +1 -1
- package/runtime/docs/robot-setup.md +1 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +147 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +2 -0
- package/runtime/packages/agenticros/src/index.ts +8 -1
- package/runtime/packages/agenticros/src/routes.ts +4 -3
- package/runtime/packages/agenticros/src/tools/index.ts +6 -5
- package/runtime/packages/agenticros/src/tools/mission-pause.ts +49 -0
- package/runtime/packages/agenticros/src/tools/mission-resume.ts +41 -0
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +9 -12
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +33 -84
- package/runtime/packages/agenticros-claude-code/README.md +2 -0
- package/runtime/packages/agenticros-claude-code/dist/config.d.ts +5 -3
- package/runtime/packages/agenticros-claude-code/dist/config.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/config.js +32 -10
- package/runtime/packages/agenticros-claude-code/dist/config.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/index.js +3 -3
- package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.js +137 -118
- package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/src/config.ts +33 -10
- package/runtime/packages/agenticros-claude-code/src/index.ts +3 -3
- package/runtime/packages/agenticros-claude-code/src/tools.ts +151 -107
- package/runtime/packages/agenticros-gemini/package.json +2 -0
- package/runtime/packages/agenticros-gemini/src/config.ts +27 -18
- package/runtime/packages/agenticros-gemini/src/find-object/coco-classes.ts +38 -0
- package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +191 -0
- package/runtime/packages/agenticros-gemini/src/follow-me/controller.ts +109 -0
- package/runtime/packages/agenticros-gemini/src/follow-me/depth-loop.ts +452 -0
- package/runtime/packages/agenticros-gemini/src/follow-me/detector.ts +303 -0
- package/runtime/packages/agenticros-gemini/src/follow-me/loop.ts +359 -0
- package/runtime/packages/agenticros-gemini/src/index.ts +2 -2
- package/runtime/packages/agenticros-gemini/src/tools.ts +356 -92
- package/runtime/packages/core/README.md +1 -1
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/config-persistence.test.ts +26 -0
- package/runtime/packages/core/src/__tests__/external-capability.test.ts +72 -0
- package/runtime/packages/core/src/__tests__/heartbeat-fleet.test.ts +152 -0
- package/runtime/packages/core/src/__tests__/mission-bindings.test.ts +101 -0
- package/runtime/packages/core/src/__tests__/mission-pause.test.ts +104 -0
- package/runtime/packages/core/src/__tests__/skill-refs.test.ts +77 -0
- package/runtime/packages/core/src/capabilities.ts +38 -3
- package/runtime/packages/core/src/capability-schema.ts +58 -0
- package/runtime/packages/core/src/config.ts +28 -0
- package/runtime/packages/core/src/discoverable-capabilities.ts +178 -0
- package/runtime/packages/core/src/discovery.ts +21 -3
- package/runtime/packages/core/src/external-capability.ts +232 -0
- package/runtime/packages/core/src/fleet-config.ts +91 -0
- package/runtime/packages/core/src/heartbeat.ts +167 -0
- package/runtime/packages/core/src/index.ts +85 -0
- package/runtime/packages/core/src/mission-bindings.ts +189 -0
- package/runtime/packages/core/src/mission-registry.ts +31 -3
- package/runtime/packages/core/src/mission.ts +64 -7
- package/runtime/packages/core/src/robots.ts +6 -2
- package/runtime/packages/core/src/skill-refs.ts +330 -0
- package/runtime/packages/core/src/transport/rosbridge/client.ts +7 -0
- package/runtime/pnpm-lock.yaml +9 -0
- package/templates/skills/camera/package.json +1 -1
- package/templates/skills/depth/package.json +1 -1
- package/templates/skills/robot/package.json +1 -1
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import type {
|
|
6
6
|
AgenticROSConfig,
|
|
7
7
|
Capability,
|
|
8
|
-
CapabilityToolBindings,
|
|
9
8
|
Mission,
|
|
10
9
|
MissionToolDispatcher,
|
|
11
10
|
} from "@agenticros/core";
|
|
@@ -13,7 +12,9 @@ import {
|
|
|
13
12
|
resolveCameraSubscribeTopic,
|
|
14
13
|
resolveMemoryNamespace,
|
|
15
14
|
toNamespacedTopic,
|
|
15
|
+
toNamespacedTopicFull,
|
|
16
16
|
listAllCapabilities,
|
|
17
|
+
listCapabilitiesWithDiscoverable,
|
|
17
18
|
runMission,
|
|
18
19
|
listRobots,
|
|
19
20
|
getActiveRobotId,
|
|
@@ -26,6 +27,10 @@ import {
|
|
|
26
27
|
missionTranscriptNamespace,
|
|
27
28
|
MissionRegistry,
|
|
28
29
|
compileGoalToMission,
|
|
30
|
+
buildMissionBindings,
|
|
31
|
+
isExternalToolName,
|
|
32
|
+
capabilityIdFromExternalTool,
|
|
33
|
+
executeExternalCapability,
|
|
29
34
|
} from "@agenticros/core";
|
|
30
35
|
import {
|
|
31
36
|
ROS_MSG_COMPRESSED_IMAGE,
|
|
@@ -40,6 +45,9 @@ import { getTransportForRobot } from "./transport.js";
|
|
|
40
45
|
import { checkPublishSafety } from "./safety.js";
|
|
41
46
|
import { getDepthDistance } from "./depth.js";
|
|
42
47
|
import { ensureMemory } from "./memory.js";
|
|
48
|
+
import { getFollowMeLocal } from "./follow-me/loop.js";
|
|
49
|
+
import { getFollowMeDepth } from "./follow-me/depth-loop.js";
|
|
50
|
+
import { findObject } from "./find-object/find-object.js";
|
|
43
51
|
|
|
44
52
|
const MEMORY_TOOL_NAMES = new Set([
|
|
45
53
|
"memory_remember",
|
|
@@ -57,91 +65,6 @@ const MEMORY_TOOL_NAMES = new Set([
|
|
|
57
65
|
*/
|
|
58
66
|
const MISSION_REGISTRY = new MissionRegistry();
|
|
59
67
|
|
|
60
|
-
/**
|
|
61
|
-
* Capability → MCP tool dispatch table for `run_mission` (Phase 1.c).
|
|
62
|
-
* Mirrors the table in packages/agenticros-claude-code/src/tools.ts and
|
|
63
|
-
* packages/agenticros/src/tools/ros2-mission.ts — all three adapters
|
|
64
|
-
* agree on what each capability does.
|
|
65
|
-
*/
|
|
66
|
-
const MISSION_BINDINGS: CapabilityToolBindings = {
|
|
67
|
-
drive_base: {
|
|
68
|
-
tool: "ros2_publish",
|
|
69
|
-
buildArgs: (inputs) => {
|
|
70
|
-
const lx = Number(inputs.linear_x ?? 0) || 0;
|
|
71
|
-
const az = Number(inputs.angular_z ?? 0) || 0;
|
|
72
|
-
return {
|
|
73
|
-
topic: "/cmd_vel",
|
|
74
|
-
type: "geometry_msgs/msg/Twist",
|
|
75
|
-
message: {
|
|
76
|
-
linear: { x: lx, y: 0, z: 0 },
|
|
77
|
-
angular: { x: 0, y: 0, z: az },
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
take_snapshot: {
|
|
83
|
-
tool: "ros2_camera_snapshot",
|
|
84
|
-
buildArgs: (inputs) => {
|
|
85
|
-
const out: Record<string, unknown> = {};
|
|
86
|
-
if (typeof inputs.topic === "string") out.topic = inputs.topic;
|
|
87
|
-
if (typeof inputs.message_type === "string") out.message_type = inputs.message_type;
|
|
88
|
-
if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
|
|
89
|
-
return out;
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
measure_depth: {
|
|
93
|
-
tool: "ros2_depth_distance",
|
|
94
|
-
buildArgs: (inputs) => {
|
|
95
|
-
const out: Record<string, unknown> = {};
|
|
96
|
-
if (typeof inputs.topic === "string") out.topic = inputs.topic;
|
|
97
|
-
if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
|
|
98
|
-
return out;
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
list_topics: {
|
|
102
|
-
tool: "ros2_list_topics",
|
|
103
|
-
buildArgs: () => ({}),
|
|
104
|
-
},
|
|
105
|
-
publish_topic: {
|
|
106
|
-
tool: "ros2_publish",
|
|
107
|
-
buildArgs: (inputs) => ({
|
|
108
|
-
topic: String(inputs.topic ?? ""),
|
|
109
|
-
type: String(inputs.type ?? inputs.msg_type ?? ""),
|
|
110
|
-
message: inputs.message ?? inputs.msg ?? {},
|
|
111
|
-
}),
|
|
112
|
-
},
|
|
113
|
-
subscribe_once: {
|
|
114
|
-
tool: "ros2_subscribe_once",
|
|
115
|
-
buildArgs: (inputs) => {
|
|
116
|
-
const out: Record<string, unknown> = { topic: String(inputs.topic ?? "") };
|
|
117
|
-
if (typeof inputs.type === "string") out.type = inputs.type;
|
|
118
|
-
if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
|
|
119
|
-
return out;
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
follow_person: {
|
|
123
|
-
tool: "ros2_follow_me_start",
|
|
124
|
-
buildArgs: (inputs) => {
|
|
125
|
-
const out: Record<string, unknown> = {};
|
|
126
|
-
if (typeof inputs.target_distance === "number") out.target_distance = inputs.target_distance;
|
|
127
|
-
if (typeof inputs.mode === "string") out.mode = inputs.mode;
|
|
128
|
-
return out;
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
find_object: {
|
|
132
|
-
tool: "ros2_find_object",
|
|
133
|
-
buildArgs: (inputs) => {
|
|
134
|
-
const target = String(inputs.target ?? "");
|
|
135
|
-
const out: Record<string, unknown> = { target };
|
|
136
|
-
if (typeof inputs.angular_speed === "number") out.angular_speed = inputs.angular_speed;
|
|
137
|
-
if (typeof inputs.clockwise === "boolean") out.clockwise = inputs.clockwise;
|
|
138
|
-
if (typeof inputs.timeout_seconds === "number") out.timeout_seconds = inputs.timeout_seconds;
|
|
139
|
-
if (typeof inputs.min_confidence === "number") out.min_confidence = inputs.min_confidence;
|
|
140
|
-
return out;
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
|
|
145
68
|
const DEFAULT_DEPTH_TOPIC = "/camera/camera/depth/image_rect_raw";
|
|
146
69
|
const ENABLE_MULTIMODAL_FUNCTION_RESPONSE =
|
|
147
70
|
(process.env.GEMINI_ENABLE_MULTIMODAL_TOOL_RESPONSE ?? "").toLowerCase() === "true";
|
|
@@ -189,6 +112,28 @@ function resolveRobotForTool(
|
|
|
189
112
|
}
|
|
190
113
|
}
|
|
191
114
|
|
|
115
|
+
function followMeMode(args: Record<string, unknown>): "node" | "local" | "depth" {
|
|
116
|
+
const raw = String(args["mode"] ?? "depth").toLowerCase().trim();
|
|
117
|
+
if (raw === "local") return "local";
|
|
118
|
+
if (raw === "depth") return "depth";
|
|
119
|
+
return "node";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function publishFollowMeCmd(
|
|
123
|
+
config: AgenticROSConfig,
|
|
124
|
+
robot: ResolvedRobot,
|
|
125
|
+
payload: Record<string, unknown>,
|
|
126
|
+
): Promise<{ topic: string }> {
|
|
127
|
+
const transport = await getTransportForRobot(config, robot);
|
|
128
|
+
const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/cmd");
|
|
129
|
+
await transport.publish({
|
|
130
|
+
topic,
|
|
131
|
+
type: "std_msgs/msg/String",
|
|
132
|
+
msg: { data: JSON.stringify(payload) },
|
|
133
|
+
});
|
|
134
|
+
return { topic };
|
|
135
|
+
}
|
|
136
|
+
|
|
192
137
|
export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
|
|
193
138
|
{
|
|
194
139
|
name: "ros2_list_topics",
|
|
@@ -325,6 +270,70 @@ export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
|
|
|
325
270
|
...ROBOT_ID_PROP,
|
|
326
271
|
}),
|
|
327
272
|
},
|
|
273
|
+
{
|
|
274
|
+
name: "ros2_follow_me_start",
|
|
275
|
+
description:
|
|
276
|
+
"Start the follow-me skill — the robot follows a person. Modes: 'depth' (default), 'node', or 'local' (YOLO). Pass robot_id for multi-robot.",
|
|
277
|
+
parametersJsonSchema: schemaFromProps({
|
|
278
|
+
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
279
|
+
target_description: { type: "string", description: "Optional person description." },
|
|
280
|
+
...ROBOT_ID_PROP,
|
|
281
|
+
}),
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "ros2_follow_me_stop",
|
|
285
|
+
description: "Stop the follow-me skill.",
|
|
286
|
+
parametersJsonSchema: schemaFromProps({
|
|
287
|
+
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
288
|
+
...ROBOT_ID_PROP,
|
|
289
|
+
}),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "ros2_follow_me_status",
|
|
293
|
+
description: "Read follow-me status for the active or named robot.",
|
|
294
|
+
parametersJsonSchema: schemaFromProps({
|
|
295
|
+
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
296
|
+
timeout: { type: "number", description: "Timeout ms for mode=node (default 3000)." },
|
|
297
|
+
...ROBOT_ID_PROP,
|
|
298
|
+
}),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: "ros2_follow_me_set_distance",
|
|
302
|
+
description: "Set follow-me target distance in meters (0.2–5.0).",
|
|
303
|
+
parametersJsonSchema: schemaFromProps(
|
|
304
|
+
{
|
|
305
|
+
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
306
|
+
distance: { type: "number", description: "Target distance in meters." },
|
|
307
|
+
...ROBOT_ID_PROP,
|
|
308
|
+
},
|
|
309
|
+
["distance"],
|
|
310
|
+
),
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "ros2_follow_me_set_target",
|
|
314
|
+
description: "Update the follow-me target description while running.",
|
|
315
|
+
parametersJsonSchema: schemaFromProps({
|
|
316
|
+
mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
|
|
317
|
+
target_description: { type: "string", description: "Person description." },
|
|
318
|
+
...ROBOT_ID_PROP,
|
|
319
|
+
}),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "ros2_find_object",
|
|
323
|
+
description:
|
|
324
|
+
"Rotate in place until a COCO-class object is found in the camera, then stop. Returns horizontal_offset for approach missions.",
|
|
325
|
+
parametersJsonSchema: schemaFromProps(
|
|
326
|
+
{
|
|
327
|
+
target: { type: "string", description: "COCO class name (e.g. chair, bottle)." },
|
|
328
|
+
angular_speed: { type: "number", description: "Rotation speed rad/s." },
|
|
329
|
+
clockwise: { type: "boolean", description: "Rotate clockwise (default true)." },
|
|
330
|
+
timeout_seconds: { type: "number", description: "Search timeout seconds." },
|
|
331
|
+
min_confidence: { type: "number", description: "Min detection confidence." },
|
|
332
|
+
...ROBOT_ID_PROP,
|
|
333
|
+
},
|
|
334
|
+
["target"],
|
|
335
|
+
),
|
|
336
|
+
},
|
|
328
337
|
{
|
|
329
338
|
name: "memory_remember",
|
|
330
339
|
description:
|
|
@@ -413,6 +422,40 @@ export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
|
|
|
413
422
|
required: ["mission_id"],
|
|
414
423
|
},
|
|
415
424
|
},
|
|
425
|
+
{
|
|
426
|
+
name: "mission_pause",
|
|
427
|
+
description:
|
|
428
|
+
"Pause a mission that's currently running in this Gemini CLI process. Pass the mission_id returned by run_mission. The runner waits at the next step boundary until mission_resume (or mission_cancel). Idempotent if already paused.",
|
|
429
|
+
parametersJsonSchema: {
|
|
430
|
+
type: "object",
|
|
431
|
+
properties: {
|
|
432
|
+
mission_id: {
|
|
433
|
+
type: "string",
|
|
434
|
+
description: "The mission_id echoed by run_mission. Required.",
|
|
435
|
+
},
|
|
436
|
+
reason: {
|
|
437
|
+
type: "string",
|
|
438
|
+
description: "Optional free-text reason — surfaced in the paused transcript entry.",
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
required: ["mission_id"],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: "mission_resume",
|
|
446
|
+
description:
|
|
447
|
+
"Resume a mission previously paused with mission_pause. Pass the mission_id returned by run_mission. Idempotent if the mission is not paused.",
|
|
448
|
+
parametersJsonSchema: {
|
|
449
|
+
type: "object",
|
|
450
|
+
properties: {
|
|
451
|
+
mission_id: {
|
|
452
|
+
type: "string",
|
|
453
|
+
description: "The mission_id echoed by run_mission. Required.",
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
required: ["mission_id"],
|
|
457
|
+
},
|
|
458
|
+
},
|
|
416
459
|
];
|
|
417
460
|
|
|
418
461
|
/** Single tool object for Gemini (one item in config.tools array). */
|
|
@@ -458,15 +501,17 @@ export async function executeTool(
|
|
|
458
501
|
if (name === "ros2_list_capabilities") {
|
|
459
502
|
const resolved = resolveRobotForTool(config, args);
|
|
460
503
|
if ("error" in resolved) return resolved.error;
|
|
461
|
-
const caps
|
|
504
|
+
const caps = await listCapabilitiesWithDiscoverable(config);
|
|
462
505
|
const intrinsic = caps.filter((c) => c.source?.kind === "builtin").length;
|
|
463
|
-
const skill = caps.filter((c) => c.source?.kind === "skill").length;
|
|
506
|
+
const skill = caps.filter((c) => c.installed !== false && c.source?.kind === "skill").length;
|
|
507
|
+
const discoverable = caps.filter((c) => c.discoverable === true).length;
|
|
464
508
|
return {
|
|
465
509
|
output: JSON.stringify({
|
|
466
510
|
success: true,
|
|
467
511
|
total: caps.length,
|
|
468
512
|
intrinsic_count: intrinsic,
|
|
469
513
|
skill_count: skill,
|
|
514
|
+
discoverable_count: discoverable,
|
|
470
515
|
capabilities: caps,
|
|
471
516
|
}),
|
|
472
517
|
};
|
|
@@ -543,9 +588,8 @@ export async function executeTool(
|
|
|
543
588
|
};
|
|
544
589
|
}
|
|
545
590
|
}
|
|
546
|
-
// mission_cancel
|
|
547
|
-
// must work without a ROS transport
|
|
548
|
-
// unconditional transport resolution.
|
|
591
|
+
// mission_cancel / pause / resume mutate only the in-process
|
|
592
|
+
// MissionRegistry and must work without a ROS transport.
|
|
549
593
|
if (name === "mission_cancel") {
|
|
550
594
|
const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
|
|
551
595
|
if (!missionId) {
|
|
@@ -568,6 +612,48 @@ export async function executeTool(
|
|
|
568
612
|
}),
|
|
569
613
|
};
|
|
570
614
|
}
|
|
615
|
+
if (name === "mission_pause") {
|
|
616
|
+
const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
|
|
617
|
+
if (!missionId) {
|
|
618
|
+
return {
|
|
619
|
+
output: JSON.stringify({
|
|
620
|
+
success: false,
|
|
621
|
+
error: "mission_pause requires 'mission_id' (a non-empty string returned by run_mission).",
|
|
622
|
+
}),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const reason = typeof args["reason"] === "string" ? (args["reason"] as string) : undefined;
|
|
626
|
+
const outcome = MISSION_REGISTRY.pause(missionId, reason);
|
|
627
|
+
return {
|
|
628
|
+
output: JSON.stringify({
|
|
629
|
+
success: true,
|
|
630
|
+
mission_id: missionId,
|
|
631
|
+
found: outcome.found,
|
|
632
|
+
already_paused: outcome.alreadyPaused,
|
|
633
|
+
reason: reason ?? null,
|
|
634
|
+
}),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (name === "mission_resume") {
|
|
638
|
+
const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
|
|
639
|
+
if (!missionId) {
|
|
640
|
+
return {
|
|
641
|
+
output: JSON.stringify({
|
|
642
|
+
success: false,
|
|
643
|
+
error: "mission_resume requires 'mission_id' (a non-empty string returned by run_mission).",
|
|
644
|
+
}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const outcome = MISSION_REGISTRY.resume(missionId);
|
|
648
|
+
return {
|
|
649
|
+
output: JSON.stringify({
|
|
650
|
+
success: true,
|
|
651
|
+
mission_id: missionId,
|
|
652
|
+
found: outcome.found,
|
|
653
|
+
was_paused: outcome.wasPaused,
|
|
654
|
+
}),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
571
657
|
// run_mission dispatches to other tools recursively — let each sub-tool
|
|
572
658
|
// handle its own transport gating instead of requiring a connection up
|
|
573
659
|
// front. Validate the input shape here; defer execution to runMission().
|
|
@@ -621,6 +707,22 @@ export async function executeTool(
|
|
|
621
707
|
if ("error" in resolved) return resolved.error;
|
|
622
708
|
}
|
|
623
709
|
const dispatcher: MissionToolDispatcher = async (toolName, toolArgs) => {
|
|
710
|
+
if (isExternalToolName(toolName)) {
|
|
711
|
+
const capId = capabilityIdFromExternalTool(toolName);
|
|
712
|
+
const cap = caps.find((c) => c.id === capId);
|
|
713
|
+
if (!cap) {
|
|
714
|
+
return { text: `Unknown external capability "${capId}".`, isError: true };
|
|
715
|
+
}
|
|
716
|
+
const resolved = resolveRobotForTool(config, toolArgs);
|
|
717
|
+
if ("error" in resolved) {
|
|
718
|
+
return { text: resolved.error.output, isError: true };
|
|
719
|
+
}
|
|
720
|
+
const transport = await getTransportForRobot(config, resolved.robot);
|
|
721
|
+
const ext = await executeExternalCapability(cap, toolArgs, transport, {
|
|
722
|
+
namespace: resolved.robot.namespace,
|
|
723
|
+
});
|
|
724
|
+
return { text: ext.text, outputs: ext.outputs, isError: ext.isError };
|
|
725
|
+
}
|
|
624
726
|
const sub = await executeTool(toolName, toolArgs, config);
|
|
625
727
|
return { text: sub.output };
|
|
626
728
|
};
|
|
@@ -638,7 +740,7 @@ export async function executeTool(
|
|
|
638
740
|
|
|
639
741
|
let result;
|
|
640
742
|
try {
|
|
641
|
-
result = await runMission(mission, caps,
|
|
743
|
+
result = await runMission(mission, caps, buildMissionBindings(caps), dispatcher, {
|
|
642
744
|
mission_id: missionId,
|
|
643
745
|
cancellation: regEntry.cancellation,
|
|
644
746
|
transcript,
|
|
@@ -942,6 +1044,168 @@ export async function executeTool(
|
|
|
942
1044
|
}
|
|
943
1045
|
}
|
|
944
1046
|
|
|
1047
|
+
case "ros2_follow_me_start": {
|
|
1048
|
+
const mode = followMeMode(args);
|
|
1049
|
+
const desc = String(args["target_description"] ?? "").trim();
|
|
1050
|
+
if (mode === "local") {
|
|
1051
|
+
try {
|
|
1052
|
+
const loop = getFollowMeLocal(robot, config, transport);
|
|
1053
|
+
await loop.start({ targetDescription: desc || undefined });
|
|
1054
|
+
return {
|
|
1055
|
+
output: `Follow-me (local) started on ${robot.id}${desc ? ` (target: ${desc})` : " (closest person)"}.`,
|
|
1056
|
+
};
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
return { output: `Follow-me local start failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
if (mode === "depth") {
|
|
1062
|
+
try {
|
|
1063
|
+
const loop = getFollowMeDepth(robot, config, transport);
|
|
1064
|
+
await loop.start({ targetDescription: desc || undefined });
|
|
1065
|
+
return {
|
|
1066
|
+
output: `Follow-me (depth-only) started on ${robot.id}. Use ros2_follow_me_status with mode='depth' to check.`,
|
|
1067
|
+
};
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
return { output: `Follow-me depth start failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
const { topic } = await publishFollowMeCmd(config, robot, {
|
|
1074
|
+
action: "start",
|
|
1075
|
+
...(desc ? { target: desc } : {}),
|
|
1076
|
+
});
|
|
1077
|
+
return {
|
|
1078
|
+
output: `Follow-me start sent to ${topic}${desc ? ` (target: ${desc})` : ""}.`,
|
|
1079
|
+
};
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
return { output: `Follow-me start failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
case "ros2_follow_me_stop": {
|
|
1086
|
+
const mode = followMeMode(args);
|
|
1087
|
+
if (mode === "local") {
|
|
1088
|
+
getFollowMeLocal(robot, config, transport).stop();
|
|
1089
|
+
return { output: `Follow-me (local) stopped on ${robot.id}.` };
|
|
1090
|
+
}
|
|
1091
|
+
if (mode === "depth") {
|
|
1092
|
+
getFollowMeDepth(robot, config, transport).stop();
|
|
1093
|
+
return { output: `Follow-me (depth) stopped on ${robot.id}.` };
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
const { topic } = await publishFollowMeCmd(config, robot, { action: "stop" });
|
|
1097
|
+
return { output: `Follow-me stop sent to ${topic}.` };
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
return { output: `Follow-me stop failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
case "ros2_follow_me_status": {
|
|
1104
|
+
const mode = followMeMode(args);
|
|
1105
|
+
if (mode === "local") {
|
|
1106
|
+
const status = getFollowMeLocal(robot, config, transport).status();
|
|
1107
|
+
return { output: JSON.stringify(status) };
|
|
1108
|
+
}
|
|
1109
|
+
if (mode === "depth") {
|
|
1110
|
+
const status = getFollowMeDepth(robot, config, transport).status();
|
|
1111
|
+
return { output: JSON.stringify(status) };
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
const statusTopic = toNamespacedTopicFull(robot.namespace, "/follow_me/status");
|
|
1115
|
+
const timeout = (args["timeout"] as number | undefined) ?? 3000;
|
|
1116
|
+
const statusMsg = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
1117
|
+
const timer = setTimeout(() => {
|
|
1118
|
+
sub.unsubscribe();
|
|
1119
|
+
reject(new Error(`Timeout waiting for ${statusTopic}`));
|
|
1120
|
+
}, timeout);
|
|
1121
|
+
const sub = transport.subscribe(
|
|
1122
|
+
{ topic: statusTopic, type: "std_msgs/msg/String" },
|
|
1123
|
+
(m) => {
|
|
1124
|
+
clearTimeout(timer);
|
|
1125
|
+
sub.unsubscribe();
|
|
1126
|
+
resolve(m as Record<string, unknown>);
|
|
1127
|
+
},
|
|
1128
|
+
);
|
|
1129
|
+
});
|
|
1130
|
+
return { output: JSON.stringify(statusMsg) };
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
return { output: `Follow-me status failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
case "ros2_follow_me_set_distance": {
|
|
1137
|
+
const mode = followMeMode(args);
|
|
1138
|
+
const distance = Number(args["distance"]);
|
|
1139
|
+
if (!Number.isFinite(distance)) {
|
|
1140
|
+
return { output: "ros2_follow_me_set_distance requires numeric 'distance'." };
|
|
1141
|
+
}
|
|
1142
|
+
if (mode === "local") {
|
|
1143
|
+
getFollowMeLocal(robot, config, transport).setTargetDistance(distance);
|
|
1144
|
+
return { output: `Follow-me (local) target distance set to ${distance} m on ${robot.id}.` };
|
|
1145
|
+
}
|
|
1146
|
+
if (mode === "depth") {
|
|
1147
|
+
getFollowMeDepth(robot, config, transport).setTargetDistance(distance);
|
|
1148
|
+
return { output: `Follow-me (depth) target distance set to ${distance} m on ${robot.id}.` };
|
|
1149
|
+
}
|
|
1150
|
+
try {
|
|
1151
|
+
const { topic } = await publishFollowMeCmd(config, robot, {
|
|
1152
|
+
action: "set_distance",
|
|
1153
|
+
distance,
|
|
1154
|
+
});
|
|
1155
|
+
return { output: `Follow-me set_distance sent to ${topic}.` };
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
return { output: `Follow-me set_distance failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
case "ros2_follow_me_set_target": {
|
|
1162
|
+
const mode = followMeMode(args);
|
|
1163
|
+
const desc = String(args["target_description"] ?? "").trim();
|
|
1164
|
+
if (mode === "local") {
|
|
1165
|
+
getFollowMeLocal(robot, config, transport).setTargetDescription(desc);
|
|
1166
|
+
return { output: `Follow-me (local) target updated on ${robot.id}.` };
|
|
1167
|
+
}
|
|
1168
|
+
if (mode === "depth") {
|
|
1169
|
+
getFollowMeDepth(robot, config, transport).setTargetDescription(desc);
|
|
1170
|
+
return { output: "Depth mode recorded target description (still follows closest blob)." };
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
const { topic } = await publishFollowMeCmd(config, robot, {
|
|
1174
|
+
action: "set_target",
|
|
1175
|
+
target_description: desc,
|
|
1176
|
+
});
|
|
1177
|
+
return { output: `Follow-me set_target sent to ${topic}.` };
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
return { output: `Follow-me set_target failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
case "ros2_find_object": {
|
|
1184
|
+
const target = String(args["target"] ?? "").trim();
|
|
1185
|
+
if (!target) {
|
|
1186
|
+
return { output: "Missing required argument: target" };
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
const result = await findObject(robot, config, transport, {
|
|
1190
|
+
target,
|
|
1191
|
+
angularSpeed: args["angular_speed"] as number | undefined,
|
|
1192
|
+
clockwise: args["clockwise"] as boolean | undefined,
|
|
1193
|
+
timeoutSeconds: args["timeout_seconds"] as number | undefined,
|
|
1194
|
+
minConfidence: args["min_confidence"] as number | undefined,
|
|
1195
|
+
});
|
|
1196
|
+
const summary = result.error
|
|
1197
|
+
? result.error
|
|
1198
|
+
: result.found
|
|
1199
|
+
? `Found ${target} after ${result.elapsedSeconds.toFixed(1)}s. ` +
|
|
1200
|
+
`Confidence ${((result.detection!.confidence) * 100).toFixed(0)}%, ` +
|
|
1201
|
+
`horizontal offset ${result.detection!.horizontalOffset.toFixed(2)}. Robot stopped.`
|
|
1202
|
+
: `${target} not found within ${result.elapsedSeconds.toFixed(1)}s. Robot stopped.`;
|
|
1203
|
+
return { output: summary + "\n" + JSON.stringify(result) };
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
return { output: `ros2_find_object failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
945
1209
|
default:
|
|
946
1210
|
return { output: `Unknown tool: ${name}` };
|
|
947
1211
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseConfig, prepareConfigForPersistence } from "../config.js";
|
|
4
|
+
|
|
5
|
+
test("prepareConfigForPersistence: omits empty robots[] when raw config had no robots key", () => {
|
|
6
|
+
const raw = { robot: { namespace: "robot-alpha" } };
|
|
7
|
+
const parsed = parseConfig(raw);
|
|
8
|
+
const out = prepareConfigForPersistence(parsed, raw);
|
|
9
|
+
assert.equal("robots" in out, false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("prepareConfigForPersistence: keeps explicit robots[] even when empty", () => {
|
|
13
|
+
const raw = { robot: { namespace: "robot-alpha" }, robots: [] };
|
|
14
|
+
const parsed = parseConfig(raw);
|
|
15
|
+
const out = prepareConfigForPersistence(parsed, raw);
|
|
16
|
+
assert.deepEqual(out.robots, []);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("prepareConfigForPersistence: keeps non-empty robots[]", () => {
|
|
20
|
+
const raw = {
|
|
21
|
+
robots: [{ id: "alpha", namespace: "alpha-ns" }],
|
|
22
|
+
};
|
|
23
|
+
const parsed = parseConfig(raw);
|
|
24
|
+
const out = prepareConfigForPersistence(parsed, raw);
|
|
25
|
+
assert.equal(Array.isArray(out.robots) && (out.robots as unknown[]).length, 1);
|
|
26
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External capability executor tests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
buildExternalGoal,
|
|
9
|
+
executeExternalCapability,
|
|
10
|
+
} from "../external-capability.js";
|
|
11
|
+
import type { Capability } from "../capabilities.js";
|
|
12
|
+
import type { RosTransport } from "../transport/transport.js";
|
|
13
|
+
import { safeParseCapability } from "../capability-schema.js";
|
|
14
|
+
|
|
15
|
+
test("buildExternalGoal maps x/y/yaw to NavigateToPose", () => {
|
|
16
|
+
const goal = buildExternalGoal(
|
|
17
|
+
{
|
|
18
|
+
kind: "external_ros_node",
|
|
19
|
+
action: "navigate_to_pose",
|
|
20
|
+
msg_type: "nav2_msgs/action/NavigateToPose",
|
|
21
|
+
},
|
|
22
|
+
{ x: 1, y: 2, yaw: 0 },
|
|
23
|
+
);
|
|
24
|
+
const pose = (goal as { pose: { pose: { position: { x: number; y: number } } } }).pose;
|
|
25
|
+
assert.equal(pose.pose.position.x, 1);
|
|
26
|
+
assert.equal(pose.pose.position.y, 2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("executeExternalCapability sends action goal", async () => {
|
|
30
|
+
const calls: unknown[] = [];
|
|
31
|
+
const transport = {
|
|
32
|
+
sendActionGoal: async (opts: unknown) => {
|
|
33
|
+
calls.push(opts);
|
|
34
|
+
return { result: true, values: { status: "succeeded" } };
|
|
35
|
+
},
|
|
36
|
+
} as unknown as RosTransport;
|
|
37
|
+
|
|
38
|
+
const cap: Capability = {
|
|
39
|
+
id: "navigate_to",
|
|
40
|
+
verb: "navigate",
|
|
41
|
+
description: "Nav",
|
|
42
|
+
implementation: {
|
|
43
|
+
kind: "external_ros_node",
|
|
44
|
+
action: "navigate_to_pose",
|
|
45
|
+
msg_type: "nav2_msgs/action/NavigateToPose",
|
|
46
|
+
launch: "navigation_launch.py",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = await executeExternalCapability(cap, { x: 0.5, y: 0.1 }, transport, {
|
|
51
|
+
namespace: "robot1",
|
|
52
|
+
});
|
|
53
|
+
assert.equal(result.isError, false);
|
|
54
|
+
assert.equal(calls.length, 1);
|
|
55
|
+
const call = calls[0] as { action: string; actionType: string };
|
|
56
|
+
assert.ok(call.action.includes("navigate_to_pose"));
|
|
57
|
+
assert.equal(call.actionType, "nav2_msgs/action/NavigateToPose");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("safeParseCapability accepts external_ros_node", () => {
|
|
61
|
+
const parsed = safeParseCapability({
|
|
62
|
+
id: "navigate_to",
|
|
63
|
+
verb: "navigate",
|
|
64
|
+
description: "Nav2",
|
|
65
|
+
implementation: {
|
|
66
|
+
kind: "external_ros_node",
|
|
67
|
+
action: "navigate_to_pose",
|
|
68
|
+
msg_type: "nav2_msgs/action/NavigateToPose",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
assert.equal(parsed.ok, true);
|
|
72
|
+
});
|