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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for skillRefs parsing and discoverable capability merge.
|
|
3
|
+
*/
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import {
|
|
7
|
+
parseSkillRef,
|
|
8
|
+
parseConfig,
|
|
9
|
+
listCapabilitiesWithDiscoverable,
|
|
10
|
+
listAllCapabilities,
|
|
11
|
+
} from "../index.js";
|
|
12
|
+
|
|
13
|
+
test("parseSkillRef: owner/skill and @pin", () => {
|
|
14
|
+
assert.deepEqual(parseSkillRef("agenticros/navigate-to"), {
|
|
15
|
+
marketplaceRef: "agenticros/navigate-to",
|
|
16
|
+
owner: "agenticros",
|
|
17
|
+
skill: "navigate-to",
|
|
18
|
+
gitRef: "main",
|
|
19
|
+
});
|
|
20
|
+
assert.equal(parseSkillRef("agenticros/start-slam@v0.1.0")?.gitRef, "v0.1.0");
|
|
21
|
+
assert.equal(parseSkillRef("nope"), null);
|
|
22
|
+
assert.equal(parseSkillRef(""), null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("parseConfig: skillRefs defaults to []", () => {
|
|
26
|
+
const cfg = parseConfig({});
|
|
27
|
+
assert.deepEqual(cfg.skillRefs, []);
|
|
28
|
+
const cfg2 = parseConfig({ skillRefs: ["agenticros/detect-humans"] });
|
|
29
|
+
assert.deepEqual(cfg2.skillRefs, ["agenticros/detect-humans"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("listCapabilitiesWithDiscoverable: soft-fail offline keeps installed", async () => {
|
|
33
|
+
const cfg = parseConfig({});
|
|
34
|
+
const caps = await listCapabilitiesWithDiscoverable(cfg, {
|
|
35
|
+
apiBase: "http://127.0.0.1:9", // nothing listening
|
|
36
|
+
softFail: true,
|
|
37
|
+
});
|
|
38
|
+
const baseline = listAllCapabilities(cfg);
|
|
39
|
+
assert.equal(caps.length, baseline.length);
|
|
40
|
+
assert.ok(caps.every((c) => c.installed !== false || c.discoverable !== true));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("listCapabilitiesWithDiscoverable: merges marketplace caps when fetch works", async () => {
|
|
44
|
+
const originalFetch = globalThis.fetch;
|
|
45
|
+
globalThis.fetch = (async () =>
|
|
46
|
+
new Response(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
skills: [
|
|
49
|
+
{
|
|
50
|
+
marketplaceRef: "agenticros/detect-humans",
|
|
51
|
+
visibility: "public",
|
|
52
|
+
capabilities: [
|
|
53
|
+
{
|
|
54
|
+
id: "detect_humans",
|
|
55
|
+
verb: "detect",
|
|
56
|
+
description: "Read detections",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
63
|
+
)) as typeof fetch;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const cfg = parseConfig({});
|
|
67
|
+
const caps = await listCapabilitiesWithDiscoverable(cfg, { softFail: false });
|
|
68
|
+
const det = caps.find((c) => c.id === "detect_humans");
|
|
69
|
+
assert.ok(det);
|
|
70
|
+
assert.equal(det!.discoverable, true);
|
|
71
|
+
assert.equal(det!.installed, false);
|
|
72
|
+
assert.equal(det!.install_ref, "agenticros/detect-humans");
|
|
73
|
+
assert.ok(caps.some((c) => c.id === "drive_base" && c.installed === true));
|
|
74
|
+
} finally {
|
|
75
|
+
globalThis.fetch = originalFetch;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -418,6 +418,14 @@ export const AgenticROSConfigSchema = z.object({
|
|
|
418
418
|
|
|
419
419
|
/** Npm (or local) package names to load as skills. Resolved via require.resolve from plugin context. */
|
|
420
420
|
skillPackages: z.array(z.string()).default([]),
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Marketplace skill refs to auto-fetch into `~/.agenticros/skills-cache/`
|
|
424
|
+
* (e.g. `agenticros/navigate-to` or `owner/skill@main`). Resolved at
|
|
425
|
+
* adapter startup into effective skillPaths. Never auto-upgrades pins.
|
|
426
|
+
* Distinct from `skills` (per-skill runtime config object).
|
|
427
|
+
*/
|
|
428
|
+
skillRefs: z.array(z.string()).default([]),
|
|
421
429
|
});
|
|
422
430
|
|
|
423
431
|
export type AgenticROSConfig = z.infer<typeof AgenticROSConfigSchema>;
|
|
@@ -441,6 +449,26 @@ export function parseConfig(raw: Record<string, unknown>): AgenticROSConfig {
|
|
|
441
449
|
return AgenticROSConfigSchema.parse(normalized);
|
|
442
450
|
}
|
|
443
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Strip Zod defaults that were never present in the raw config before parse.
|
|
454
|
+
* The config page validates via parseConfig() but should not inject keys the
|
|
455
|
+
* user never set — e.g. `robots: []` from the schema default breaks OpenClaw's
|
|
456
|
+
* strict plugin configSchema when that field isn't declared yet.
|
|
457
|
+
*/
|
|
458
|
+
export function prepareConfigForPersistence(
|
|
459
|
+
parsed: AgenticROSConfig,
|
|
460
|
+
raw: Record<string, unknown>,
|
|
461
|
+
): Record<string, unknown> {
|
|
462
|
+
const out = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
|
|
463
|
+
if (!Object.prototype.hasOwnProperty.call(raw, "robots")) {
|
|
464
|
+
const robots = out.robots;
|
|
465
|
+
if (Array.isArray(robots) && robots.length === 0) {
|
|
466
|
+
delete out.robots;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
444
472
|
/**
|
|
445
473
|
* Build TransportConfig from full config for createTransport().
|
|
446
474
|
*/
|