agenticros 0.3.3 → 0.3.5

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 (94) hide show
  1. package/README.md +25 -7
  2. package/dist/__tests__/claude-config.test.d.ts +2 -0
  3. package/dist/__tests__/claude-config.test.d.ts.map +1 -0
  4. package/dist/__tests__/claude-config.test.js +77 -0
  5. package/dist/__tests__/claude-config.test.js.map +1 -0
  6. package/dist/__tests__/mcp-setup.test.d.ts +2 -0
  7. package/dist/__tests__/mcp-setup.test.d.ts.map +1 -0
  8. package/dist/__tests__/mcp-setup.test.js +23 -0
  9. package/dist/__tests__/mcp-setup.test.js.map +1 -0
  10. package/dist/commands/claude.d.ts +16 -0
  11. package/dist/commands/claude.d.ts.map +1 -0
  12. package/dist/commands/claude.js +30 -0
  13. package/dist/commands/claude.js.map +1 -0
  14. package/dist/commands/codex.d.ts +4 -4
  15. package/dist/commands/codex.d.ts.map +1 -1
  16. package/dist/commands/codex.js +21 -90
  17. package/dist/commands/codex.js.map +1 -1
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +22 -27
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/hermes.d.ts +3 -3
  22. package/dist/commands/hermes.d.ts.map +1 -1
  23. package/dist/commands/hermes.js +13 -76
  24. package/dist/commands/hermes.js.map +1 -1
  25. package/dist/commands/init.d.ts +2 -3
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +16 -38
  28. package/dist/commands/init.js.map +1 -1
  29. package/dist/commands/mcp.d.ts +20 -0
  30. package/dist/commands/mcp.d.ts.map +1 -0
  31. package/dist/commands/mcp.js +31 -0
  32. package/dist/commands/mcp.js.map +1 -0
  33. package/dist/index.js +57 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/util/claude-config.d.ts +47 -0
  36. package/dist/util/claude-config.d.ts.map +1 -0
  37. package/dist/util/claude-config.js +227 -0
  38. package/dist/util/claude-config.js.map +1 -0
  39. package/dist/util/mcp-setup.d.ts +55 -0
  40. package/dist/util/mcp-setup.d.ts.map +1 -0
  41. package/dist/util/mcp-setup.js +235 -0
  42. package/dist/util/mcp-setup.js.map +1 -0
  43. package/dist/util/skill-manifest.d.ts.map +1 -1
  44. package/dist/util/skill-manifest.js +22 -1
  45. package/dist/util/skill-manifest.js.map +1 -1
  46. package/package.json +1 -1
  47. package/runtime/BUNDLE.json +1 -1
  48. package/runtime/README.md +31 -6
  49. package/runtime/docs/cli.md +54 -14
  50. package/runtime/docs/robot-setup.md +1 -1
  51. package/runtime/packages/agenticros/openclaw.plugin.json +147 -1
  52. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +2 -0
  53. package/runtime/packages/agenticros/src/routes.ts +4 -3
  54. package/runtime/packages/agenticros/src/tools/index.ts +6 -5
  55. package/runtime/packages/agenticros/src/tools/mission-pause.ts +49 -0
  56. package/runtime/packages/agenticros/src/tools/mission-resume.ts +41 -0
  57. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +33 -84
  58. package/runtime/packages/agenticros-claude-code/README.md +2 -0
  59. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  60. package/runtime/packages/agenticros-claude-code/dist/tools.js +130 -111
  61. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  62. package/runtime/packages/agenticros-claude-code/src/tools.ts +140 -99
  63. package/runtime/packages/agenticros-gemini/package.json +2 -0
  64. package/runtime/packages/agenticros-gemini/src/find-object/coco-classes.ts +38 -0
  65. package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +191 -0
  66. package/runtime/packages/agenticros-gemini/src/follow-me/controller.ts +109 -0
  67. package/runtime/packages/agenticros-gemini/src/follow-me/depth-loop.ts +452 -0
  68. package/runtime/packages/agenticros-gemini/src/follow-me/detector.ts +303 -0
  69. package/runtime/packages/agenticros-gemini/src/follow-me/loop.ts +359 -0
  70. package/runtime/packages/agenticros-gemini/src/tools.ts +351 -90
  71. package/runtime/packages/core/README.md +1 -1
  72. package/runtime/packages/core/package.json +1 -1
  73. package/runtime/packages/core/src/__tests__/config-persistence.test.ts +26 -0
  74. package/runtime/packages/core/src/__tests__/external-capability.test.ts +72 -0
  75. package/runtime/packages/core/src/__tests__/heartbeat-fleet.test.ts +152 -0
  76. package/runtime/packages/core/src/__tests__/mission-bindings.test.ts +101 -0
  77. package/runtime/packages/core/src/__tests__/mission-pause.test.ts +104 -0
  78. package/runtime/packages/core/src/capabilities.ts +38 -3
  79. package/runtime/packages/core/src/capability-schema.ts +58 -0
  80. package/runtime/packages/core/src/config.ts +20 -0
  81. package/runtime/packages/core/src/discovery.ts +21 -3
  82. package/runtime/packages/core/src/external-capability.ts +219 -0
  83. package/runtime/packages/core/src/fleet-config.ts +91 -0
  84. package/runtime/packages/core/src/heartbeat.ts +167 -0
  85. package/runtime/packages/core/src/index.ts +55 -0
  86. package/runtime/packages/core/src/mission-bindings.ts +189 -0
  87. package/runtime/packages/core/src/mission-registry.ts +31 -3
  88. package/runtime/packages/core/src/mission.ts +64 -7
  89. package/runtime/packages/core/src/robots.ts +6 -2
  90. package/runtime/packages/core/src/transport/rosbridge/client.ts +7 -0
  91. package/runtime/pnpm-lock.yaml +6 -0
  92. package/templates/skills/camera/package.json +1 -1
  93. package/templates/skills/depth/package.json +1 -1
  94. 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,6 +12,7 @@ import {
13
12
  resolveCameraSubscribeTopic,
14
13
  resolveMemoryNamespace,
15
14
  toNamespacedTopic,
15
+ toNamespacedTopicFull,
16
16
  listAllCapabilities,
17
17
  runMission,
18
18
  listRobots,
@@ -26,6 +26,10 @@ import {
26
26
  missionTranscriptNamespace,
27
27
  MissionRegistry,
28
28
  compileGoalToMission,
29
+ buildMissionBindings,
30
+ isExternalToolName,
31
+ capabilityIdFromExternalTool,
32
+ executeExternalCapability,
29
33
  } from "@agenticros/core";
30
34
  import {
31
35
  ROS_MSG_COMPRESSED_IMAGE,
@@ -40,6 +44,9 @@ import { getTransportForRobot } from "./transport.js";
40
44
  import { checkPublishSafety } from "./safety.js";
41
45
  import { getDepthDistance } from "./depth.js";
42
46
  import { ensureMemory } from "./memory.js";
47
+ import { getFollowMeLocal } from "./follow-me/loop.js";
48
+ import { getFollowMeDepth } from "./follow-me/depth-loop.js";
49
+ import { findObject } from "./find-object/find-object.js";
43
50
 
44
51
  const MEMORY_TOOL_NAMES = new Set([
45
52
  "memory_remember",
@@ -57,91 +64,6 @@ const MEMORY_TOOL_NAMES = new Set([
57
64
  */
58
65
  const MISSION_REGISTRY = new MissionRegistry();
59
66
 
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
67
  const DEFAULT_DEPTH_TOPIC = "/camera/camera/depth/image_rect_raw";
146
68
  const ENABLE_MULTIMODAL_FUNCTION_RESPONSE =
147
69
  (process.env.GEMINI_ENABLE_MULTIMODAL_TOOL_RESPONSE ?? "").toLowerCase() === "true";
@@ -189,6 +111,28 @@ function resolveRobotForTool(
189
111
  }
190
112
  }
191
113
 
114
+ function followMeMode(args: Record<string, unknown>): "node" | "local" | "depth" {
115
+ const raw = String(args["mode"] ?? "depth").toLowerCase().trim();
116
+ if (raw === "local") return "local";
117
+ if (raw === "depth") return "depth";
118
+ return "node";
119
+ }
120
+
121
+ async function publishFollowMeCmd(
122
+ config: AgenticROSConfig,
123
+ robot: ResolvedRobot,
124
+ payload: Record<string, unknown>,
125
+ ): Promise<{ topic: string }> {
126
+ const transport = await getTransportForRobot(config, robot);
127
+ const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/cmd");
128
+ await transport.publish({
129
+ topic,
130
+ type: "std_msgs/msg/String",
131
+ msg: { data: JSON.stringify(payload) },
132
+ });
133
+ return { topic };
134
+ }
135
+
192
136
  export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
193
137
  {
194
138
  name: "ros2_list_topics",
@@ -325,6 +269,70 @@ export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
325
269
  ...ROBOT_ID_PROP,
326
270
  }),
327
271
  },
272
+ {
273
+ name: "ros2_follow_me_start",
274
+ description:
275
+ "Start the follow-me skill — the robot follows a person. Modes: 'depth' (default), 'node', or 'local' (YOLO). Pass robot_id for multi-robot.",
276
+ parametersJsonSchema: schemaFromProps({
277
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
278
+ target_description: { type: "string", description: "Optional person description." },
279
+ ...ROBOT_ID_PROP,
280
+ }),
281
+ },
282
+ {
283
+ name: "ros2_follow_me_stop",
284
+ description: "Stop the follow-me skill.",
285
+ parametersJsonSchema: schemaFromProps({
286
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
287
+ ...ROBOT_ID_PROP,
288
+ }),
289
+ },
290
+ {
291
+ name: "ros2_follow_me_status",
292
+ description: "Read follow-me status for the active or named robot.",
293
+ parametersJsonSchema: schemaFromProps({
294
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
295
+ timeout: { type: "number", description: "Timeout ms for mode=node (default 3000)." },
296
+ ...ROBOT_ID_PROP,
297
+ }),
298
+ },
299
+ {
300
+ name: "ros2_follow_me_set_distance",
301
+ description: "Set follow-me target distance in meters (0.2–5.0).",
302
+ parametersJsonSchema: schemaFromProps(
303
+ {
304
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
305
+ distance: { type: "number", description: "Target distance in meters." },
306
+ ...ROBOT_ID_PROP,
307
+ },
308
+ ["distance"],
309
+ ),
310
+ },
311
+ {
312
+ name: "ros2_follow_me_set_target",
313
+ description: "Update the follow-me target description while running.",
314
+ parametersJsonSchema: schemaFromProps({
315
+ mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
316
+ target_description: { type: "string", description: "Person description." },
317
+ ...ROBOT_ID_PROP,
318
+ }),
319
+ },
320
+ {
321
+ name: "ros2_find_object",
322
+ description:
323
+ "Rotate in place until a COCO-class object is found in the camera, then stop. Returns horizontal_offset for approach missions.",
324
+ parametersJsonSchema: schemaFromProps(
325
+ {
326
+ target: { type: "string", description: "COCO class name (e.g. chair, bottle)." },
327
+ angular_speed: { type: "number", description: "Rotation speed rad/s." },
328
+ clockwise: { type: "boolean", description: "Rotate clockwise (default true)." },
329
+ timeout_seconds: { type: "number", description: "Search timeout seconds." },
330
+ min_confidence: { type: "number", description: "Min detection confidence." },
331
+ ...ROBOT_ID_PROP,
332
+ },
333
+ ["target"],
334
+ ),
335
+ },
328
336
  {
329
337
  name: "memory_remember",
330
338
  description:
@@ -413,6 +421,40 @@ export const GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [
413
421
  required: ["mission_id"],
414
422
  },
415
423
  },
424
+ {
425
+ name: "mission_pause",
426
+ description:
427
+ "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.",
428
+ parametersJsonSchema: {
429
+ type: "object",
430
+ properties: {
431
+ mission_id: {
432
+ type: "string",
433
+ description: "The mission_id echoed by run_mission. Required.",
434
+ },
435
+ reason: {
436
+ type: "string",
437
+ description: "Optional free-text reason — surfaced in the paused transcript entry.",
438
+ },
439
+ },
440
+ required: ["mission_id"],
441
+ },
442
+ },
443
+ {
444
+ name: "mission_resume",
445
+ description:
446
+ "Resume a mission previously paused with mission_pause. Pass the mission_id returned by run_mission. Idempotent if the mission is not paused.",
447
+ parametersJsonSchema: {
448
+ type: "object",
449
+ properties: {
450
+ mission_id: {
451
+ type: "string",
452
+ description: "The mission_id echoed by run_mission. Required.",
453
+ },
454
+ },
455
+ required: ["mission_id"],
456
+ },
457
+ },
416
458
  ];
417
459
 
418
460
  /** Single tool object for Gemini (one item in config.tools array). */
@@ -543,9 +585,8 @@ export async function executeTool(
543
585
  };
544
586
  }
545
587
  }
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.
588
+ // mission_cancel / pause / resume mutate only the in-process
589
+ // MissionRegistry and must work without a ROS transport.
549
590
  if (name === "mission_cancel") {
550
591
  const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
551
592
  if (!missionId) {
@@ -568,6 +609,48 @@ export async function executeTool(
568
609
  }),
569
610
  };
