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.
- package/README.md +25 -7
- package/dist/__tests__/claude-config.test.d.ts +2 -0
- package/dist/__tests__/claude-config.test.d.ts.map +1 -0
- package/dist/__tests__/claude-config.test.js +77 -0
- package/dist/__tests__/claude-config.test.js.map +1 -0
- package/dist/__tests__/mcp-setup.test.d.ts +2 -0
- package/dist/__tests__/mcp-setup.test.d.ts.map +1 -0
- package/dist/__tests__/mcp-setup.test.js +23 -0
- package/dist/__tests__/mcp-setup.test.js.map +1 -0
- package/dist/commands/claude.d.ts +16 -0
- package/dist/commands/claude.d.ts.map +1 -0
- package/dist/commands/claude.js +30 -0
- package/dist/commands/claude.js.map +1 -0
- package/dist/commands/codex.d.ts +4 -4
- package/dist/commands/codex.d.ts.map +1 -1
- package/dist/commands/codex.js +21 -90
- package/dist/commands/codex.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +22 -27
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/hermes.d.ts +3 -3
- package/dist/commands/hermes.d.ts.map +1 -1
- package/dist/commands/hermes.js +13 -76
- package/dist/commands/hermes.js.map +1 -1
- package/dist/commands/init.d.ts +2 -3
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -38
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mcp.d.ts +20 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +31 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -1
- package/dist/util/claude-config.d.ts +47 -0
- package/dist/util/claude-config.d.ts.map +1 -0
- package/dist/util/claude-config.js +227 -0
- package/dist/util/claude-config.js.map +1 -0
- package/dist/util/mcp-setup.d.ts +55 -0
- package/dist/util/mcp-setup.d.ts.map +1 -0
- package/dist/util/mcp-setup.js +235 -0
- package/dist/util/mcp-setup.js.map +1 -0
- 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/package.json +1 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +31 -6
- package/runtime/docs/cli.md +54 -14
- 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/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-mission.ts +33 -84
- package/runtime/packages/agenticros-claude-code/README.md +2 -0
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.js +130 -111
- package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/src/tools.ts +140 -99
- package/runtime/packages/agenticros-gemini/package.json +2 -0
- 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/tools.ts +351 -90
- 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/capabilities.ts +38 -3
- package/runtime/packages/core/src/capability-schema.ts +58 -0
- package/runtime/packages/core/src/config.ts +20 -0
- package/runtime/packages/core/src/discovery.ts +21 -3
- package/runtime/packages/core/src/external-capability.ts +219 -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 +55 -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/transport/rosbridge/client.ts +7 -0
- package/runtime/pnpm-lock.yaml +6 -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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat + fleet-config unit tests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
detectRobotsFromTopics,
|
|
12
|
+
discoverRobots,
|
|
13
|
+
} from "../discovery.js";
|
|
14
|
+
import {
|
|
15
|
+
isHeartbeatFresh,
|
|
16
|
+
mergeRobotHeartbeats,
|
|
17
|
+
onlineIdsFromHeartbeats,
|
|
18
|
+
parseRobotInfoMessage,
|
|
19
|
+
DEFAULT_HEARTBEAT_STALENESS_MS,
|
|
20
|
+
} from "../heartbeat.js";
|
|
21
|
+
import { applyFleetOverride, loadFleetFile } from "../fleet-config.js";
|
|
22
|
+
import { listRobots } from "../robots.js";
|
|
23
|
+
import { parseConfig } from "../config.js";
|
|
24
|
+
|
|
25
|
+
test("parseRobotInfoMessage extracts fields", () => {
|
|
26
|
+
const hb = parseRobotInfoMessage("/bot1/agenticros/robot_info", {
|
|
27
|
+
id: "kitchen",
|
|
28
|
+
name: "Kitchen Bot",
|
|
29
|
+
kind: "amr",
|
|
30
|
+
robot_namespace: "bot1",
|
|
31
|
+
capability_ids: ["drive_base", "follow_person"],
|
|
32
|
+
has_realsense: true,
|
|
33
|
+
has_lidar: false,
|
|
34
|
+
has_arm: false,
|
|
35
|
+
stamp: { sec: 1_700_000_000, nanosec: 0 },
|
|
36
|
+
});
|
|
37
|
+
assert.equal(hb.id, "kitchen");
|
|
38
|
+
assert.equal(hb.sensors.has_realsense, true);
|
|
39
|
+
assert.equal(hb.stamp_ms, 1_700_000_000_000);
|
|
40
|
+
assert.deepEqual(hb.capability_ids, ["drive_base", "follow_person"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("isHeartbeatFresh respects 5s window", () => {
|
|
44
|
+
const now = 1_000_000;
|
|
45
|
+
const fresh = parseRobotInfoMessage("/n/agenticros/robot_info", {
|
|
46
|
+
id: "a",
|
|
47
|
+
robot_namespace: "n",
|
|
48
|
+
stamp: { sec: Math.floor((now - 2000) / 1000), nanosec: 0 },
|
|
49
|
+
});
|
|
50
|
+
// Fix stamp_ms manually for precise control
|
|
51
|
+
fresh.stamp_ms = now - 2000;
|
|
52
|
+
assert.equal(isHeartbeatFresh(fresh, { nowMs: now }), true);
|
|
53
|
+
|
|
54
|
+
const stale = { ...fresh, stamp_ms: now - DEFAULT_HEARTBEAT_STALENESS_MS - 1 };
|
|
55
|
+
assert.equal(isHeartbeatFresh(stale, { nowMs: now }), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("onlineIdsFromHeartbeats maps to configured ids", () => {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const hb = parseRobotInfoMessage("/robotabc/agenticros/robot_info", {
|
|
61
|
+
id: "wire-id",
|
|
62
|
+
robot_namespace: "robotabc",
|
|
63
|
+
stamp: { sec: Math.floor(now / 1000), nanosec: 0 },
|
|
64
|
+
});
|
|
65
|
+
hb.stamp_ms = now;
|
|
66
|
+
const map = new Map([["robotabc", "configured-kitchen"]]);
|
|
67
|
+
const online = onlineIdsFromHeartbeats([hb], {
|
|
68
|
+
nowMs: now,
|
|
69
|
+
configuredIdByNamespace: map,
|
|
70
|
+
});
|
|
71
|
+
assert.ok(online.has("configured-kitchen"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("mergeRobotHeartbeats keeps freshest per namespace", () => {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const older = parseRobotInfoMessage("/robotn/agenticros/robot_info", {
|
|
77
|
+
id: "a",
|
|
78
|
+
robot_namespace: "robotn",
|
|
79
|
+
name: "old",
|
|
80
|
+
});
|
|
81
|
+
older.stamp_ms = now - 1000;
|
|
82
|
+
const newer = parseRobotInfoMessage("/robotn/agenticros/robot_info", {
|
|
83
|
+
id: "a",
|
|
84
|
+
robot_namespace: "robotn",
|
|
85
|
+
name: "new",
|
|
86
|
+
});
|
|
87
|
+
newer.stamp_ms = now;
|
|
88
|
+
const merged = mergeRobotHeartbeats([older, newer], { nowMs: now });
|
|
89
|
+
assert.equal(merged.get("robotn")?.name, "new");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("detectRobotsFromTopics includes robot_info-only namespaces", () => {
|
|
93
|
+
const detected = detectRobotsFromTopics([
|
|
94
|
+
{ name: "/arm1/agenticros/robot_info", type: "agenticros_msgs/msg/RobotInfo" },
|
|
95
|
+
]);
|
|
96
|
+
assert.equal(detected.length, 1);
|
|
97
|
+
assert.equal(detected[0]!.id, "arm1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("discoverRobots treats robot_info topic as online signal", () => {
|
|
101
|
+
// Isolate from a real ~/.agenticros/fleet.json on the developer machine.
|
|
102
|
+
const prev = process.env.AGENTICROS_FLEET_PATH;
|
|
103
|
+
process.env.AGENTICROS_FLEET_PATH = join(tmpdir(), "no-fleet-for-discover-test.json");
|
|
104
|
+
try {
|
|
105
|
+
// Namespace must already be the on-wire form (or UUID) so
|
|
106
|
+
// effectiveCmdVelNamespace matches the topic segment.
|
|
107
|
+
const cfg = parseConfig({
|
|
108
|
+
robots: [{ id: "arm1", namespace: "robotarm1", kind: "arm" }],
|
|
109
|
+
});
|
|
110
|
+
const result = discoverRobots(
|
|
111
|
+
[{ name: "/robotarm1/agenticros/robot_info", type: "agenticros_msgs/msg/RobotInfo" }],
|
|
112
|
+
cfg,
|
|
113
|
+
);
|
|
114
|
+
assert.equal(result.configured_online.length, 1);
|
|
115
|
+
assert.equal(result.configured_online[0]!.id, "arm1");
|
|
116
|
+
} finally {
|
|
117
|
+
if (prev === undefined) delete process.env.AGENTICROS_FLEET_PATH;
|
|
118
|
+
else process.env.AGENTICROS_FLEET_PATH = prev;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("loadFleetFile + applyFleetOverride wins over config.robots", () => {
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), "agenticros-fleet-"));
|
|
124
|
+
const path = join(dir, "fleet.json");
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(
|
|
127
|
+
path,
|
|
128
|
+
JSON.stringify([{ id: "fleet-bot", namespace: "fleetns", kind: "amr", name: "From Fleet" }]),
|
|
129
|
+
);
|
|
130
|
+
const loaded = loadFleetFile(path);
|
|
131
|
+
assert.equal(loaded.used, true);
|
|
132
|
+
assert.equal(loaded.robots[0]!.id, "fleet-bot");
|
|
133
|
+
|
|
134
|
+
const cfg = parseConfig({
|
|
135
|
+
robots: [{ id: "config-bot", namespace: "configns" }],
|
|
136
|
+
});
|
|
137
|
+
const overridden = applyFleetOverride(cfg, path);
|
|
138
|
+
const robots = listRobots(overridden);
|
|
139
|
+
// listRobots also applies fleet from default path — pass overridden
|
|
140
|
+
// config that already has fleet robots embedded:
|
|
141
|
+
assert.equal(overridden.robots?.[0]?.id, "fleet-bot");
|
|
142
|
+
assert.equal(robots[0]!.id, "fleet-bot");
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(dir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("loadFleetFile missing file is unused", () => {
|
|
149
|
+
const result = loadFleetFile(join(tmpdir(), "no-such-agenticros-fleet-xyz.json"));
|
|
150
|
+
assert.equal(result.used, false);
|
|
151
|
+
assert.equal(result.robots.length, 0);
|
|
152
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for buildMissionBindings — Phase 1 dynamic bindings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
BUILTIN_MISSION_BINDINGS,
|
|
9
|
+
buildMissionBindings,
|
|
10
|
+
defaultToolForCapability,
|
|
11
|
+
externalToolName,
|
|
12
|
+
isExternalToolName,
|
|
13
|
+
} from "../mission-bindings.js";
|
|
14
|
+
import type { Capability } from "../capabilities.js";
|
|
15
|
+
|
|
16
|
+
test("buildMissionBindings includes all builtins", () => {
|
|
17
|
+
const bindings = buildMissionBindings([]);
|
|
18
|
+
for (const id of Object.keys(BUILTIN_MISSION_BINDINGS)) {
|
|
19
|
+
assert.ok(bindings[id], `missing builtin binding ${id}`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("buildMissionBindings derives ros2_<id> for skill capabilities", () => {
|
|
24
|
+
const caps: Capability[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "wave_hand",
|
|
27
|
+
verb: "wave",
|
|
28
|
+
description: "Wave",
|
|
29
|
+
source: { kind: "skill", skillId: "wave", package: "agenticros-skill-wave" },
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
const bindings = buildMissionBindings(caps);
|
|
33
|
+
assert.equal(bindings.wave_hand.tool, "ros2_wave_hand");
|
|
34
|
+
assert.deepEqual(bindings.wave_hand.buildArgs({ a: 1 }), { a: 1 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("buildMissionBindings uses external: prefix for external_ros_node", () => {
|
|
38
|
+
const caps: Capability[] = [
|
|
39
|
+
{
|
|
40
|
+
id: "navigate_to",
|
|
41
|
+
verb: "navigate",
|
|
42
|
+
description: "Nav2",
|
|
43
|
+
implementation: {
|
|
44
|
+
kind: "external_ros_node",
|
|
45
|
+
action: "navigate_to_pose",
|
|
46
|
+
msg_type: "nav2_msgs/action/NavigateToPose",
|
|
47
|
+
},
|
|
48
|
+
source: { kind: "skill", skillId: "navigate", package: "navigate-to" },
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
const bindings = buildMissionBindings(caps);
|
|
52
|
+
assert.equal(bindings.navigate_to.tool, externalToolName("navigate_to"));
|
|
53
|
+
assert.ok(isExternalToolName(bindings.navigate_to.tool));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("buildMissionBindings respects toolNameResolver", () => {
|
|
57
|
+
const caps: Capability[] = [
|
|
58
|
+
{
|
|
59
|
+
id: "custom_cap",
|
|
60
|
+
verb: "custom",
|
|
61
|
+
description: "Custom",
|
|
62
|
+
source: { kind: "skill", skillId: "c", package: "c" },
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
const bindings = buildMissionBindings(caps, {
|
|
66
|
+
toolNameResolver: (cap) => (cap.id === "custom_cap" ? "my_custom_tool" : undefined),
|
|
67
|
+
});
|
|
68
|
+
assert.equal(bindings.custom_cap.tool, "my_custom_tool");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("defaultToolForCapability prefers explicit tool field", () => {
|
|
72
|
+
assert.equal(
|
|
73
|
+
defaultToolForCapability({
|
|
74
|
+
id: "x",
|
|
75
|
+
verb: "x",
|
|
76
|
+
description: "x",
|
|
77
|
+
tool: "ros2_special",
|
|
78
|
+
}),
|
|
79
|
+
"ros2_special",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("extra bindings win over derived", () => {
|
|
84
|
+
const caps: Capability[] = [
|
|
85
|
+
{
|
|
86
|
+
id: "wave_hand",
|
|
87
|
+
verb: "wave",
|
|
88
|
+
description: "Wave",
|
|
89
|
+
source: { kind: "skill", skillId: "wave", package: "w" },
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
const bindings = buildMissionBindings(caps, {
|
|
93
|
+
extra: {
|
|
94
|
+
wave_hand: {
|
|
95
|
+
tool: "override_tool",
|
|
96
|
+
buildArgs: () => ({ overridden: true }),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
assert.equal(bindings.wave_hand.tool, "override_tool");
|
|
101
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pause / resume tests for runMission + MissionRegistry.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { runMission, type CapabilityToolBindings, type Mission } from "../mission.js";
|
|
8
|
+
import { MissionRegistry } from "../mission-registry.js";
|
|
9
|
+
import type { Capability } from "../capabilities.js";
|
|
10
|
+
|
|
11
|
+
const CAPS: Capability[] = [
|
|
12
|
+
{ id: "drive_base", verb: "drive", description: "Drive", source: { kind: "builtin" } },
|
|
13
|
+
{ id: "list_topics", verb: "list", description: "List", source: { kind: "builtin" } },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const BINDINGS: CapabilityToolBindings = {
|
|
17
|
+
drive_base: {
|
|
18
|
+
tool: "ros2_publish",
|
|
19
|
+
buildArgs: (inputs) => ({ topic: "cmd_vel", msg: inputs }),
|
|
20
|
+
},
|
|
21
|
+
list_topics: {
|
|
22
|
+
tool: "ros2_list_topics",
|
|
23
|
+
buildArgs: () => ({}),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test("pause blocks next step until resume", async () => {
|
|
28
|
+
const token = { cancelled: false, paused: true, reason: "human in aisle" };
|
|
29
|
+
const calls: string[] = [];
|
|
30
|
+
const mission: Mission = {
|
|
31
|
+
name: "pause-test",
|
|
32
|
+
steps: [
|
|
33
|
+
{ id: "a", capability: "list_topics" },
|
|
34
|
+
{ id: "b", capability: "drive_base", inputs: { linear_x: 0.1 } },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const runPromise = runMission(mission, CAPS, BINDINGS, async (tool) => {
|
|
39
|
+
calls.push(tool);
|
|
40
|
+
return { text: JSON.stringify({ ok: true }) };
|
|
41
|
+
}, { cancellation: token, mission_id: "mn_pause" });
|
|
42
|
+
|
|
43
|
+
// First step should not run while paused — wait a bit then resume.
|
|
44
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
45
|
+
assert.equal(calls.length, 0, "no tools while paused before first step");
|
|
46
|
+
token.paused = false;
|
|
47
|
+
|
|
48
|
+
const result = await runPromise;
|
|
49
|
+
assert.equal(result.status, "ok");
|
|
50
|
+
assert.equal(calls.length, 2);
|
|
51
|
+
assert.deepEqual(calls, ["ros2_list_topics", "ros2_publish"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("cancel while paused aborts remaining steps", async () => {
|
|
55
|
+
const token = { cancelled: false, paused: true };
|
|
56
|
+
const mission: Mission = {
|
|
57
|
+
steps: [
|
|
58
|
+
{ id: "a", capability: "list_topics" },
|
|
59
|
+
{ id: "b", capability: "drive_base" },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const runPromise = runMission(mission, CAPS, BINDINGS, async () => ({ text: "{}" }), {
|
|
64
|
+
cancellation: token,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
68
|
+
token.cancelled = true;
|
|
69
|
+
token.paused = false;
|
|
70
|
+
|
|
71
|
+
const result = await runPromise;
|
|
72
|
+
assert.equal(result.status, "cancelled");
|
|
73
|
+
assert.ok(result.steps.every((s) => s.status === "cancelled"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("MissionRegistry pause/resume/cancel", () => {
|
|
77
|
+
const reg = new MissionRegistry();
|
|
78
|
+
const { entry, dispose } = reg.register("mn_1", { name: "t" });
|
|
79
|
+
assert.equal(entry.cancellation.paused, false);
|
|
80
|
+
|
|
81
|
+
const p1 = reg.pause("mn_1", "wait");
|
|
82
|
+
assert.equal(p1.found, true);
|
|
83
|
+
assert.equal(p1.alreadyPaused, false);
|
|
84
|
+
assert.equal(entry.cancellation.paused, true);
|
|
85
|
+
assert.equal(entry.cancellation.reason, "wait");
|
|
86
|
+
|
|
87
|
+
const p2 = reg.pause("mn_1");
|
|
88
|
+
assert.equal(p2.alreadyPaused, true);
|
|
89
|
+
|
|
90
|
+
const r1 = reg.resume("mn_1");
|
|
91
|
+
assert.equal(r1.found, true);
|
|
92
|
+
assert.equal(r1.wasPaused, true);
|
|
93
|
+
assert.equal(entry.cancellation.paused, false);
|
|
94
|
+
|
|
95
|
+
assert.equal(reg.pause("missing").found, false);
|
|
96
|
+
assert.equal(reg.resume("missing").found, false);
|
|
97
|
+
|
|
98
|
+
reg.pause("mn_1");
|
|
99
|
+
reg.cancel("mn_1", "abort");
|
|
100
|
+
assert.equal(entry.cancellation.cancelled, true);
|
|
101
|
+
assert.equal(entry.cancellation.paused, false);
|
|
102
|
+
|
|
103
|
+
dispose();
|
|
104
|
+
});
|
|
@@ -193,9 +193,30 @@ function normalizeCapability(
|
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
function readCapabilitiesJson(
|
|
197
|
+
packageDir: string,
|
|
198
|
+
): Array<Partial<Capability> & { id: string }> {
|
|
199
|
+
const capsPath = join(packageDir, "capabilities.json");
|
|
200
|
+
if (!existsSync(capsPath)) return [];
|
|
201
|
+
try {
|
|
202
|
+
const raw = JSON.parse(readFileSync(capsPath, "utf-8")) as unknown;
|
|
203
|
+
const list = Array.isArray(raw)
|
|
204
|
+
? raw
|
|
205
|
+
: raw && typeof raw === "object" && Array.isArray((raw as { capabilities?: unknown }).capabilities)
|
|
206
|
+
? (raw as { capabilities: unknown[] }).capabilities
|
|
207
|
+
: [];
|
|
208
|
+
const out: Array<Partial<Capability> & { id: string }> = [];
|
|
209
|
+
for (const c of list) if (isCapabilityLike(c)) out.push(c);
|
|
210
|
+
return out;
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
/**
|
|
197
|
-
* Read the `agenticros.capabilities[]` array from a skill's package.json
|
|
198
|
-
* Returns `null` if the package
|
|
217
|
+
* Read the `agenticros.capabilities[]` array from a skill's package.json,
|
|
218
|
+
* merging any sibling `capabilities.json`. Returns `null` if the package
|
|
219
|
+
* doesn't declare a valid `agenticros` block.
|
|
199
220
|
*
|
|
200
221
|
* The legacy `agenticrosSkill: true | { capabilities }` form is NOT supported
|
|
201
222
|
* — every skill must use the single `agenticros` block as its source of truth.
|
|
@@ -220,10 +241,24 @@ function readSkillManifestFromDir(
|
|
|
220
241
|
const packageName = pkg.name ?? "unknown";
|
|
221
242
|
const skillId = pkg.agenticros.id;
|
|
222
243
|
const rawCaps: Array<Partial<Capability> & { id: string }> = [];
|
|
244
|
+
const seenIds = new Set<string>();
|
|
223
245
|
|
|
224
246
|
const caps = pkg.agenticros.capabilities;
|
|
225
247
|
if (Array.isArray(caps)) {
|
|
226
|
-
for (const c of caps)
|
|
248
|
+
for (const c of caps) {
|
|
249
|
+
if (isCapabilityLike(c) && !seenIds.has(c.id)) {
|
|
250
|
+
seenIds.add(c.id);
|
|
251
|
+
rawCaps.push(c);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Sibling capabilities.json merges in (package.json wins on id conflict).
|
|
257
|
+
for (const c of readCapabilitiesJson(packageDir)) {
|
|
258
|
+
if (!seenIds.has(c.id)) {
|
|
259
|
+
seenIds.add(c.id);
|
|
260
|
+
rawCaps.push(c);
|
|
261
|
+
}
|
|
227
262
|
}
|
|
228
263
|
|
|
229
264
|
return { packageName, skillId, rawCaps };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for capability manifests (skill load / CLI publish).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export const CapabilityFieldSchema = z.object({
|
|
8
|
+
type: z.string(),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
optional: z.boolean().optional(),
|
|
11
|
+
default: z.unknown().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const CapabilityImplementationSchema = z.union([
|
|
15
|
+
z.object({ kind: z.literal("in_process") }),
|
|
16
|
+
z.object({
|
|
17
|
+
kind: z.literal("external_ros_node"),
|
|
18
|
+
package: z.string().optional(),
|
|
19
|
+
launch: z.string().optional(),
|
|
20
|
+
action: z.string().optional(),
|
|
21
|
+
service: z.string().optional(),
|
|
22
|
+
topic: z.string().optional(),
|
|
23
|
+
msg_type: z.string().optional(),
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const CapabilitySchema = z.object({
|
|
28
|
+
id: z.string().min(1),
|
|
29
|
+
verb: z.string().min(1),
|
|
30
|
+
description: z.string().default(""),
|
|
31
|
+
inputs: z.record(CapabilityFieldSchema).optional(),
|
|
32
|
+
outputs: z.record(CapabilityFieldSchema).optional(),
|
|
33
|
+
preconditions: z.array(z.string()).optional(),
|
|
34
|
+
interruptible: z.boolean().optional(),
|
|
35
|
+
blocks_base: z.boolean().optional(),
|
|
36
|
+
implementation: CapabilityImplementationSchema.optional(),
|
|
37
|
+
tool: z.string().optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type ParsedCapability = z.infer<typeof CapabilitySchema>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate a raw capability object. Throws ZodError on failure.
|
|
44
|
+
*/
|
|
45
|
+
export function parseCapability(raw: unknown): ParsedCapability {
|
|
46
|
+
return CapabilitySchema.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Soft-validate: returns { ok, capability?, error? }.
|
|
51
|
+
*/
|
|
52
|
+
export function safeParseCapability(
|
|
53
|
+
raw: unknown,
|
|
54
|
+
): { ok: true; capability: ParsedCapability } | { ok: false; error: string } {
|
|
55
|
+
const result = CapabilitySchema.safeParse(raw);
|
|
56
|
+
if (result.success) return { ok: true, capability: result.data };
|
|
57
|
+
return { ok: false, error: result.error.message };
|
|
58
|
+
}
|
|
@@ -441,6 +441,26 @@ export function parseConfig(raw: Record<string, unknown>): AgenticROSConfig {
|
|
|
441
441
|
return AgenticROSConfigSchema.parse(normalized);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Strip Zod defaults that were never present in the raw config before parse.
|
|
446
|
+
* The config page validates via parseConfig() but should not inject keys the
|
|
447
|
+
* user never set — e.g. `robots: []` from the schema default breaks OpenClaw's
|
|
448
|
+
* strict plugin configSchema when that field isn't declared yet.
|
|
449
|
+
*/
|
|
450
|
+
export function prepareConfigForPersistence(
|
|
451
|
+
parsed: AgenticROSConfig,
|
|
452
|
+
raw: Record<string, unknown>,
|
|
453
|
+
): Record<string, unknown> {
|
|
454
|
+
const out = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
|
|
455
|
+
if (!Object.prototype.hasOwnProperty.call(raw, "robots")) {
|
|
456
|
+
const robots = out.robots;
|
|
457
|
+
if (Array.isArray(robots) && robots.length === 0) {
|
|
458
|
+
delete out.robots;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
444
464
|
/**
|
|
445
465
|
* Build TransportConfig from full config for createTransport().
|
|
446
466
|
*/
|
|
@@ -102,9 +102,13 @@ export function effectiveCmdVelNamespace(robotNamespace: string): string {
|
|
|
102
102
|
/**
|
|
103
103
|
* Find every robot-shaped namespace in a topic list.
|
|
104
104
|
*
|
|
105
|
-
* Detection
|
|
106
|
-
*
|
|
107
|
-
*
|
|
105
|
+
* Detection rules (either is enough):
|
|
106
|
+
* 1. `/<seg>/cmd_vel` — classic mobile-base signal
|
|
107
|
+
* 2. `/<seg>/agenticros/robot_info` — Phase 1.d heartbeat (arms/drones
|
|
108
|
+
* without cmd_vel still show up)
|
|
109
|
+
*
|
|
110
|
+
* The unnamespaced `/cmd_vel` / `/agenticros/robot_info` topics register
|
|
111
|
+
* the empty namespace (id=""). Topic count under each namespace is
|
|
108
112
|
* accumulated so consumers can rank by liveness.
|
|
109
113
|
*
|
|
110
114
|
* The returned `DetectedRobot.configuredRobotId` is always `null` here —
|
|
@@ -124,6 +128,20 @@ export function detectRobotsFromTopics(topics: TopicInfo[]): DetectedRobot[] {
|
|
|
124
128
|
} else if (t.name === "/cmd_vel" && !byNs.has("")) {
|
|
125
129
|
byNs.set("", { cmdVelTopic: "/cmd_vel", topicCount: 0 });
|
|
126
130
|
}
|
|
131
|
+
|
|
132
|
+
// Heartbeat topic — covers robots that don't publish cmd_vel.
|
|
133
|
+
const hb = t.name.match(/^\/([^/]+)\/agenticros\/robot_info$/);
|
|
134
|
+
if (hb) {
|
|
135
|
+
const ns = hb[1]!;
|
|
136
|
+
if (!byNs.has(ns)) {
|
|
137
|
+
byNs.set(ns, { cmdVelTopic: `/${ns}/cmd_vel`, topicCount: 0 });
|
|
138
|
+
}
|
|
139
|
+
} else if (
|
|
140
|
+
(t.name === "/agenticros/robot_info" || t.name === "agenticros/robot_info") &&
|
|
141
|
+
!byNs.has("")
|
|
142
|
+
) {
|
|
143
|
+
byNs.set("", { cmdVelTopic: "/cmd_vel", topicCount: 0 });
|
|
144
|
+
}
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
// Count topics under each detected namespace. Empty-namespace robots
|