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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discoverable marketplace capabilities — list verbs from skills.agenticros.com
|
|
3
|
+
* that are not yet installed locally, so agents can propose installs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AgenticROSConfig } from "./config.js";
|
|
7
|
+
import {
|
|
8
|
+
listAllCapabilities,
|
|
9
|
+
type Capability,
|
|
10
|
+
type CapabilityField,
|
|
11
|
+
type CapabilitySource,
|
|
12
|
+
} from "./capabilities.js";
|
|
13
|
+
import { skillsApiBase } from "./skill-refs.js";
|
|
14
|
+
|
|
15
|
+
export interface DiscoverableCapability extends Capability {
|
|
16
|
+
/** Always true for marketplace-only entries. */
|
|
17
|
+
discoverable: true;
|
|
18
|
+
/** False when not present in the local registry. */
|
|
19
|
+
installed: false;
|
|
20
|
+
/** Marketplace install ref (owner/skill). */
|
|
21
|
+
install_ref: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ListedCapability extends Capability {
|
|
25
|
+
discoverable?: boolean;
|
|
26
|
+
installed?: boolean;
|
|
27
|
+
install_ref?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ListCapabilitiesOptions {
|
|
31
|
+
/** Include marketplace browse (network). Default true. */
|
|
32
|
+
includeDiscoverable?: boolean;
|
|
33
|
+
apiBase?: string;
|
|
34
|
+
/** Max marketplace skills to scan. Default 50. */
|
|
35
|
+
marketplaceLimit?: number;
|
|
36
|
+
/** Soft-fail: on network error return installed-only. Default true. */
|
|
37
|
+
softFail?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface MarketplaceSkillDoc {
|
|
41
|
+
marketplaceRef?: string;
|
|
42
|
+
ownerLogin?: string;
|
|
43
|
+
skillSlug?: string;
|
|
44
|
+
slug?: string;
|
|
45
|
+
capabilities?: Array<Record<string, unknown>>;
|
|
46
|
+
visibility?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function installRefOf(s: MarketplaceSkillDoc): string | null {
|
|
50
|
+
if (s.marketplaceRef && typeof s.marketplaceRef === "string") {
|
|
51
|
+
return s.marketplaceRef.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
if (s.ownerLogin && s.skillSlug) {
|
|
54
|
+
return `${s.ownerLogin}/${s.skillSlug}`.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
if (s.slug && s.slug.includes("/")) return s.slug.toLowerCase();
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fieldMap(
|
|
61
|
+
raw: unknown,
|
|
62
|
+
): Record<string, CapabilityField> | undefined {
|
|
63
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
64
|
+
const out: Record<string, CapabilityField> = {};
|
|
65
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
66
|
+
if (typeof v === "string") {
|
|
67
|
+
out[k] = { type: v };
|
|
68
|
+
} else if (v && typeof v === "object") {
|
|
69
|
+
const o = v as Record<string, unknown>;
|
|
70
|
+
out[k] = {
|
|
71
|
+
type: typeof o.type === "string" ? o.type : "unknown",
|
|
72
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
73
|
+
optional: o.optional === true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(out).length ? out : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeMarketplaceCap(
|
|
81
|
+
raw: Record<string, unknown>,
|
|
82
|
+
installRef: string,
|
|
83
|
+
packageName: string,
|
|
84
|
+
): DiscoverableCapability | null {
|
|
85
|
+
const id = typeof raw.id === "string" ? raw.id : null;
|
|
86
|
+
const verb = typeof raw.verb === "string" ? raw.verb : null;
|
|
87
|
+
const description =
|
|
88
|
+
typeof raw.description === "string" ? raw.description : id ? `Marketplace capability ${id}` : null;
|
|
89
|
+
if (!id || !verb || !description) return null;
|
|
90
|
+
const source: CapabilitySource = {
|
|
91
|
+
kind: "skill",
|
|
92
|
+
skillId: installRef.split("/")[1] ?? installRef,
|
|
93
|
+
package: packageName,
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
verb,
|
|
98
|
+
description,
|
|
99
|
+
inputs: fieldMap(raw.inputs),
|
|
100
|
+
outputs: fieldMap(raw.outputs),
|
|
101
|
+
interruptible: raw.interruptible === true ? true : raw.interruptible === false ? false : undefined,
|
|
102
|
+
blocks_base: raw.blocks_base === true ? true : undefined,
|
|
103
|
+
source,
|
|
104
|
+
discoverable: true,
|
|
105
|
+
installed: false,
|
|
106
|
+
install_ref: installRef,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function fetchMarketplaceSkills(
|
|
111
|
+
opts: { apiBase?: string; limit?: number } = {},
|
|
112
|
+
): Promise<MarketplaceSkillDoc[]> {
|
|
113
|
+
const base = skillsApiBase(opts.apiBase);
|
|
114
|
+
const limit = opts.limit ?? 50;
|
|
115
|
+
const u = new URL(`${base}/skills`);
|
|
116
|
+
u.searchParams.set("limit", String(limit));
|
|
117
|
+
u.searchParams.set("sort", "popular");
|
|
118
|
+
const res = await fetch(u.toString(), { headers: { Accept: "application/json" } });
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new Error(`Marketplace list ${res.status}`);
|
|
121
|
+
}
|
|
122
|
+
const body = (await res.json()) as { skills?: MarketplaceSkillDoc[] };
|
|
123
|
+
return body.skills ?? [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merge installed capabilities with discoverable marketplace capabilities
|
|
128
|
+
* (ids not already present locally). Soft-fails offline.
|
|
129
|
+
*/
|
|
130
|
+
export async function listCapabilitiesWithDiscoverable(
|
|
131
|
+
config: AgenticROSConfig,
|
|
132
|
+
opts: ListCapabilitiesOptions = {},
|
|
133
|
+
): Promise<ListedCapability[]> {
|
|
134
|
+
const installed = listAllCapabilities(config).map((c) => {
|
|
135
|
+
const listed: ListedCapability = {
|
|
136
|
+
...c,
|
|
137
|
+
installed: true,
|
|
138
|
+
discoverable: false,
|
|
139
|
+
};
|
|
140
|
+
return listed;
|
|
141
|
+
});
|
|
142
|
+
if (opts.includeDiscoverable === false) {
|
|
143
|
+
return installed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const installedIds = new Set(installed.map((c) => c.id));
|
|
147
|
+
try {
|
|
148
|
+
const skills = await fetchMarketplaceSkills({
|
|
149
|
+
apiBase: opts.apiBase,
|
|
150
|
+
limit: opts.marketplaceLimit ?? 50,
|
|
151
|
+
});
|
|
152
|
+
const extra: DiscoverableCapability[] = [];
|
|
153
|
+
const seenDiscoverable = new Set<string>();
|
|
154
|
+
for (const s of skills) {
|
|
155
|
+
if (s.visibility && s.visibility !== "public") continue;
|
|
156
|
+
const ref = installRefOf(s);
|
|
157
|
+
if (!ref) continue;
|
|
158
|
+
const caps = Array.isArray(s.capabilities) ? s.capabilities : [];
|
|
159
|
+
for (const raw of caps) {
|
|
160
|
+
if (!raw || typeof raw !== "object") continue;
|
|
161
|
+
const id = typeof (raw as { id?: unknown }).id === "string" ? (raw as { id: string }).id : null;
|
|
162
|
+
if (!id || installedIds.has(id) || seenDiscoverable.has(id)) continue;
|
|
163
|
+
const norm = normalizeMarketplaceCap(
|
|
164
|
+
raw as Record<string, unknown>,
|
|
165
|
+
ref,
|
|
166
|
+
s.slug ?? ref,
|
|
167
|
+
);
|
|
168
|
+
if (!norm) continue;
|
|
169
|
+
seenDiscoverable.add(id);
|
|
170
|
+
extra.push(norm);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return [...installed, ...extra];
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (opts.softFail === false) throw e;
|
|
176
|
+
return installed;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External ROS-node capability executor — Phase 1 completion.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches `implementation.kind: "external_ros_node"` capabilities via
|
|
5
|
+
* the existing RosTransport action / service / topic APIs. The manifest
|
|
6
|
+
* `launch` field is metadata only (operator-owned bringup) — the gateway
|
|
7
|
+
* never shells out to `ros2 launch` in v1.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Capability, CapabilityImplementation } from "./capabilities.js";
|
|
11
|
+
import type { RosTransport } from "./transport/transport.js";
|
|
12
|
+
import { toNamespacedTopic } from "./topic-utils.js";
|
|
13
|
+
|
|
14
|
+
export interface ExecuteExternalOptions {
|
|
15
|
+
/** Robot namespace for topic/action/service prefixing. */
|
|
16
|
+
namespace?: string;
|
|
17
|
+
/** Optional timeout for subscribe_once (ms). */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ExecuteExternalResult {
|
|
22
|
+
text: string;
|
|
23
|
+
outputs?: Record<string, unknown>;
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isExternalImpl(
|
|
28
|
+
impl: CapabilityImplementation | undefined,
|
|
29
|
+
): impl is Extract<CapabilityImplementation, { kind: "external_ros_node" }> {
|
|
30
|
+
return impl?.kind === "external_ros_node";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveName(name: string, namespace?: string): string {
|
|
34
|
+
const trimmed = name.trim();
|
|
35
|
+
if (!trimmed) return trimmed;
|
|
36
|
+
if (!namespace?.trim()) return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
37
|
+
return toNamespacedTopic(namespace, trimmed);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map navigate_to-style inputs into a Nav2 NavigateToPose goal when the
|
|
42
|
+
* action type looks like NavigateToPose and inputs have x/y.
|
|
43
|
+
*/
|
|
44
|
+
export function buildExternalGoal(
|
|
45
|
+
impl: Extract<CapabilityImplementation, { kind: "external_ros_node" }>,
|
|
46
|
+
inputs: Record<string, unknown>,
|
|
47
|
+
): Record<string, unknown> {
|
|
48
|
+
// Explicit goal / request / message wins.
|
|
49
|
+
if (inputs.goal && typeof inputs.goal === "object") {
|
|
50
|
+
return inputs.goal as Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
if (inputs.request && typeof inputs.request === "object") {
|
|
53
|
+
return inputs.request as Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
if (inputs.message && typeof inputs.message === "object") {
|
|
56
|
+
return inputs.message as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const msgType = (impl.msg_type ?? "").toLowerCase();
|
|
60
|
+
if (msgType.includes("navigatetopose") || impl.action?.includes("navigate_to_pose")) {
|
|
61
|
+
const x = Number(inputs.x ?? 0) || 0;
|
|
62
|
+
const y = Number(inputs.y ?? 0) || 0;
|
|
63
|
+
const yaw = Number(inputs.yaw ?? 0) || 0;
|
|
64
|
+
const qz = Math.sin(yaw / 2);
|
|
65
|
+
const qw = Math.cos(yaw / 2);
|
|
66
|
+
const frame = typeof inputs.frame_id === "string" ? inputs.frame_id : "map";
|
|
67
|
+
return {
|
|
68
|
+
pose: {
|
|
69
|
+
header: { frame_id: frame },
|
|
70
|
+
pose: {
|
|
71
|
+
position: { x, y, z: 0 },
|
|
72
|
+
orientation: { x: 0, y: 0, z: qz, w: qw },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Passthrough remaining inputs (minus robot_id).
|
|
79
|
+
const { robot_id: _rid, ...rest } = inputs;
|
|
80
|
+
return rest;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute an external_ros_node capability against a connected transport.
|
|
85
|
+
*/
|
|
86
|
+
export async function executeExternalCapability(
|
|
87
|
+
capability: Capability,
|
|
88
|
+
inputs: Record<string, unknown>,
|
|
89
|
+
transport: RosTransport,
|
|
90
|
+
options: ExecuteExternalOptions = {},
|
|
91
|
+
): Promise<ExecuteExternalResult> {
|
|
92
|
+
const impl = capability.implementation;
|
|
93
|
+
if (!isExternalImpl(impl)) {
|
|
94
|
+
return {
|
|
95
|
+
text: `Capability "${capability.id}" is not an external_ros_node implementation.`,
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ns = options.namespace;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (impl.action) {
|
|
104
|
+
const action = resolveName(impl.action, ns);
|
|
105
|
+
const actionType = impl.msg_type ?? "";
|
|
106
|
+
if (!actionType) {
|
|
107
|
+
return {
|
|
108
|
+
text: `Capability "${capability.id}" external action requires implementation.msg_type.`,
|
|
109
|
+
isError: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const args = buildExternalGoal(impl, inputs);
|
|
113
|
+
const result = await transport.sendActionGoal({
|
|
114
|
+
action,
|
|
115
|
+
actionType,
|
|
116
|
+
args,
|
|
117
|
+
});
|
|
118
|
+
const outputs = {
|
|
119
|
+
success: result.result,
|
|
120
|
+
...(result.values ?? {}),
|
|
121
|
+
launch_hint: impl.launch ?? null,
|
|
122
|
+
package_hint: impl.package ?? null,
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
text: JSON.stringify(outputs),
|
|
126
|
+
outputs,
|
|
127
|
+
isError: !result.result,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (impl.service) {
|
|
132
|
+
const service = resolveName(impl.service, ns);
|
|
133
|
+
const serviceType = impl.msg_type ?? "";
|
|
134
|
+
if (!serviceType) {
|
|
135
|
+
return {
|
|
136
|
+
text: `Capability "${capability.id}" external service requires implementation.msg_type.`,
|
|
137
|
+
isError: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const args = buildExternalGoal(impl, inputs);
|
|
141
|
+
const result = await transport.callService({
|
|
142
|
+
service,
|
|
143
|
+
type: serviceType,
|
|
144
|
+
args,
|
|
145
|
+
});
|
|
146
|
+
const outputs = {
|
|
147
|
+
success: result.result,
|
|
148
|
+
...(result.values ?? {}),
|
|
149
|
+
launch_hint: impl.launch ?? null,
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
text: JSON.stringify(outputs),
|
|
153
|
+
outputs,
|
|
154
|
+
isError: !result.result,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (impl.topic) {
|
|
159
|
+
const topicName =
|
|
160
|
+
typeof inputs.topic === "string" && inputs.topic.trim()
|
|
161
|
+
? inputs.topic.trim()
|
|
162
|
+
: impl.topic;
|
|
163
|
+
const topic = resolveName(topicName, ns);
|
|
164
|
+
const msgType = impl.msg_type ?? "";
|
|
165
|
+
// Detection / sensor skills default to subscribe_once; publishers must pass mode: "publish".
|
|
166
|
+
const mode =
|
|
167
|
+
typeof inputs.mode === "string"
|
|
168
|
+
? inputs.mode
|
|
169
|
+
: "subscribe";
|
|
170
|
+
if (mode === "subscribe" || mode === "subscribe_once") {
|
|
171
|
+
const timeout =
|
|
172
|
+
options.timeoutMs ??
|
|
173
|
+
(typeof inputs.timeout_ms === "number"
|
|
174
|
+
? inputs.timeout_ms
|
|
175
|
+
: typeof inputs.timeout === "number"
|
|
176
|
+
? inputs.timeout
|
|
177
|
+
: 5000);
|
|
178
|
+
const msg = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
179
|
+
let settled = false;
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
try {
|
|
184
|
+
sub.unsubscribe();
|
|
185
|
+
} catch {
|
|
186
|
+
/* ignore */
|
|
187
|
+
}
|
|
188
|
+
reject(new Error(`Timed out waiting for message on ${topic} after ${timeout}ms`));
|
|
189
|
+
}, timeout);
|
|
190
|
+
const sub = transport.subscribe(
|
|
191
|
+
{ topic, type: msgType || "std_msgs/msg/String" },
|
|
192
|
+
(message) => {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
try {
|
|
197
|
+
sub.unsubscribe();
|
|
198
|
+
} catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
resolve(message as Record<string, unknown>);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
const outputs = { message: msg, topic };
|
|
206
|
+
return { text: JSON.stringify(outputs), outputs };
|
|
207
|
+
}
|
|
208
|
+
if (!msgType) {
|
|
209
|
+
return {
|
|
210
|
+
text: `Capability "${capability.id}" external topic publish requires implementation.msg_type.`,
|
|
211
|
+
isError: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const message = buildExternalGoal(impl, inputs);
|
|
215
|
+
await transport.publish({ topic, type: msgType, msg: message });
|
|
216
|
+
const outputs = { published: true, topic, launch_hint: impl.launch ?? null };
|
|
217
|
+
return { text: JSON.stringify(outputs), outputs };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
text:
|
|
222
|
+
`Capability "${capability.id}" external_ros_node has no action, service, or topic. ` +
|
|
223
|
+
`Bringup hint: ${impl.launch ?? impl.package ?? "(none)"}.`,
|
|
224
|
+
isError: true,
|
|
225
|
+
};
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return {
|
|
228
|
+
text: err instanceof Error ? err.message : String(err),
|
|
229
|
+
isError: true,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static fleet file — Phase 1.d hybrid discovery.
|
|
3
|
+
*
|
|
4
|
+
* Precedence: `AGENTICROS_FLEET_PATH` → `~/.agenticros/fleet.json` →
|
|
5
|
+
* `config.robots[]` (via listRobots). When a fleet file exists and
|
|
6
|
+
* parses as a non-empty robot array (or `{ robots: [...] }`), it wins.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { AgenticROSConfig } from "./config.js";
|
|
13
|
+
import { AgenticROSConfigSchema } from "./config.js";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FLEET_FILENAME = "fleet.json";
|
|
16
|
+
|
|
17
|
+
/** Resolve the fleet file path (env override or default under ~/.agenticros). */
|
|
18
|
+
export function resolveFleetPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
19
|
+
const override = env.AGENTICROS_FLEET_PATH?.trim();
|
|
20
|
+
if (override) return override;
|
|
21
|
+
return join(homedir(), ".agenticros", DEFAULT_FLEET_FILENAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FleetFileResult {
|
|
25
|
+
/** Absolute path that was checked. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** True when the file exists and was used. */
|
|
28
|
+
used: boolean;
|
|
29
|
+
/** Parsed robot entries (empty when unused / invalid). */
|
|
30
|
+
robots: NonNullable<AgenticROSConfig["robots"]>;
|
|
31
|
+
/** Human-readable error when the file exists but failed to parse. */
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load `fleet.json` if present. Does not throw on missing file.
|
|
37
|
+
* Invalid JSON / schema → `used: false` with `error` set so callers can warn.
|
|
38
|
+
*/
|
|
39
|
+
export function loadFleetFile(path?: string): FleetFileResult {
|
|
40
|
+
const fleetPath = path ?? resolveFleetPath();
|
|
41
|
+
if (!existsSync(fleetPath)) {
|
|
42
|
+
return { path: fleetPath, used: false, robots: [] };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(readFileSync(fleetPath, "utf8")) as unknown;
|
|
46
|
+
let robotsRaw: unknown;
|
|
47
|
+
if (Array.isArray(raw)) {
|
|
48
|
+
robotsRaw = raw;
|
|
49
|
+
} else if (raw && typeof raw === "object" && Array.isArray((raw as { robots?: unknown }).robots)) {
|
|
50
|
+
robotsRaw = (raw as { robots: unknown[] }).robots;
|
|
51
|
+
} else {
|
|
52
|
+
return {
|
|
53
|
+
path: fleetPath,
|
|
54
|
+
used: false,
|
|
55
|
+
robots: [],
|
|
56
|
+
error: "fleet.json must be an array of robots or { robots: [...] }",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const parsed = AgenticROSConfigSchema.pick({ robots: true }).safeParse({ robots: robotsRaw });
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
return {
|
|
62
|
+
path: fleetPath,
|
|
63
|
+
used: false,
|
|
64
|
+
robots: [],
|
|
65
|
+
error: parsed.error.message,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const robots = parsed.data.robots ?? [];
|
|
69
|
+
if (robots.length === 0) {
|
|
70
|
+
return { path: fleetPath, used: false, robots: [] };
|
|
71
|
+
}
|
|
72
|
+
return { path: fleetPath, used: true, robots };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
path: fleetPath,
|
|
76
|
+
used: false,
|
|
77
|
+
robots: [],
|
|
78
|
+
error: err instanceof Error ? err.message : String(err),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Return a config view where `robots` comes from fleet.json when present.
|
|
85
|
+
* Does not mutate the input config object.
|
|
86
|
+
*/
|
|
87
|
+
export function applyFleetOverride(config: AgenticROSConfig, fleetPath?: string): AgenticROSConfig {
|
|
88
|
+
const fleet = loadFleetFile(fleetPath);
|
|
89
|
+
if (!fleet.used) return config;
|
|
90
|
+
return { ...config, robots: fleet.robots };
|
|
91
|
+
}
|