570
611
  }
612
+ if (name === "mission_pause") {
613
+ const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
614
+ if (!missionId) {
615
+ return {
616
+ output: JSON.stringify({
617
+ success: false,
618
+ error: "mission_pause requires 'mission_id' (a non-empty string returned by run_mission).",
619
+ }),
620
+ };
621
+ }
622
+ const reason = typeof args["reason"] === "string" ? (args["reason"] as string) : undefined;
623
+ const outcome = MISSION_REGISTRY.pause(missionId, reason);
624
+ return {
625
+ output: JSON.stringify({
626
+ success: true,
627
+ mission_id: missionId,
628
+ found: outcome.found,
629
+ already_paused: outcome.alreadyPaused,
630
+ reason: reason ?? null,
631
+ }),
632
+ };
633
+ }
634
+ if (name === "mission_resume") {
635
+ const missionId = typeof args["mission_id"] === "string" ? (args["mission_id"] as string).trim() : "";
636
+ if (!missionId) {
637
+ return {
638
+ output: JSON.stringify({
639
+ success: false,
640
+ error: "mission_resume requires 'mission_id' (a non-empty string returned by run_mission).",
641
+ }),
642
+ };
643
+ }
644
+ const outcome = MISSION_REGISTRY.resume(missionId);
645
+ return {
646
+ output: JSON.stringify({
647
+ success: true,
648
+ mission_id: missionId,
649
+ found: outcome.found,
650
+ was_paused: outcome.wasPaused,
651
+ }),
652
+ };
653
+ }
571
654
  // run_mission dispatches to other tools recursively — let each sub-tool
