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.
Files changed (79) hide show
  1. package/README.md +18 -3
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +34 -1
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/skills.d.ts +3 -5
  6. package/dist/commands/skills.d.ts.map +1 -1
  7. package/dist/commands/skills.js +21 -7
  8. package/dist/commands/skills.js.map +1 -1
  9. package/dist/util/skill-manifest.d.ts.map +1 -1
  10. package/dist/util/skill-manifest.js +22 -1
  11. package/dist/util/skill-manifest.js.map +1 -1
  12. package/dist/util/skills.d.ts +6 -0
  13. package/dist/util/skills.d.ts.map +1 -1
  14. package/dist/util/skills.js +69 -1
  15. package/dist/util/skills.js.map +1 -1
  16. package/package.json +2 -1
  17. package/runtime/BUNDLE.json +1 -1
  18. package/runtime/README.md +31 -6
  19. package/runtime/docs/cli.md +1 -1
  20. package/runtime/docs/robot-setup.md +1 -1
  21. package/runtime/packages/agenticros/openclaw.plugin.json +147 -1
  22. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +2 -0
  23. package/runtime/packages/agenticros/src/index.ts +8 -1
  24. package/runtime/packages/agenticros/src/routes.ts +4 -3
  25. package/runtime/packages/agenticros/src/tools/index.ts +6 -5
  26. package/runtime/packages/agenticros/src/tools/mission-pause.ts +49 -0
  27. package/runtime/packages/agenticros/src/tools/mission-resume.ts +41 -0
  28. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +9 -12
  29. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +33 -84
  30. package/runtime/packages/agenticros-claude-code/README.md +2 -0
  31. package/runtime/packages/agenticros-claude-code/dist/config.d.ts +5 -3
  32. package/runtime/packages/agenticros-claude-code/dist/config.d.ts.map +1 -1
  33. package/runtime/packages/agenticros-claude-code/dist/config.js +32 -10
  34. package/runtime/packages/agenticros-claude-code/dist/config.js.map +1 -1
  35. package/runtime/packages/agenticros-claude-code/dist/index.js +3 -3
  36. package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
  37. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  38. package/runtime/packages/agenticros-claude-code/dist/tools.js +137 -118
  39. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  40. package/runtime/packages/agenticros-claude-code/src/config.ts +33 -10
  41. package/runtime/packages/agenticros-claude-code/src/index.ts +3 -3
  42. package/runtime/packages/agenticros-claude-code/src/tools.ts +151 -107
  43. package/runtime/packages/agenticros-gemini/package.json +2 -0
  44. package/runtime/packages/agenticros-gemini/src/config.ts +27 -18
  45. package/runtime/packages/agenticros-gemini/src/find-object/coco-classes.ts +38 -0
  46. package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +191 -0
  47. package/runtime/packages/agenticros-gemini/src/follow-me/controller.ts +109 -0
  48. package/runtime/packages/agenticros-gemini/src/follow-me/depth-loop.ts +452 -0
  49. package/runtime/packages/agenticros-gemini/src/follow-me/detector.ts +303 -0
  50. package/runtime/packages/agenticros-gemini/src/follow-me/loop.ts +359 -0
  51. package/runtime/packages/agenticros-gemini/src/index.ts +2 -2
  52. package/runtime/packages/agenticros-gemini/src/tools.ts +356 -92
  53. package/runtime/packages/core/README.md +1 -1
  54. package/runtime/packages/core/package.json +1 -1
  55. package/runtime/packages/core/src/__tests__/config-persistence.test.ts +26 -0
  56. package/runtime/packages/core/src/__tests__/external-capability.test.ts +72 -0
  57. package/runtime/packages/core/src/__tests__/heartbeat-fleet.test.ts +152 -0
  58. package/runtime/packages/core/src/__tests__/mission-bindings.test.ts +101 -0
  59. package/runtime/packages/core/src/__tests__/mission-pause.test.ts +104 -0
  60. package/runtime/packages/core/src/__tests__/skill-refs.test.ts +77 -0
  61. package/runtime/packages/core/src/capabilities.ts +38 -3
  62. package/runtime/packages/core/src/capability-schema.ts +58 -0
  63. package/runtime/packages/core/src/config.ts +28 -0
  64. package/runtime/packages/core/src/discoverable-capabilities.ts +178 -0
  65. package/runtime/packages/core/src/discovery.ts +21 -3
  66. package/runtime/packages/core/src/external-capability.ts +232 -0
  67. package/runtime/packages/core/src/fleet-config.ts +91 -0
  68. package/runtime/packages/core/src/heartbeat.ts +167 -0
  69. package/runtime/packages/core/src/index.ts +85 -0
  70. package/runtime/packages/core/src/mission-bindings.ts +189 -0
  71. package/runtime/packages/core/src/mission-registry.ts +31 -3
  72. package/runtime/packages/core/src/mission.ts +64 -7
  73. package/runtime/packages/core/src/robots.ts +6 -2
  74. package/runtime/packages/core/src/skill-refs.ts +330 -0
  75. package/runtime/packages/core/src/transport/rosbridge/client.ts +7 -0
  76. package/runtime/pnpm-lock.yaml +9 -0
  77. package/templates/skills/camera/package.json +1 -1
  78. package/templates/skills/depth/package.json +1 -1
  79. 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: Capability[] = listAllCapabilities(config);
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 mutates only the in-process MissionRegistry and
547
- // must work without a ROS transport — keep it above the
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, MISSION_BINDINGS, dispatcher, {
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
  }
@@ -61,7 +61,7 @@ Your `package.json` declares an `agenticros` block:
61
61
  ]
62
62
  },
63
63
  "dependencies": {
64
- "@agenticros/core": "^0.5.0"
64
+ "@agenticros/core": "^0.6.0"
65
65
  }
66
66
  }
67
67
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticros/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "AgenticROS core — platform-agnostic ROS2 transport, types, and config (the dep every AgenticROS skill imports).",
6
6
  "keywords": [
@@ -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
+ });