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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RobotInfo heartbeat helpers — Phase 1.d completion.
|
|
3
|
+
*
|
|
4
|
+
* `agenticros_discovery` publishes `<ns>/agenticros/robot_info` at 1 Hz.
|
|
5
|
+
* Consumers treat a robot as online when the last stamp is within
|
|
6
|
+
* {@link DEFAULT_HEARTBEAT_STALENESS_MS} (5 s per strategy memo).
|
|
7
|
+
*
|
|
8
|
+
* Topic-scan path: when adapters only have `listTopics()`, presence of
|
|
9
|
+
* `/<ns>/agenticros/robot_info` on the graph is a weak online signal
|
|
10
|
+
* (same as cmd_vel). Prefer {@link mergeRobotHeartbeats} when message
|
|
11
|
+
* payloads with stamps are available.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { effectiveCmdVelNamespace } from "./discovery.js";
|
|
15
|
+
import type { TopicInfo } from "./transport/types.js";
|
|
16
|
+
import type { RobotSensors } from "./robots.js";
|
|
17
|
+
|
|
18
|
+
/** Default staleness window — strategy §4(d): 1 Hz + 5 s. */
|
|
19
|
+
export const DEFAULT_HEARTBEAT_STALENESS_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
/** Parsed robot_info payload (subset of agenticros_msgs/RobotInfo). */
|
|
22
|
+
export interface RobotHeartbeat {
|
|
23
|
+
/** Stable id (falls back to namespace). */
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
/** ROS namespace without leading slash. */
|
|
28
|
+
robot_namespace: string;
|
|
29
|
+
capability_ids: string[];
|
|
30
|
+
sensors: RobotSensors;
|
|
31
|
+
/** Epoch ms of the heartbeat stamp (0 when unknown). */
|
|
32
|
+
stamp_ms: number;
|
|
33
|
+
/** Topic the heartbeat was observed on. */
|
|
34
|
+
topic: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HeartbeatOnlineOptions {
|
|
38
|
+
/** Wall clock now (ms). Defaults to Date.now(). */
|
|
39
|
+
nowMs?: number;
|
|
40
|
+
/** Staleness window in ms. Defaults to 5000. */
|
|
41
|
+
stalenessMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract namespace from `/<ns>/agenticros/robot_info` (or without leading slash).
|
|
46
|
+
* Returns "" for unnamespaced `/agenticros/robot_info`.
|
|
47
|
+
*/
|
|
48
|
+
export function namespaceFromRobotInfoTopic(topic: string): string {
|
|
49
|
+
const m = topic.match(/^\/?([^/]+)\/agenticros\/robot_info$/);
|
|
50
|
+
if (m) return m[1]!;
|
|
51
|
+
if (topic === "/agenticros/robot_info" || topic === "agenticros/robot_info") return "";
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when the topic name is a robot_info heartbeat topic. */
|
|
56
|
+
export function isRobotInfoTopic(topic: string): boolean {
|
|
57
|
+
return /(?:^|\/)agenticros\/robot_info$/.test(topic);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect namespaces that advertise `agenticros/robot_info` on the topic graph
|
|
62
|
+
* (no stamp available — treat as online for discovery fallback).
|
|
63
|
+
*/
|
|
64
|
+
export function detectHeartbeatNamespacesFromTopics(topics: TopicInfo[]): string[] {
|
|
65
|
+
const out = new Set<string>();
|
|
66
|
+
for (const t of topics) {
|
|
67
|
+
if (!isRobotInfoTopic(t.name)) continue;
|
|
68
|
+
out.add(namespaceFromRobotInfoTopic(t.name));
|
|
69
|
+
}
|
|
70
|
+
return [...out];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rosTimeToMs(stamp: unknown): number {
|
|
74
|
+
if (!stamp || typeof stamp !== "object") return 0;
|
|
75
|
+
const s = stamp as Record<string, unknown>;
|
|
76
|
+
const sec = Number(s.sec ?? s.secs ?? 0) || 0;
|
|
77
|
+
const nanosec = Number(s.nanosec ?? s.nsecs ?? 0) || 0;
|
|
78
|
+
return sec * 1000 + Math.floor(nanosec / 1e6);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse a plain RobotInfo-like message into {@link RobotHeartbeat}.
|
|
83
|
+
* Tolerates missing optional fields.
|
|
84
|
+
*/
|
|
85
|
+
export function parseRobotInfoMessage(
|
|
86
|
+
topic: string,
|
|
87
|
+
msg: Record<string, unknown>,
|
|
88
|
+
): RobotHeartbeat {
|
|
89
|
+
const nsFromTopic = namespaceFromRobotInfoTopic(topic);
|
|
90
|
+
const robot_namespace = String(msg.robot_namespace ?? msg.namespace ?? nsFromTopic ?? "");
|
|
91
|
+
const id = String(msg.id ?? (robot_namespace || "default"));
|
|
92
|
+
const capability_ids = Array.isArray(msg.capability_ids)
|
|
93
|
+
? msg.capability_ids.map((c) => String(c))
|
|
94
|
+
: Array.isArray(msg.capabilities)
|
|
95
|
+
? (msg.capabilities as unknown[]).map((c) => String(c))
|
|
96
|
+
: [];
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
name: String(msg.name ?? "Robot"),
|
|
100
|
+
kind: String(msg.kind ?? "amr"),
|
|
101
|
+
robot_namespace,
|
|
102
|
+
capability_ids,
|
|
103
|
+
sensors: {
|
|
104
|
+
has_realsense: Boolean(msg.has_realsense),
|
|
105
|
+
has_lidar: Boolean(msg.has_lidar),
|
|
106
|
+
has_arm: Boolean(msg.has_arm),
|
|
107
|
+
},
|
|
108
|
+
stamp_ms: rosTimeToMs(msg.stamp),
|
|
109
|
+
topic,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when the heartbeat stamp is within the staleness window. */
|
|
114
|
+
export function isHeartbeatFresh(
|
|
115
|
+
heartbeat: RobotHeartbeat,
|
|
116
|
+
options: HeartbeatOnlineOptions = {},
|
|
117
|
+
): boolean {
|
|
118
|
+
const now = options.nowMs ?? Date.now();
|
|
119
|
+
const window = options.stalenessMs ?? DEFAULT_HEARTBEAT_STALENESS_MS;
|
|
120
|
+
if (!heartbeat.stamp_ms) {
|
|
121
|
+
// No stamp — treat as fresh if we just received the message (caller
|
|
122
|
+
// should only pass live subscribe results here).
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return now - heartbeat.stamp_ms <= window;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Merge heartbeats into an online-id set keyed by configured robot id
|
|
130
|
+
* when `configuredIdByNamespace` is provided, else by heartbeat id /
|
|
131
|
+
* effective namespace.
|
|
132
|
+
*/
|
|
133
|
+
export function onlineIdsFromHeartbeats(
|
|
134
|
+
heartbeats: readonly RobotHeartbeat[],
|
|
135
|
+
options: HeartbeatOnlineOptions & {
|
|
136
|
+
/** Map effective cmd_vel namespace → configured robot id. */
|
|
137
|
+
configuredIdByNamespace?: ReadonlyMap<string, string>;
|
|
138
|
+
} = {},
|
|
139
|
+
): Set<string> {
|
|
140
|
+
const online = new Set<string>();
|
|
141
|
+
for (const hb of heartbeats) {
|
|
142
|
+
if (!isHeartbeatFresh(hb, options)) continue;
|
|
143
|
+
const eff = effectiveCmdVelNamespace(hb.robot_namespace);
|
|
144
|
+
const cfgId = options.configuredIdByNamespace?.get(eff);
|
|
145
|
+
online.add(cfgId ?? hb.id);
|
|
146
|
+
}
|
|
147
|
+
return online;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build a map of effective-namespace → latest fresh heartbeat.
|
|
152
|
+
*/
|
|
153
|
+
export function mergeRobotHeartbeats(
|
|
154
|
+
heartbeats: readonly RobotHeartbeat[],
|
|
155
|
+
options: HeartbeatOnlineOptions = {},
|
|
156
|
+
): Map<string, RobotHeartbeat> {
|
|
157
|
+
const byNs = new Map<string, RobotHeartbeat>();
|
|
158
|
+
for (const hb of heartbeats) {
|
|
159
|
+
if (!isHeartbeatFresh(hb, options)) continue;
|
|
160
|
+
const key = effectiveCmdVelNamespace(hb.robot_namespace);
|
|
161
|
+
const prev = byNs.get(key);
|
|
162
|
+
if (!prev || hb.stamp_ms >= prev.stamp_ms) {
|
|
163
|
+
byNs.set(key, hb);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return byNs;
|
|
167
|
+
}
|
|
@@ -6,6 +6,7 @@ export type { AgenticROSConfig, RobotTransportOverride } from "./config.js";
|
|
|
6
6
|
export {
|
|
7
7
|
AgenticROSConfigSchema,
|
|
8
8
|
parseConfig,
|
|
9
|
+
prepareConfigForPersistence,
|
|
9
10
|
getTransportConfig,
|
|
10
11
|
} from "./config.js";
|
|
11
12
|
|
|
@@ -65,6 +66,24 @@ export type {
|
|
|
65
66
|
CapabilitySource,
|
|
66
67
|
} from "./capabilities.js";
|
|
67
68
|
|
|
69
|
+
export {
|
|
70
|
+
CapabilitySchema,
|
|
71
|
+
CapabilityFieldSchema,
|
|
72
|
+
CapabilityImplementationSchema,
|
|
73
|
+
parseCapability,
|
|
74
|
+
safeParseCapability,
|
|
75
|
+
} from "./capability-schema.js";
|
|
76
|
+
export type { ParsedCapability } from "./capability-schema.js";
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
buildExternalGoal,
|
|
80
|
+
executeExternalCapability,
|
|
81
|
+
} from "./external-capability.js";
|
|
82
|
+
export type {
|
|
83
|
+
ExecuteExternalOptions,
|
|
84
|
+
ExecuteExternalResult,
|
|
85
|
+
} from "./external-capability.js";
|
|
86
|
+
|
|
68
87
|
export { runMission } from "./mission.js";
|
|
69
88
|
export type {
|
|
70
89
|
Mission,
|
|
@@ -75,11 +94,27 @@ export type {
|
|
|
75
94
|
CapabilityToolBinding,
|
|
76
95
|
CapabilityToolBindings,
|
|
77
96
|
MissionCancellationToken,
|
|
97
|
+
MissionControlToken,
|
|
78
98
|
MissionTranscriptEntry,
|
|
79
99
|
MissionTranscriptSink,
|
|
80
100
|
RunMissionOptions,
|
|
81
101
|
} from "./mission.js";
|
|
82
102
|
|
|
103
|
+
export {
|
|
104
|
+
BUILTIN_MISSION_BINDINGS,
|
|
105
|
+
EXTERNAL_TOOL_PREFIX,
|
|
106
|
+
buildMissionBindings,
|
|
107
|
+
capabilityIdFromExternalTool,
|
|
108
|
+
defaultToolForCapability,
|
|
109
|
+
externalToolName,
|
|
110
|
+
isExternalToolName,
|
|
111
|
+
passthroughBuildArgs,
|
|
112
|
+
} from "./mission-bindings.js";
|
|
113
|
+
export type {
|
|
114
|
+
BuildMissionBindingsOptions,
|
|
115
|
+
CapabilityWithTool,
|
|
116
|
+
} from "./mission-bindings.js";
|
|
117
|
+
|
|
83
118
|
export {
|
|
84
119
|
MissionRegistry,
|
|
85
120
|
generateMissionId,
|
|
@@ -109,6 +144,26 @@ export {
|
|
|
109
144
|
} from "./discovery.js";
|
|
110
145
|
export type { DetectedRobot, RobotDiscoveryResult } from "./discovery.js";
|
|
111
146
|
|
|
147
|
+
export {
|
|
148
|
+
DEFAULT_HEARTBEAT_STALENESS_MS,
|
|
149
|
+
detectHeartbeatNamespacesFromTopics,
|
|
150
|
+
isHeartbeatFresh,
|
|
151
|
+
isRobotInfoTopic,
|
|
152
|
+
mergeRobotHeartbeats,
|
|
153
|
+
namespaceFromRobotInfoTopic,
|
|
154
|
+
onlineIdsFromHeartbeats,
|
|
155
|
+
parseRobotInfoMessage,
|
|
156
|
+
} from "./heartbeat.js";
|
|
157
|
+
export type { HeartbeatOnlineOptions, RobotHeartbeat } from "./heartbeat.js";
|
|
158
|
+
|
|
159
|
+
export {
|
|
160
|
+
DEFAULT_FLEET_FILENAME,
|
|
161
|
+
applyFleetOverride,
|
|
162
|
+
loadFleetFile,
|
|
163
|
+
resolveFleetPath,
|
|
164
|
+
} from "./fleet-config.js";
|
|
165
|
+
export type { FleetFileResult } from "./fleet-config.js";
|
|
166
|
+
|
|
112
167
|
export { findRobotsFor } from "./find-robots-for.js";
|
|
113
168
|
export type {
|
|
114
169
|
FindRobotsForQuery,
|
|
@@ -116,5 +171,35 @@ export type {
|
|
|
116
171
|
FindRobotsForResult,
|
|
117
172
|
} from "./find-robots-for.js";
|
|
118
173
|
|
|
174
|
+
export {
|
|
175
|
+
DEFAULT_SKILLS_API,
|
|
176
|
+
DEFAULT_SKILLS_CACHE_DIR,
|
|
177
|
+
ensureSkillRefCached,
|
|
178
|
+
fetchInstallDescriptor,
|
|
179
|
+
githubRepoBasename,
|
|
180
|
+
parseSkillRef,
|
|
181
|
+
resolveSkillRefs,
|
|
182
|
+
skillsApiBase,
|
|
183
|
+
skillsCacheDir,
|
|
184
|
+
withResolvedSkillRefs,
|
|
185
|
+
applyCachedSkillRefs,
|
|
186
|
+
} from "./skill-refs.js";
|
|
187
|
+
export type {
|
|
188
|
+
InstallDescriptor,
|
|
189
|
+
ParsedSkillRef,
|
|
190
|
+
ResolveSkillRefsOptions,
|
|
191
|
+
ResolveSkillRefsResult,
|
|
192
|
+
} from "./skill-refs.js";
|
|
193
|
+
|
|
194
|
+
export {
|
|
195
|
+
fetchMarketplaceSkills,
|
|
196
|
+
listCapabilitiesWithDiscoverable,
|
|
197
|
+
} from "./discoverable-capabilities.js";
|
|
198
|
+
export type {
|
|
199
|
+
DiscoverableCapability,
|
|
200
|
+
ListCapabilitiesOptions,
|
|
201
|
+
ListedCapability,
|
|
202
|
+
} from "./discoverable-capabilities.js";
|
|
203
|
+
|
|
119
204
|
export { TransportPool, TRANSPORT_POOL_GLOBAL_KEY } from "./transport-pool.js";
|
|
120
205
|
export type { TransportFactory } from "./transport-pool.js";
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability → tool bindings for `run_mission`.
|
|
3
|
+
*
|
|
4
|
+
* Builtins live here once; adapters import `buildMissionBindings()` instead
|
|
5
|
+
* of triplicating MISSION_BINDINGS. Skill-declared capabilities get a
|
|
6
|
+
* default tool name (`ros2_<id>`, or `capability.tool` when set) and a
|
|
7
|
+
* passthrough `buildArgs` that forwards resolved inputs.
|
|
8
|
+
*
|
|
9
|
+
* External ROS-node capabilities map to the synthetic tool
|
|
10
|
+
* `external:<capability_id>` so adapters can dispatch via
|
|
11
|
+
* `executeExternalCapability` before looking up a real tool.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Capability } from "./capabilities.js";
|
|
15
|
+
import type { CapabilityToolBinding, CapabilityToolBindings } from "./mission.js";
|
|
16
|
+
|
|
17
|
+
/** Optional explicit tool name on a capability (skill authors may set this). */
|
|
18
|
+
export type CapabilityWithTool = Capability & { tool?: string };
|
|
19
|
+
|
|
20
|
+
function copyNumber(out: Record<string, unknown>, inputs: Record<string, unknown>, key: string): void {
|
|
21
|
+
if (typeof inputs[key] === "number") out[key] = inputs[key];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function copyString(out: Record<string, unknown>, inputs: Record<string, unknown>, key: string): void {
|
|
25
|
+
if (typeof inputs[key] === "string") out[key] = inputs[key];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function copyBoolean(out: Record<string, unknown>, inputs: Record<string, unknown>, key: string): void {
|
|
29
|
+
if (typeof inputs[key] === "boolean") out[key] = inputs[key];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Built-in capability → tool mappings shared by all adapters. */
|
|
33
|
+
export const BUILTIN_MISSION_BINDINGS: CapabilityToolBindings = {
|
|
34
|
+
drive_base: {
|
|
35
|
+
tool: "ros2_publish",
|
|
36
|
+
buildArgs: (inputs) => {
|
|
37
|
+
const lx = Number(inputs.linear_x ?? 0) || 0;
|
|
38
|
+
const az = Number(inputs.angular_z ?? 0) || 0;
|
|
39
|
+
return {
|
|
40
|
+
topic: "/cmd_vel",
|
|
41
|
+
type: "geometry_msgs/msg/Twist",
|
|
42
|
+
message: {
|
|
43
|
+
linear: { x: lx, y: 0, z: 0 },
|
|
44
|
+
angular: { x: 0, y: 0, z: az },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
take_snapshot: {
|
|
50
|
+
tool: "ros2_camera_snapshot",
|
|
51
|
+
buildArgs: (inputs) => {
|
|
52
|
+
const out: Record<string, unknown> = {};
|
|
53
|
+
copyString(out, inputs, "topic");
|
|
54
|
+
copyString(out, inputs, "message_type");
|
|
55
|
+
copyNumber(out, inputs, "timeout");
|
|
56
|
+
return out;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
measure_depth: {
|
|
60
|
+
tool: "ros2_depth_distance",
|
|
61
|
+
buildArgs: (inputs) => {
|
|
62
|
+
const out: Record<string, unknown> = {};
|
|
63
|
+
copyString(out, inputs, "topic");
|
|
64
|
+
copyNumber(out, inputs, "timeout");
|
|
65
|
+
return out;
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
list_topics: {
|
|
69
|
+
tool: "ros2_list_topics",
|
|
70
|
+
buildArgs: () => ({}),
|
|
71
|
+
},
|
|
72
|
+
publish_topic: {
|
|
73
|
+
tool: "ros2_publish",
|
|
74
|
+
buildArgs: (inputs) => ({
|
|
75
|
+
topic: String(inputs.topic ?? ""),
|
|
76
|
+
type: String(inputs.type ?? inputs.msg_type ?? ""),
|
|
77
|
+
message: inputs.message ?? inputs.msg ?? {},
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
subscribe_once: {
|
|
81
|
+
tool: "ros2_subscribe_once",
|
|
82
|
+
buildArgs: (inputs) => {
|
|
83
|
+
const out: Record<string, unknown> = { topic: String(inputs.topic ?? "") };
|
|
84
|
+
copyString(out, inputs, "type");
|
|
85
|
+
copyNumber(out, inputs, "timeout");
|
|
86
|
+
return out;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
follow_person: {
|
|
90
|
+
tool: "ros2_follow_me_start",
|
|
91
|
+
buildArgs: (inputs) => {
|
|
92
|
+
const out: Record<string, unknown> = {};
|
|
93
|
+
copyNumber(out, inputs, "target_distance");
|
|
94
|
+
copyString(out, inputs, "mode");
|
|
95
|
+
return out;
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
find_object: {
|
|
99
|
+
tool: "ros2_find_object",
|
|
100
|
+
buildArgs: (inputs) => {
|
|
101
|
+
const out: Record<string, unknown> = { target: String(inputs.target ?? "") };
|
|
102
|
+
copyNumber(out, inputs, "angular_speed");
|
|
103
|
+
copyBoolean(out, inputs, "clockwise");
|
|
104
|
+
copyNumber(out, inputs, "timeout_seconds");
|
|
105
|
+
copyNumber(out, inputs, "min_confidence");
|
|
106
|
+
return out;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Synthetic tool prefix for external_ros_node capabilities. */
|
|
112
|
+
export const EXTERNAL_TOOL_PREFIX = "external:";
|
|
113
|
+
|
|
114
|
+
export function externalToolName(capabilityId: string): string {
|
|
115
|
+
return `${EXTERNAL_TOOL_PREFIX}${capabilityId}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isExternalToolName(toolName: string): boolean {
|
|
119
|
+
return toolName.startsWith(EXTERNAL_TOOL_PREFIX);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function capabilityIdFromExternalTool(toolName: string): string {
|
|
123
|
+
return toolName.slice(EXTERNAL_TOOL_PREFIX.length);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Default tool name for a skill-declared capability.
|
|
128
|
+
* Prefer explicit `tool`, then external synthetic name, else `ros2_<id>`.
|
|
129
|
+
*/
|
|
130
|
+
export function defaultToolForCapability(cap: CapabilityWithTool): string {
|
|
131
|
+
if (typeof cap.tool === "string" && cap.tool.trim().length > 0) {
|
|
132
|
+
return cap.tool.trim();
|
|
133
|
+
}
|
|
134
|
+
if (cap.implementation?.kind === "external_ros_node") {
|
|
135
|
+
return externalToolName(cap.id);
|
|
136
|
+
}
|
|
137
|
+
return `ros2_${cap.id}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Passthrough: forward resolved inputs as tool args (skill tools). */
|
|
141
|
+
export function passthroughBuildArgs(inputs: Record<string, unknown>): Record<string, unknown> {
|
|
142
|
+
return { ...inputs };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface BuildMissionBindingsOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Override tool name resolution for a capability id.
|
|
148
|
+
* Useful when OpenClaw registers a skill tool under a non-default name.
|
|
149
|
+
*/
|
|
150
|
+
toolNameResolver?: (cap: Capability) => string | undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Extra bindings merged last (win over builtins and auto-derived).
|
|
153
|
+
*/
|
|
154
|
+
extra?: CapabilityToolBindings;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the full capability → tool map for a mission run.
|
|
159
|
+
*
|
|
160
|
+
* Order: builtins → auto-derived from non-builtin capabilities → extra.
|
|
161
|
+
*/
|
|
162
|
+
export function buildMissionBindings(
|
|
163
|
+
capabilities: readonly Capability[],
|
|
164
|
+
options: BuildMissionBindingsOptions = {},
|
|
165
|
+
): CapabilityToolBindings {
|
|
166
|
+
const out: CapabilityToolBindings = { ...BUILTIN_MISSION_BINDINGS };
|
|
167
|
+
|
|
168
|
+
for (const cap of capabilities) {
|
|
169
|
+
if (cap.source?.kind === "builtin") continue;
|
|
170
|
+
// Skip if already covered by builtins (e.g. follow_person from a skill
|
|
171
|
+
// that duplicates the builtin id — keep the richer builtin binding).
|
|
172
|
+
if (out[cap.id] && BUILTIN_MISSION_BINDINGS[cap.id]) continue;
|
|
173
|
+
|
|
174
|
+
const resolved =
|
|
175
|
+
options.toolNameResolver?.(cap) ?? defaultToolForCapability(cap as CapabilityWithTool);
|
|
176
|
+
|
|
177
|
+
const binding: CapabilityToolBinding = {
|
|
178
|
+
tool: resolved,
|
|
179
|
+
buildArgs: passthroughBuildArgs,
|
|
180
|
+
};
|
|
181
|
+
out[cap.id] = binding;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (options.extra) {
|
|
185
|
+
Object.assign(out, options.extra);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
@@ -35,7 +35,7 @@ export interface MissionRegistryEntry {
|
|
|
35
35
|
name?: string;
|
|
36
36
|
/** ms since epoch when the mission was registered. */
|
|
37
37
|
started_at: number;
|
|
38
|
-
/** The token the mission runner reads each step. */
|
|
38
|
+
/** The token the mission runner reads each step (cancel + pause). */
|
|
39
39
|
cancellation: MissionCancellationToken;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -45,7 +45,7 @@ export interface MissionRegistryEntry {
|
|
|
45
45
|
* Uses `crypto.randomUUID()` when available (Node 14.17+, Bun, modern
|
|
46
46
|
* browsers); falls back to a Math.random hex string with a `mns_` prefix.
|
|
47
47
|
* Either way the id is opaque to downstream consumers — they just echo
|
|
48
|
-
* it back to `mission_cancel` / `memory_recall`.
|
|
48
|
+
* it back to `mission_cancel` / `mission_pause` / `memory_recall`.
|
|
49
49
|
*/
|
|
50
50
|
export function generateMissionId(): string {
|
|
51
51
|
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
@@ -86,7 +86,7 @@ export class MissionRegistry {
|
|
|
86
86
|
mission_id: missionId,
|
|
87
87
|
name: opts?.name,
|
|
88
88
|
started_at: Date.now(),
|
|
89
|
-
cancellation: { cancelled: false },
|
|
89
|
+
cancellation: { cancelled: false, paused: false },
|
|
90
90
|
};
|
|
91
91
|
this.entries.set(missionId, entry);
|
|
92
92
|
return {
|
|
@@ -112,10 +112,38 @@ export class MissionRegistry {
|
|
|
112
112
|
if (!entry) return { found: false, alreadyCancelled: false };
|
|
113
113
|
const alreadyCancelled = entry.cancellation.cancelled === true;
|
|
114
114
|
entry.cancellation.cancelled = true;
|
|
115
|
+
entry.cancellation.paused = false;
|
|
115
116
|
if (reason !== undefined) entry.cancellation.reason = reason;
|
|
116
117
|
return { found: true, alreadyCancelled };
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Pause the named mission at the next step boundary.
|
|
122
|
+
* Idempotent — pausing an already-paused mission is a no-op.
|
|
123
|
+
*/
|
|
124
|
+
pause(missionId: string, reason?: string): { found: boolean; alreadyPaused: boolean } {
|
|
125
|
+
const entry = this.entries.get(missionId);
|
|
126
|
+
if (!entry) return { found: false, alreadyPaused: false };
|
|
127
|
+
if (entry.cancellation.cancelled) {
|
|
128
|
+
return { found: true, alreadyPaused: entry.cancellation.paused === true };
|
|
129
|
+
}
|
|
130
|
+
const alreadyPaused = entry.cancellation.paused === true;
|
|
131
|
+
entry.cancellation.paused = true;
|
|
132
|
+
if (reason !== undefined) entry.cancellation.reason = reason;
|
|
133
|
+
return { found: true, alreadyPaused };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resume a paused mission. Idempotent when not paused.
|
|
138
|
+
*/
|
|
139
|
+
resume(missionId: string): { found: boolean; wasPaused: boolean } {
|
|
140
|
+
const entry = this.entries.get(missionId);
|
|
141
|
+
if (!entry) return { found: false, wasPaused: false };
|
|
142
|
+
const wasPaused = entry.cancellation.paused === true;
|
|
143
|
+
entry.cancellation.paused = false;
|
|
144
|
+
return { found: true, wasPaused };
|
|
145
|
+
}
|
|
146
|
+
|
|
119
147
|
/** True when the named mission is currently registered. */
|
|
120
148
|
has(missionId: string): boolean {
|
|
121
149
|
return this.entries.has(missionId);
|
|
@@ -45,29 +45,40 @@
|
|
|
45
45
|
*
|
|
46
46
|
* What's still deferred:
|
|
47
47
|
* - Parallel step execution (today: sequential only).
|
|
48
|
-
* - Per-tool cancellation (cancel TIES to step boundaries today
|
|
49
|
-
*
|
|
48
|
+
* - Per-tool cancellation (cancel TIES to step boundaries today;
|
|
49
|
+
* pause/resume also ties to step boundaries).
|
|
50
50
|
* - Retry / backoff policies.
|
|
51
51
|
*
|
|
52
|
+
* Shipped since the original Phase 1.f header:
|
|
53
|
+
* - Natural-language plan compilation (`compileGoalToMission` + goal arg).
|
|
54
|
+
* - Pause / resume via the same control token (`paused` flag).
|
|
55
|
+
*
|
|
52
56
|
* See: docs/strategy-ai-agents-plus-ros.md §4 (Phase 1.c / 1.f).
|
|
53
57
|
*/
|
|
54
58
|
|
|
55
59
|
import type { Capability } from "./capabilities.js";
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
|
-
* Cancellation token consumed by `runMission`.
|
|
62
|
+
* Cancellation / pause token consumed by `runMission`.
|
|
59
63
|
*
|
|
60
64
|
* Plain object (not AbortController) so it's easy to share across
|
|
61
65
|
* processes via a registry without pulling Web platform shims in. The
|
|
62
|
-
* runner
|
|
63
|
-
* for traceability.
|
|
66
|
+
* runner reads `cancelled` and `paused` at each step boundary.
|
|
64
67
|
*/
|
|
65
68
|
export interface MissionCancellationToken {
|
|
66
69
|
cancelled: boolean;
|
|
67
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* When true, the runner waits at the next step boundary until
|
|
72
|
+
* `paused` is cleared or `cancelled` is set. Phase 1 pause/resume.
|
|
73
|
+
*/
|
|
74
|
+
paused?: boolean;
|
|
75
|
+
/** Optional free-text reason — bubbled up into cancelled / paused results. */
|
|
68
76
|
reason?: string;
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
/** Alias — control token is the same object as the cancellation token. */
|
|
80
|
+
export type MissionControlToken = MissionCancellationToken;
|
|
81
|
+
|
|
71
82
|
/**
|
|
72
83
|
* Per-step transcript sink. Called immediately after a step finishes
|
|
73
84
|
* (including cancelled / skipped steps) so an external store sees the
|
|
@@ -154,8 +165,11 @@ export interface MissionStepResult {
|
|
|
154
165
|
* - "error": tool returned an error or the binding/build threw.
|
|
155
166
|
* - "skipped": earlier step failed with on_fail=stop.
|
|
156
167
|
* - "cancelled": mission was cancelled (Phase 1.f) before this step ran.
|
|
168
|
+
* - "paused": transcript-only marker emitted when the runner entered
|
|
169
|
+
* a pause wait before this step (not a final step outcome
|
|
170
|
+
* in `MissionResult.steps` — those use ok/error/skipped/cancelled).
|
|
157
171
|
*/
|
|
158
|
-
status: "ok" | "error" | "skipped" | "cancelled";
|
|
172
|
+
status: "ok" | "error" | "skipped" | "cancelled" | "paused";
|
|
159
173
|
/** Resolved inputs (with `{{...}}` templates substituted). */
|
|
160
174
|
inputs: Record<string, unknown>;
|
|
161
175
|
/** Outputs parsed from the tool response (may be `undefined` for fire-and-forget). */
|
|
@@ -253,6 +267,31 @@ export type CapabilityToolBindings = Record<string, CapabilityToolBinding>;
|
|
|
253
267
|
|
|
254
268
|
const TEMPLATE_RE = /\{\{\s*([a-zA-Z0-9_]+)\.outputs\.([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
255
269
|
|
|
270
|
+
function sleep(ms: number): Promise<void> {
|
|
271
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const PAUSE_POLL_MS = 100;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Wait while `token.paused` is true. Returns when resumed or cancelled.
|
|
278
|
+
* Emits one transcript entry with status "paused" the first time we enter
|
|
279
|
+
* the wait (so a second agent can see the mission is held).
|
|
280
|
+
*/
|
|
281
|
+
async function waitWhilePaused(
|
|
282
|
+
token: MissionCancellationToken | undefined,
|
|
283
|
+
emitPaused: () => void,
|
|
284
|
+
): Promise<"resumed" | "cancelled"> {
|
|
285
|
+
if (!token?.paused || token.cancelled) {
|
|
286
|
+
return token?.cancelled ? "cancelled" : "resumed";
|
|
287
|
+
}
|
|
288
|
+
emitPaused();
|
|
289
|
+
while (token.paused && !token.cancelled) {
|
|
290
|
+
await sleep(PAUSE_POLL_MS);
|
|
291
|
+
}
|
|
292
|
+
return token.cancelled ? "cancelled" : "resumed";
|
|
293
|
+
}
|
|
294
|
+
|
|
256
295
|
/**
|
|
257
296
|
* Substitute `{{stepId.outputs.field}}` references in `value` using the
|
|
258
297
|
* given step output map. Recurses into nested objects/arrays.
|
|
@@ -404,6 +443,24 @@ export async function runMission(
|
|
|
404
443
|
cancelled = true;
|
|
405
444
|
}
|
|
406
445
|
|
|
446
|
+
// Pause: wait at the step boundary until resume or cancel.
|
|
447
|
+
if (!cancelled && options?.cancellation?.paused) {
|
|
448
|
+
const pauseOutcome = await waitWhilePaused(options.cancellation, () => {
|
|
449
|
+
const pausedMarker: MissionStepResult = {
|
|
450
|
+
id: step.id,
|
|
451
|
+
capability: step.capability,
|
|
452
|
+
status: "paused",
|
|
453
|
+
inputs: {},
|
|
454
|
+
message: `Paused: ${options.cancellation?.reason ?? "paused"}`,
|
|
455
|
+
duration_ms: 0,
|
|
456
|
+
};
|
|
457
|
+
emitTranscript(idx, t0, pausedMarker);
|
|
458
|
+
});
|
|
459
|
+
if (pauseOutcome === "cancelled" || options.cancellation?.cancelled) {
|
|
460
|
+
cancelled = true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
407
464
|
if (cancelled) {
|
|
408
465
|
const result: MissionStepResult = {
|
|
409
466
|
id: step.id,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type { AgenticROSConfig } from "./config.js";
|
|
26
26
|
import { getTransportConfig } from "./config.js";
|
|
27
27
|
import type { TransportConfig } from "./transport/types.js";
|
|
28
|
+
import { applyFleetOverride } from "./fleet-config.js";
|
|
28
29
|
|
|
29
30
|
/** Sensor/hardware tags on a robot (Phase 1.e). */
|
|
30
31
|
export interface RobotSensors {
|
|
@@ -85,7 +86,10 @@ const DEFAULT_SENSORS: RobotSensors = {
|
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
export function listRobots(config: AgenticROSConfig): ResolvedRobot[] {
|
|
88
|
-
|
|
89
|
+
// Phase 1.d — ~/.agenticros/fleet.json (or AGENTICROS_FLEET_PATH) wins
|
|
90
|
+
// over config.robots when present and non-empty.
|
|
91
|
+
const effective = applyFleetOverride(config);
|
|
92
|
+
const explicit = Array.isArray(effective.robots) ? effective.robots : [];
|
|
89
93
|
if (explicit.length > 0) {
|
|
90
94
|
return explicit.map((r) => ({
|
|
91
95
|
id: String(r.id),
|
|
@@ -107,7 +111,7 @@ export function listRobots(config: AgenticROSConfig): ResolvedRobot[] {
|
|
|
107
111
|
// ros2_find_robots_for). Users on multi-robot deployments will have
|
|
108
112
|
// promoted into config.robots[] anyway, where the fields are
|
|
109
113
|
// explicit.
|
|
110
|
-
const legacy =
|
|
114
|
+
const legacy = effective.robot ?? { name: "Robot", namespace: "", cameraTopic: "" };
|
|
111
115
|
const id = (legacy.namespace?.trim() || "default");
|
|
112
116
|
return [
|
|
113
117
|
{
|