572
655
  // handle its own transport gating instead of requiring a connection up
573
656
  // front. Validate the input shape here; defer execution to runMission().
@@ -621,6 +704,22 @@ export async function executeTool(
621
704
  if ("error" in resolved) return resolved.error;
622
705
  }
623
706
  const dispatcher: MissionToolDispatcher = async (toolName, toolArgs) => {
707
+ if (isExternalToolName(toolName)) {
708
+ const capId = capabilityIdFromExternalTool(toolName);
709
+ const cap = caps.find((c) => c.id === capId);
710
+ if (!cap) {
711
+ return { text: `Unknown external capability "${capId}".`, isError: true };
712
+ }
713
+ const resolved = resolveRobotForTool(config, toolArgs);
714
+ if ("error" in resolved) {
715
+ return { text: resolved.error.output, isError: true };
716
+ }
717
+ const transport = await getTransportForRobot(config, resolved.robot);
718
+ const ext = await executeExternalCapability(cap, toolArgs, transport, {
719
+ namespace: resolved.robot.namespace,
720
+ });
721
+ return { text: ext.text, outputs: ext.outputs, isError: ext.isError };
722
+ }
624
723
  const sub = await executeTool(toolName, toolArgs, config);
625
724
  return { text: sub.output };
626
725
  };
@@ -638,7 +737,7 @@ export async function executeTool(
638
737
 
639
738
  let result;
640
739
  try {
641
- result = await runMission(mission, caps, MISSION_BINDINGS, dispatcher, {
740
+ result = await runMission(mission, caps, buildMissionBindings(caps), dispatcher, {
642
741
  mission_id: missionId,
643
742
  cancellation: regEntry.cancellation,
644
743
  transcript,
@@ -942,6 +1041,168 @@ export async function executeTool(
942
1041
  }
943
1042
  }
944
1043
 
1044
+ case "ros2_follow_me_start": {
1045
+ const mode = followMeMode(args);
1046
+ const desc = String(args["target_description"] ?? "").trim();
1047
+ if (mode === "local") {
1048
+ try {
1049
+ const loop = getFollowMeLocal(robot, config, transport);
1050
+ await loop.start({ targetDescription: desc || undefined });
1051
+ return {
1052
+ output: `Follow-me (local) started on ${robot.id}${desc ? ` (target: ${desc})` : " (closest person)"}.`,
1053
+ };
1054
+ } catch (err) {
1055
+ return { output: `Follow-me local start failed: ${err instanceof Error ? err.message : String(err)}` };
1056
+ }
1057
+ }
1058
+ if (mode === "depth") {
1059
+ try {
1060
+ const loop = getFollowMeDepth(robot, config, transport);
1061
+ await loop.start({ targetDescription: desc || undefined });
1062
+ return {
1063
+ output: `Follow-me (depth-only) started on ${robot.id}. Use ros2_follow_me_status with mode='depth' to check.`,
1064
+ };
1065
+ } catch (err) {
1066
+ return { output: `Follow-me depth start failed: ${err instanceof Error ? err.message : String(err)}` };
1067
+ }
1068
+ }
1069
+ try {
1070
+ const { topic } = await publishFollowMeCmd(config, robot, {
1071
+ action: "start",
1072
+ ...(desc ? { target: desc } : {}),
1073
+ });
1074
+ return {
1075
+ output: `Follow-me start sent to ${topic}${desc ? ` (target: ${desc})` : ""}.`,
1076
+ };
1077
+ } catch (err) {
1078
+ return { output: `Follow-me start failed: ${err instanceof Error ? err.message : String(err)}` };
1079
+ }
1080
+ }
1081
+
1082
+ case "ros2_follow_me_stop": {
1083
+ const mode = followMeMode(args);
1084
+ if (mode === "local") {
1085
+ getFollowMeLocal(robot, config, transport).stop();
1086
+ return { output: `Follow-me (local) stopped on ${robot.id}.` };
1087
+ }
1088
+ if (mode === "depth") {
1089
+ getFollowMeDepth(robot, config, transport).stop();
1090
+ return { output: `Follow-me (depth) stopped on ${robot.id}.` };
1091
+ }
1092
+ try {
1093
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "stop" });
1094
+ return { output: `Follow-me stop sent to ${topic}.` };
1095
+ } catch (err) {
1096
+ return { output: `Follow-me stop failed: ${err instanceof Error ? err.message : String(err)}` };
1097
+ }
1098
+ }
1099
+
1100
+ case "ros2_follow_me_status": {
1101
+ const mode = followMeMode(args);
1102
+ if (mode === "local") {
1103
+ const status = getFollowMeLocal(robot, config, transport).status();
1104
+ return { output: JSON.stringify(status) };
1105
+ }
1106
+ if (mode === "depth") {
1107
+ const status = getFollowMeDepth(robot, config, transport).status();
1108
+ return { output: JSON.stringify(status) };
1109
+ }
1110
+ try {
1111
+ const statusTopic = toNamespacedTopicFull(robot.namespace, "/follow_me/status");
1112
+ const timeout = (args["timeout"] as number | undefined) ?? 3000;
1113
+ const statusMsg = await new Promise<Record<string, unknown>>((resolve, reject) => {
1114
+ const timer = setTimeout(() => {
1115
+ sub.unsubscribe();
1116
+ reject(new Error(`Timeout waiting for ${statusTopic}`));
1117
+ }, timeout);
1118
+ const sub = transport.subscribe(
1119
+ { topic: statusTopic, type: "std_msgs/msg/String" },
1120
+ (m) => {
1121
+ clearTimeout(timer);
1122
+ sub.unsubscribe();
1123
+ resolve(m as Record<string, unknown>);
1124
+ },
1125
+ );
1126
+ });
1127
+ return { output: JSON.stringify(statusMsg) };
1128
+ } catch (err) {
1129
+ return { output: `Follow-me status failed: ${err instanceof Error ? err.message : String(err)}` };
1130
+ }
1131
+ }
1132
+
1133
+ case "ros2_follow_me_set_distance": {
1134
+ const mode = followMeMode(args);
1135
+ const distance = Number(args["distance"]);
1136
+ if (!Number.isFinite(distance)) {
1137
+ return { output: "ros2_follow_me_set_distance requires numeric 'distance'." };
1138
+ }
1139
+ if (mode === "local") {
1140
+ getFollowMeLocal(robot, config, transport).setTargetDistance(distance);
1141
+ return { output: `Follow-me (local) target distance set to ${distance} m on ${robot.id}.` };
1142
+ }
1143
+ if (mode === "depth") {
1144
+ getFollowMeDepth(robot, config, transport).setTargetDistance(distance);
1145
+ return { output: `Follow-me (depth) target distance set to ${distance} m on ${robot.id}.` };
1146
+ }
1147
+ try {
1148
+ const { topic } = await publishFollowMeCmd(config, robot, {
1149
+ action: "set_distance",
1150
+ distance,
1151
+ });
1152
+ return { output: `Follow-me set_distance sent to ${topic}.` };
1153
+ } catch (err) {
1154
+ return { output: `Follow-me set_distance failed: ${err instanceof Error ? err.message : String(err)}` };
1155
+ }
1156
+ }
1157
+
1158
+ case "ros2_follow_me_set_target": {
1159
+ const mode = followMeMode(args);
1160
+ const desc = String(args["target_description"] ?? "").trim();
1161
+ if (mode === "local") {
1162
+ getFollowMeLocal(robot, config, transport).setTargetDescription(desc);
1163
+ return { output: `Follow-me (local) target updated on ${robot.id}.` };
1164
+ }
1165
+ if (mode === "depth") {
1166
+ getFollowMeDepth(robot, config, transport).setTargetDescription(desc);
1167
+ return { output: "Depth mode recorded target description (still follows closest blob)." };
1168
+ }
1169
+ try {
1170
+ const { topic } = await publishFollowMeCmd(config, robot, {
1171
+ action: "set_target",
1172
+ target_description: desc,
1173
+ });
1174
+ return { output: `Follow-me set_target sent to ${topic}.` };
1175
+ } catch (err) {
1176
+ return { output: `Follow-me set_target failed: ${err instanceof Error ? err.message : String(err)}` };
1177
+ }
1178
+ }
1179
+
1180
+ case "ros2_find_object": {
1181
+ const target = String(args["target"] ?? "").trim();
1182
+ if (!target) {
1183
+ return { output: "Missing required argument: target" };
1184
+ }
1185
+ try {
1186
+ const result = await findObject(robot, config, transport, {
1187
+ target,
1188
+ angularSpeed: args["angular_speed"] as number | undefined,
1189
+ clockwise: args["clockwise"] as boolean | undefined,
1190
+ timeoutSeconds: args["timeout_seconds"] as number | undefined,
1191
+ minConfidence: args["min_confidence"] as number | undefined,
1192
+ });
1193
+ const summary = result.error
1194
+ ? result.error
1195
+ : result.found
1196
+ ? `Found ${target} after ${result.elapsedSeconds.toFixed(1)}s. ` +
1197
+ `Confidence ${((result.detection!.confidence) * 100).toFixed(0)}%, ` +
1198
+ `horizontal offset ${result.detection!.horizontalOffset.toFixed(2)}. Robot stopped.`
1199
+ : `${target} not found within ${result.elapsedSeconds.toFixed(1)}s. Robot stopped.`;
1200
+ return { output: summary + "\n" + JSON.stringify(result) };
1201
+ } catch (err) {
1202
+ return { output: `ros2_find_object failed: ${err instanceof Error ? err.message : String(err)}` };
1203
+ }
1204
+ }
1205
+
945
1206
  default:
946
1207
  return { output: `Unknown tool: ${name}` };
947
1208
  }
@@ -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.6.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
+ });