agenticros 0.1.17 → 0.1.19
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/dist/__tests__/robot-config.test.d.ts +2 -0
- package/dist/__tests__/robot-config.test.d.ts.map +1 -0
- package/dist/__tests__/robot-config.test.js +374 -0
- package/dist/__tests__/robot-config.test.js.map +1 -0
- package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
- package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
- package/dist/__tests__/transport-shorthand.test.js +88 -0
- package/dist/__tests__/transport-shorthand.test.js.map +1 -0
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +55 -3
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/robots.d.ts +57 -0
- package/dist/commands/robots.d.ts.map +1 -0
- package/dist/commands/robots.js +678 -0
- package/dist/commands/robots.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mcp-discovery.d.ts +57 -0
- package/dist/util/mcp-discovery.d.ts.map +1 -0
- package/dist/util/mcp-discovery.js +153 -0
- package/dist/util/mcp-discovery.js.map +1 -0
- package/dist/util/robot-config.d.ts +167 -0
- package/dist/util/robot-config.d.ts.map +1 -0
- package/dist/util/robot-config.js +375 -0
- package/dist/util/robot-config.js.map +1 -0
- package/dist/util/transport-shorthand.d.ts +66 -0
- package/dist/util/transport-shorthand.d.ts.map +1 -0
- package/dist/util/transport-shorthand.js +102 -0
- package/dist/util/transport-shorthand.js.map +1 -0
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -0
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
- package/runtime/packages/agenticros/package.json +2 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
- package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
- package/runtime/packages/agenticros/src/service.ts +120 -2
- package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
- package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
- package/runtime/packages/agenticros/src/tools/index.ts +49 -9
- package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
- package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
- package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
- package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
- package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
- package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
- package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
- package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
- package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
- package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
- package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
- package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
- package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
- package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
- package/runtime/packages/agenticros-gemini/package.json +1 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
- package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
- package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
- package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
- package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
- package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
- package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
- package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
- package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
- package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
- package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
- package/runtime/packages/core/src/capabilities.ts +340 -0
- package/runtime/packages/core/src/config.ts +220 -38
- package/runtime/packages/core/src/discovery.ts +193 -0
- package/runtime/packages/core/src/find-robots-for.ts +164 -0
- package/runtime/packages/core/src/index.ts +67 -1
- package/runtime/packages/core/src/mission-registry.ts +142 -0
- package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
- package/runtime/packages/core/src/mission.ts +574 -0
- package/runtime/packages/core/src/planner/index.ts +564 -0
- package/runtime/packages/core/src/robots.ts +282 -0
- package/runtime/packages/core/src/topic-utils.ts +47 -9
- package/runtime/packages/core/src/transport-pool.ts +171 -0
- package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
- package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
- package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
- package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- package/runtime/scripts/start_demo.sh +26 -3
- package/runtime/scripts/sync-skill-tools.mjs +67 -10
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-robot discovery — Phase 1.d "fleet awareness on the wire".
|
|
3
|
+
*
|
|
4
|
+
* Configuration tells the gateway what robots the user wants to talk to.
|
|
5
|
+
* The transport tells the gateway what's actually on the wire right now.
|
|
6
|
+
* Discovery is the bridge: scan published ROS2 topics, infer robot
|
|
7
|
+
* namespaces from `<ns>/cmd_vel` patterns, cross-reference against the
|
|
8
|
+
* configured robot list, and surface three categories:
|
|
9
|
+
*
|
|
10
|
+
* 1. configured_online — in config AND seen on the wire
|
|
11
|
+
* 2. configured_offline — in config but NOT seen on the wire
|
|
12
|
+
* 3. unknown_detected — on the wire but NOT in config
|
|
13
|
+
*
|
|
14
|
+
* The agent uses this to answer questions like "which robots can I talk
|
|
15
|
+
* to right now?" without forcing the user to hand-edit
|
|
16
|
+
* `~/.agenticros/config.json` every time a robot joins the network.
|
|
17
|
+
*
|
|
18
|
+
* Persistence is intentionally OUT of scope here: discovery is read-only.
|
|
19
|
+
* Writing newly-detected robots into the user's config is a separate
|
|
20
|
+
* UX decision handled by the CLI / OpenClaw config UI on top of this
|
|
21
|
+
* function.
|
|
22
|
+
*
|
|
23
|
+
* ## Detection signal
|
|
24
|
+
*
|
|
25
|
+
* `<ns>/cmd_vel` (the velocity command topic) is the canonical signal —
|
|
26
|
+
* a robot that can be driven publishes/subscribes there. Other topics
|
|
27
|
+
* like `<ns>/odom`, `<ns>/joint_states`, and `<ns>/camera/...` corroborate
|
|
28
|
+
* the same `<ns>`; we surface them as `topicCount` so consumers can
|
|
29
|
+
* gauge how "alive" the namespace looks.
|
|
30
|
+
*
|
|
31
|
+
* The unnamespaced `/cmd_vel` topic (default namespace, common in
|
|
32
|
+
* simulation) is detected as id="" — consumers can render it as
|
|
33
|
+
* "default robot" or similar.
|
|
34
|
+
*
|
|
35
|
+
* ## Namespace rewrite
|
|
36
|
+
*
|
|
37
|
+
* `ros2_publish` rewrites a UUID-shaped namespace like
|
|
38
|
+
* `3946b404-c33e-...` into `robot3946b404c33e...` (strip dashes,
|
|
39
|
+
* prefix with "robot") because most robot firmware expects the
|
|
40
|
+
* dashless form. Discovery applies the same rewrite when matching
|
|
41
|
+
* configured robots against detected namespaces, so a UUID-style
|
|
42
|
+
* config entry still matches the `/robot<no-dashes>/cmd_vel` topic
|
|
43
|
+
* the robot is actually publishing on.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { AgenticROSConfig } from "./config.js";
|
|
47
|
+
import type { ResolvedRobot } from "./robots.js";
|
|
48
|
+
import { listRobots } from "./robots.js";
|
|
49
|
+
import type { TopicInfo } from "./transport/types.js";
|
|
50
|
+
|
|
51
|
+
/** One robot inferred from the topic graph. */
|
|
52
|
+
export interface DetectedRobot {
|
|
53
|
+
/** The namespace segment, e.g. "robot3946b...". Empty string = default namespace. */
|
|
54
|
+
id: string;
|
|
55
|
+
/** The full cmd_vel topic name we saw — e.g. "/robot3946b.../cmd_vel". */
|
|
56
|
+
cmdVelTopic: string;
|
|
57
|
+
/**
|
|
58
|
+
* Total topics under `/<id>/`, INCLUDING the cmd_vel topic itself.
|
|
59
|
+
* A topicCount of 1 means the robot is "barely there" (just cmd_vel,
|
|
60
|
+
* no odom / joint_states / camera etc.); higher counts corroborate
|
|
61
|
+
* that the namespace is actually a live robot, not a stale advert.
|
|
62
|
+
* Always 0 for the unnamespaced default robot (id="").
|
|
63
|
+
*/
|
|
64
|
+
topicCount: number;
|
|
65
|
+
/**
|
|
66
|
+
* The matching `robot.id` from `config.robots` (or the legacy fallback)
|
|
67
|
+
* when this detected namespace maps to a configured robot, otherwise
|
|
68
|
+
* `null`. Matching uses the same UUID→robot-no-dashes rewrite as the
|
|
69
|
+
* publish path.
|
|
70
|
+
*/
|
|
71
|
+
configuredRobotId: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Result of a discovery pass. */
|
|
75
|
+
export interface RobotDiscoveryResult {
|
|
76
|
+
/** Every robot inferred from the topic graph, with config match annotations. */
|
|
77
|
+
detected: DetectedRobot[];
|
|
78
|
+
/** Configured robots that ARE present on the wire right now. */
|
|
79
|
+
configured_online: ResolvedRobot[];
|
|
80
|
+
/** Configured robots that are NOT present on the wire right now. */
|
|
81
|
+
configured_offline: ResolvedRobot[];
|
|
82
|
+
/** Detected robots that have no matching config entry — candidates to add. */
|
|
83
|
+
unknown_detected: DetectedRobot[];
|
|
84
|
+
/** Total topics scanned (echoed for diagnostics). */
|
|
85
|
+
total_topics: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply the same UUID→robot-no-dashes rewrite as `ros2_publish` uses
|
|
90
|
+
* when normalising a `/<ns>/cmd_vel` target.
|
|
91
|
+
*
|
|
92
|
+
* `""` stays `""` (default namespace). Namespaces that already start
|
|
93
|
+
* with "robot" are returned unchanged.
|
|
94
|
+
*/
|
|
95
|
+
export function effectiveCmdVelNamespace(robotNamespace: string): string {
|
|
96
|
+
const ns = robotNamespace.trim();
|
|
97
|
+
if (ns === "") return "";
|
|
98
|
+
if (ns.toLowerCase().startsWith("robot")) return ns;
|
|
99
|
+
return `robot${ns.replace(/-/g, "")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find every robot-shaped namespace in a topic list.
|
|
104
|
+
*
|
|
105
|
+
* Detection rule: any `/<seg>/cmd_vel` topic implies a robot with
|
|
106
|
+
* namespace `<seg>`. The unnamespaced `/cmd_vel` topic registers the
|
|
107
|
+
* empty namespace (id=""). Topic count under each namespace is
|
|
108
|
+
* accumulated so consumers can rank by liveness.
|
|
109
|
+
*
|
|
110
|
+
* The returned `DetectedRobot.configuredRobotId` is always `null` here —
|
|
111
|
+
* call `discoverRobots()` (which wraps this function) to annotate with
|
|
112
|
+
* config matches.
|
|
113
|
+
*/
|
|
114
|
+
export function detectRobotsFromTopics(topics: TopicInfo[]): DetectedRobot[] {
|
|
115
|
+
const byNs = new Map<string, { cmdVelTopic: string; topicCount: number }>();
|
|
116
|
+
|
|
117
|
+
for (const t of topics) {
|
|
118
|
+
// Match /<seg>/cmd_vel exactly — sub-namespaces would imply a more
|
|
119
|
+
// complex robot graph than this v1 wants to handle.
|
|
120
|
+
const m = t.name.match(/^\/([^/]+)\/cmd_vel$/);
|
|
121
|
+
if (m) {
|
|
122
|
+
const ns = m[1]!;
|
|
123
|
+
if (!byNs.has(ns)) byNs.set(ns, { cmdVelTopic: t.name, topicCount: 0 });
|
|
124
|
+
} else if (t.name === "/cmd_vel" && !byNs.has("")) {
|
|
125
|
+
byNs.set("", { cmdVelTopic: "/cmd_vel", topicCount: 0 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Count topics under each detected namespace. Empty-namespace robots
|
|
130
|
+
// (default) intentionally don't accumulate counts — every topic in
|
|
131
|
+
// the system would otherwise "belong" to them, which is misleading.
|
|
132
|
+
for (const t of topics) {
|
|
133
|
+
for (const [ns, info] of byNs) {
|
|
134
|
+
if (ns === "") continue;
|
|
135
|
+
if (t.name.startsWith(`/${ns}/`)) {
|
|
136
|
+
info.topicCount += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Array.from(byNs.entries()).map(([id, info]) => ({
|
|
142
|
+
id,
|
|
143
|
+
cmdVelTopic: info.cmdVelTopic,
|
|
144
|
+
topicCount: info.topicCount,
|
|
145
|
+
configuredRobotId: null,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Top-level discovery: detect robots on the wire, match against the
|
|
151
|
+
* configured robot list, and classify into online / offline / unknown.
|
|
152
|
+
*
|
|
153
|
+
* Pure function — feed it the topic list and the config, get a result
|
|
154
|
+
* back. No transport access here; adapters wrap this with a call to
|
|
155
|
+
* `transport.listTopics()`.
|
|
156
|
+
*/
|
|
157
|
+
export function discoverRobots(
|
|
158
|
+
topics: TopicInfo[],
|
|
159
|
+
config: AgenticROSConfig,
|
|
160
|
+
): RobotDiscoveryResult {
|
|
161
|
+
const detected = detectRobotsFromTopics(topics);
|
|
162
|
+
const configured = listRobots(config);
|
|
163
|
+
|
|
164
|
+
// Map effective-namespace → configured robot for O(1) lookup.
|
|
165
|
+
const effToCfg = new Map<string, ResolvedRobot>();
|
|
166
|
+
for (const r of configured) {
|
|
167
|
+
effToCfg.set(effectiveCmdVelNamespace(r.namespace), r);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const annotated = detected.map((d) => {
|
|
171
|
+
const cfg = effToCfg.get(d.id);
|
|
172
|
+
return cfg
|
|
173
|
+
? { ...d, configuredRobotId: cfg.id }
|
|
174
|
+
: { ...d, configuredRobotId: null };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const detectedIds = new Set(annotated.map((d) => d.id));
|
|
178
|
+
const configured_online = configured.filter((r) =>
|
|
179
|
+
detectedIds.has(effectiveCmdVelNamespace(r.namespace)),
|
|
180
|
+
);
|
|
181
|
+
const configured_offline = configured.filter(
|
|
182
|
+
(r) => !detectedIds.has(effectiveCmdVelNamespace(r.namespace)),
|
|
183
|
+
);
|
|
184
|
+
const unknown_detected = annotated.filter((d) => d.configuredRobotId === null);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
detected: annotated,
|
|
188
|
+
configured_online,
|
|
189
|
+
configured_offline,
|
|
190
|
+
unknown_detected,
|
|
191
|
+
total_topics: topics.length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `findRobotsFor` — Phase 1.e capability-aware robot filter.
|
|
3
|
+
*
|
|
4
|
+
* Given a (capability?, kind?, online?) query, returns the configured
|
|
5
|
+
* robots that match, ranked best-first. This is the resolver behind the
|
|
6
|
+
* `ros2_find_robots_for` MCP tool — adapters call it from `tools.ts`
|
|
7
|
+
* with the live online-set computed from a topic scan (or `undefined`
|
|
8
|
+
* when `online` isn't being filtered, so we don't pay for the scan).
|
|
9
|
+
*
|
|
10
|
+
* Filter semantics:
|
|
11
|
+
* - `capability` — keep robots whose per-robot allowlist
|
|
12
|
+
* (`config.robots[i].capabilities`) includes the verb, OR (when no
|
|
13
|
+
* allowlist is set) whose gateway's global capability registry
|
|
14
|
+
* includes it. The global path is the common case — every robot
|
|
15
|
+
* today exposes the same builtin + skill-declared capabilities. The
|
|
16
|
+
* per-robot allowlist exists for heterogeneous fleets where (e.g.)
|
|
17
|
+
* only one robot has the `arm_skill` loaded.
|
|
18
|
+
* - `kind` — exact match on `robot.kind` (case-insensitive). Use
|
|
19
|
+
* "amr" / "arm" / "drone" / "rover" by convention but any string is
|
|
20
|
+
* accepted by the schema.
|
|
21
|
+
* - `online` — `true` keeps only robots whose id is in `onlineIds`;
|
|
22
|
+
* `false` keeps only robots NOT in `onlineIds`; `undefined` skips
|
|
23
|
+
* this filter entirely. The adapter is responsible for populating
|
|
24
|
+
* `onlineIds` from the live topic graph (typically via
|
|
25
|
+
* `discoverRobots()`) before calling this — core doesn't touch the
|
|
26
|
+
* transport.
|
|
27
|
+
*
|
|
28
|
+
* Ranking (highest score first):
|
|
29
|
+
* +2 online (when query.online is true)
|
|
30
|
+
* +1 per-robot capabilities allowlist included the requested verb
|
|
31
|
+
* (an explicit "I support this" beats a global-registry inference)
|
|
32
|
+
* +0 default tier
|
|
33
|
+
*
|
|
34
|
+
* Ties broken by config declaration order (stable).
|
|
35
|
+
*
|
|
36
|
+
* Why not a full LLM-style scoring function? Phase 1.e's goal is
|
|
37
|
+
* deterministic and explainable — an agent calling `find_robots_for`
|
|
38
|
+
* wants a list it can plan against, not a relevance heuristic. The
|
|
39
|
+
* +1/+2 deltas are enough to surface a perfect match above an
|
|
40
|
+
* inherited one without inventing fuzzy matching.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { AgenticROSConfig } from "./config.js";
|
|
44
|
+
import { listAllCapabilities, type Capability } from "./capabilities.js";
|
|
45
|
+
import { listRobots, type ResolvedRobot } from "./robots.js";
|
|
46
|
+
|
|
47
|
+
/** Query input to `findRobotsFor`. All fields optional → returns every robot. */
|
|
48
|
+
export interface FindRobotsForQuery {
|
|
49
|
+
/** Capability id (e.g. `follow_person`, `find_object`). Case-sensitive — match the registry. */
|
|
50
|
+
capability?: string;
|
|
51
|
+
/** Robot kind (e.g. `amr`, `arm`, `drone`). Case-insensitive. */
|
|
52
|
+
kind?: string;
|
|
53
|
+
/** When set, restrict to online (true) or offline (false) robots. */
|
|
54
|
+
online?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One entry in the find-robots-for result. */
|
|
58
|
+
export interface FindRobotsForMatch {
|
|
59
|
+
robot: ResolvedRobot;
|
|
60
|
+
/** True when the capability filter was satisfied by an explicit per-robot allowlist. */
|
|
61
|
+
matched_capability_explicitly: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* True when this robot is in the caller-supplied online set, false when
|
|
64
|
+
* not, and `null` when the caller didn't provide an online set at all
|
|
65
|
+
* (so we can't say either way).
|
|
66
|
+
*/
|
|
67
|
+
online: boolean | null;
|
|
68
|
+
/** Sort key — higher = better match. See module header for the formula. */
|
|
69
|
+
score: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Result envelope. */
|
|
73
|
+
export interface FindRobotsForResult {
|
|
74
|
+
query: FindRobotsForQuery;
|
|
75
|
+
total: number;
|
|
76
|
+
/** Best-matching robots first. */
|
|
77
|
+
robots: FindRobotsForMatch[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run the filter+rank against the configured fleet.
|
|
82
|
+
*
|
|
83
|
+
* `onlineIds` is optional. When omitted AND `query.online` is set, this
|
|
84
|
+
* throws — the adapter MUST resolve the live set before calling this so
|
|
85
|
+
* the core stays transport-agnostic.
|
|
86
|
+
*
|
|
87
|
+
* The match list is stable across calls with the same input (no
|
|
88
|
+
* non-determinism, no time-of-day effects).
|
|
89
|
+
*/
|
|
90
|
+
export function findRobotsFor(
|
|
91
|
+
config: AgenticROSConfig,
|
|
92
|
+
query: FindRobotsForQuery,
|
|
93
|
+
onlineIds?: ReadonlySet<string>,
|
|
94
|
+
): FindRobotsForResult {
|
|
95
|
+
if (query.online !== undefined && onlineIds === undefined) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"findRobotsFor: query.online was set but onlineIds was not provided. " +
|
|
98
|
+
"Call discoverRobots() first and pass configured_online ids.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve the global capability registry once — used as the fallback
|
|
103
|
+
// when a robot doesn't declare its own per-robot allowlist.
|
|
104
|
+
const globalCapIds: ReadonlySet<string> = new Set(
|
|
105
|
+
listAllCapabilities(config).map((c: Capability) => c.id),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const wantedKind = query.kind?.trim().toLowerCase();
|
|
109
|
+
const wantedCap = query.capability?.trim();
|
|
110
|
+
|
|
111
|
+
const fleet = listRobots(config);
|
|
112
|
+
const matches: FindRobotsForMatch[] = [];
|
|
113
|
+
|
|
114
|
+
for (const robot of fleet) {
|
|
115
|
+
// kind filter
|
|
116
|
+
if (wantedKind && robot.kind.toLowerCase() !== wantedKind) continue;
|
|
117
|
+
|
|
118
|
+
// capability filter
|
|
119
|
+
let matchedExplicitly = false;
|
|
120
|
+
if (wantedCap) {
|
|
121
|
+
const allowlist = robot.capabilities;
|
|
122
|
+
if (allowlist) {
|
|
123
|
+
if (!allowlist.includes(wantedCap)) continue;
|
|
124
|
+
matchedExplicitly = true;
|
|
125
|
+
} else {
|
|
126
|
+
// No per-robot allowlist → fall back to the gateway-wide
|
|
127
|
+
// registry. This is the common case today.
|
|
128
|
+
if (!globalCapIds.has(wantedCap)) continue;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// online filter
|
|
133
|
+
let isOnline: boolean | null = null;
|
|
134
|
+
if (onlineIds) {
|
|
135
|
+
isOnline = onlineIds.has(robot.id);
|
|
136
|
+
if (query.online === true && !isOnline) continue;
|
|
137
|
+
if (query.online === false && isOnline) continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let score = 0;
|
|
141
|
+
if (matchedExplicitly) score += 1;
|
|
142
|
+
if (query.online === true && isOnline) score += 2;
|
|
143
|
+
// Reward online robots even when the user didn't filter for it —
|
|
144
|
+
// an agent picking a robot for an action almost always wants the
|
|
145
|
+
// one currently reachable.
|
|
146
|
+
if (query.online === undefined && isOnline === true) score += 1;
|
|
147
|
+
|
|
148
|
+
matches.push({ robot, matched_capability_explicitly: matchedExplicitly, online: isOnline, score });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Stable sort: higher score first, tie-broken by original config
|
|
152
|
+
// order (which is what listRobots() returned).
|
|
153
|
+
matches.sort((a, b) => b.score - a.score);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
query: {
|
|
157
|
+
...(wantedCap ? { capability: wantedCap } : {}),
|
|
158
|
+
...(wantedKind ? { kind: wantedKind } : {}),
|
|
159
|
+
...(query.online !== undefined ? { online: query.online } : {}),
|
|
160
|
+
},
|
|
161
|
+
total: matches.length,
|
|
162
|
+
robots: matches,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @agenticros/core — Platform-agnostic ROS2 transport, config, and utilities.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export type { AgenticROSConfig } from "./config.js";
|
|
5
|
+
export type { AgenticROSConfig, RobotTransportOverride } from "./config.js";
|
|
6
6
|
export {
|
|
7
7
|
AgenticROSConfigSchema,
|
|
8
8
|
parseConfig,
|
|
@@ -52,3 +52,69 @@ export type {
|
|
|
52
52
|
RecallInput,
|
|
53
53
|
ForgetInput,
|
|
54
54
|
} from "./memory/index.js";
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
BUILTIN_CAPABILITIES,
|
|
58
|
+
readSkillCapabilities,
|
|
59
|
+
listAllCapabilities,
|
|
60
|
+
} from "./capabilities.js";
|
|
61
|
+
export type {
|
|
62
|
+
Capability,
|
|
63
|
+
CapabilityField,
|
|
64
|
+
CapabilityImplementation,
|
|
65
|
+
CapabilitySource,
|
|
66
|
+
} from "./capabilities.js";
|
|
67
|
+
|
|
68
|
+
export { runMission } from "./mission.js";
|
|
69
|
+
export type {
|
|
70
|
+
Mission,
|
|
71
|
+
MissionStep,
|
|
72
|
+
MissionResult,
|
|
73
|
+
MissionStepResult,
|
|
74
|
+
MissionToolDispatcher,
|
|
75
|
+
CapabilityToolBinding,
|
|
76
|
+
CapabilityToolBindings,
|
|
77
|
+
MissionCancellationToken,
|
|
78
|
+
MissionTranscriptEntry,
|
|
79
|
+
MissionTranscriptSink,
|
|
80
|
+
RunMissionOptions,
|
|
81
|
+
} from "./mission.js";
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
MissionRegistry,
|
|
85
|
+
generateMissionId,
|
|
86
|
+
missionTranscriptNamespace,
|
|
87
|
+
} from "./mission-registry.js";
|
|
88
|
+
export type { MissionRegistryEntry } from "./mission-registry.js";
|
|
89
|
+
|
|
90
|
+
export { createMemoryTranscriptSink } from "./mission-transcript-sink.js";
|
|
91
|
+
|
|
92
|
+
export { compileGoalToMission } from "./planner/index.js";
|
|
93
|
+
export type { PlannerResult, PlannerCandidate } from "./planner/index.js";
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
listRobots,
|
|
97
|
+
resolveRobot,
|
|
98
|
+
resolveRobotFromArgs,
|
|
99
|
+
getActiveRobotId,
|
|
100
|
+
getTransportConfigForRobot,
|
|
101
|
+
hasRobotTransportOverride,
|
|
102
|
+
} from "./robots.js";
|
|
103
|
+
export type { ResolvedRobot, RobotSensors } from "./robots.js";
|
|
104
|
+
|
|
105
|
+
export {
|
|
106
|
+
detectRobotsFromTopics,
|
|
107
|
+
discoverRobots,
|
|
108
|
+
effectiveCmdVelNamespace,
|
|
109
|
+
} from "./discovery.js";
|
|
110
|
+
export type { DetectedRobot, RobotDiscoveryResult } from "./discovery.js";
|
|
111
|
+
|
|
112
|
+
export { findRobotsFor } from "./find-robots-for.js";
|
|
113
|
+
export type {
|
|
114
|
+
FindRobotsForQuery,
|
|
115
|
+
FindRobotsForMatch,
|
|
116
|
+
FindRobotsForResult,
|
|
117
|
+
} from "./find-robots-for.js";
|
|
118
|
+
|
|
119
|
+
export { TransportPool, TRANSPORT_POOL_GLOBAL_KEY } from "./transport-pool.js";
|
|
120
|
+
export type { TransportFactory } from "./transport-pool.js";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MissionRegistry — Phase 1.f in-process tracker for active missions.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter (Claude Code MCP server, OpenClaw plugin, Gemini CLI)
|
|
5
|
+
* keeps a module-level registry. When `run_mission` starts, the adapter
|
|
6
|
+
* registers a fresh cancellation token under a unique `mission_id` and
|
|
7
|
+
* returns the id in the tool response. A sibling `mission_cancel` tool
|
|
8
|
+
* call then looks up the token by id and flips `cancelled = true` — the
|
|
9
|
+
* mission runner picks that up at the next step boundary and stops
|
|
10
|
+
* gracefully.
|
|
11
|
+
*
|
|
12
|
+
* Lifetime semantics:
|
|
13
|
+
* - `register()` returns the cancellation token AND a `dispose()`
|
|
14
|
+
* callback the caller is expected to invoke in a `finally` block
|
|
15
|
+
* once the mission ends (regardless of outcome). Disposal removes
|
|
16
|
+
* the entry so a subsequent cancel doesn't silently target a
|
|
17
|
+
* completed mission and the in-memory map stays small.
|
|
18
|
+
* - `cancel()` is idempotent — calling it on a missing id returns
|
|
19
|
+
* `{ found: false }`; calling it twice on the same id is a no-op
|
|
20
|
+
* after the first.
|
|
21
|
+
*
|
|
22
|
+
* Why in-process (not cross-process)? Cancellation is fundamentally
|
|
23
|
+
* "stop the dispatcher I'm running"; that dispatcher is local to the
|
|
24
|
+
* adapter process. Cross-agent coordination is Phase 4. The transcript
|
|
25
|
+
* subsystem (memory) handles the "different agent reads what I did"
|
|
26
|
+
* story orthogonally.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { MissionCancellationToken } from "./mission.js";
|
|
30
|
+
|
|
31
|
+
/** One registered mission. */
|
|
32
|
+
export interface MissionRegistryEntry {
|
|
33
|
+
mission_id: string;
|
|
34
|
+
/** Adapter-supplied label for diagnostics (e.g. mission.name). */
|
|
35
|
+
name?: string;
|
|
36
|
+
/** ms since epoch when the mission was registered. */
|
|
37
|
+
started_at: number;
|
|
38
|
+
/** The token the mission runner reads each step. */
|
|
39
|
+
cancellation: MissionCancellationToken;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generate a stable, URL-safe mission identifier.
|
|
44
|
+
*
|
|
45
|
+
* Uses `crypto.randomUUID()` when available (Node 14.17+, Bun, modern
|
|
46
|
+
* browsers); falls back to a Math.random hex string with a `mns_` prefix.
|
|
47
|
+
* Either way the id is opaque to downstream consumers — they just echo
|
|
48
|
+
* it back to `mission_cancel` / `memory_recall`.
|
|
49
|
+
*/
|
|
50
|
+
export function generateMissionId(): string {
|
|
51
|
+
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
52
|
+
if (g.crypto?.randomUUID) return `mn_${g.crypto.randomUUID()}`;
|
|
53
|
+
const a = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
|
|
54
|
+
const b = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
|
|
55
|
+
const c = Date.now().toString(16);
|
|
56
|
+
return `mn_${a}${b}${c}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compute the canonical memory namespace for a mission's transcripts.
|
|
61
|
+
*
|
|
62
|
+
* The convention is `mission:<id>` so a downstream agent can recall
|
|
63
|
+
* every step the first agent ran via
|
|
64
|
+
* `memory_recall({ namespace: "mission:<id>", query: "" })`. This lives
|
|
65
|
+
* here (not in the memory module) because the adapter's transcript
|
|
66
|
+
* sink uses it and we want exactly one source of truth.
|
|
67
|
+
*/
|
|
68
|
+
export function missionTranscriptNamespace(missionId: string): string {
|
|
69
|
+
return `mission:${missionId}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class MissionRegistry {
|
|
73
|
+
private readonly entries = new Map<string, MissionRegistryEntry>();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register a fresh mission, returning the entry (with the
|
|
77
|
+
* cancellation token to hand to `runMission`) and a `dispose` hook
|
|
78
|
+
* to invoke when the mission ends. The id is caller-supplied so
|
|
79
|
+
* adapters can echo it in the tool response before this point.
|
|
80
|
+
*/
|
|
81
|
+
register(missionId: string, opts?: { name?: string }): {
|
|
82
|
+
entry: MissionRegistryEntry;
|
|
83
|
+
dispose: () => void;
|
|
84
|
+
} {
|
|
85
|
+
const entry: MissionRegistryEntry = {
|
|
86
|
+
mission_id: missionId,
|
|
87
|
+
name: opts?.name,
|
|
88
|
+
started_at: Date.now(),
|
|
89
|
+
cancellation: { cancelled: false },
|
|
90
|
+
};
|
|
91
|
+
this.entries.set(missionId, entry);
|
|
92
|
+
return {
|
|
93
|
+
entry,
|
|
94
|
+
dispose: () => {
|
|
95
|
+
// Only remove if we're disposing the same entry — guards against
|
|
96
|
+
// a race where the same id is re-registered between dispose calls
|
|
97
|
+
// (unlikely but cheap to defend against).
|
|
98
|
+
if (this.entries.get(missionId) === entry) this.entries.delete(missionId);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Flip the cancellation token for the named mission.
|
|
105
|
+
*
|
|
106
|
+
* Returns `{ found: true }` when the mission existed (whether it was
|
|
107
|
+
* already cancelled or not), `{ found: false }` when the id is
|
|
108
|
+
* unknown (e.g. mission already finished + disposed).
|
|
109
|
+
*/
|
|
110
|
+
cancel(missionId: string, reason?: string): { found: boolean; alreadyCancelled: boolean } {
|
|
111
|
+
const entry = this.entries.get(missionId);
|
|
112
|
+
if (!entry) return { found: false, alreadyCancelled: false };
|
|
113
|
+
const alreadyCancelled = entry.cancellation.cancelled === true;
|
|
114
|
+
entry.cancellation.cancelled = true;
|
|
115
|
+
if (reason !== undefined) entry.cancellation.reason = reason;
|
|
116
|
+
return { found: true, alreadyCancelled };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** True when the named mission is currently registered. */
|
|
120
|
+
has(missionId: string): boolean {
|
|
121
|
+
return this.entries.has(missionId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Snapshot of the active mission set (for diagnostics / mission_list
|
|
126
|
+
* tools down the road). Returns a shallow copy so callers can't
|
|
127
|
+
* mutate internal state.
|
|
128
|
+
*/
|
|
129
|
+
list(): MissionRegistryEntry[] {
|
|
130
|
+
return [...this.entries.values()].map((e) => ({
|
|
131
|
+
mission_id: e.mission_id,
|
|
132
|
+
name: e.name,
|
|
133
|
+
started_at: e.started_at,
|
|
134
|
+
cancellation: { ...e.cancellation },
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Test-only: drop every registered mission. */
|
|
139
|
+
_clear(): void {
|
|
140
|
+
this.entries.clear();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-backed mission transcript sink — Phase 1.f wiring.
|
|
3
|
+
*
|
|
4
|
+
* `createMemoryTranscriptSink(memory, missionId)` returns a
|
|
5
|
+
* `MissionTranscriptSink` the adapter hands to `runMission`. Each
|
|
6
|
+
* step's `MissionTranscriptEntry` is serialised to JSON and persisted
|
|
7
|
+
* via the shared `MemoryProvider` under the canonical
|
|
8
|
+
* `mission:<id>` namespace.
|
|
9
|
+
*
|
|
10
|
+
* Why JSON content (and not the structured `MemoryRecord` fields):
|
|
11
|
+
* `MemoryProvider.remember` only takes `content: string` (plus
|
|
12
|
+
* optional tags/path). We pack the full step snapshot into `content`
|
|
13
|
+
* so a downstream agent can `JSON.parse(record.content)` after
|
|
14
|
+
* `memory_recall(namespace="mission:<id>")`. Tags are used as
|
|
15
|
+
* coarse-grained filters (e.g. "step:ok" vs "step:error") for
|
|
16
|
+
* future status queries.
|
|
17
|
+
*
|
|
18
|
+
* The returned sink is best-effort by contract — `runMission` already
|
|
19
|
+
* swallows thrown errors, but we also defensively catch the await so a
|
|
20
|
+
* mem0 outage never propagates back into the run loop.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { MemoryProvider } from "./memory/index.js";
|
|
24
|
+
import type { MissionTranscriptSink, MissionTranscriptEntry } from "./mission.js";
|
|
25
|
+
import { missionTranscriptNamespace } from "./mission-registry.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a transcript sink that writes each step's entry to memory.
|
|
29
|
+
*
|
|
30
|
+
* @param memory Active memory provider (caller has already
|
|
31
|
+
* confirmed `config.memory.enabled === true`).
|
|
32
|
+
* @param missionId Mission identifier. Surfaced as the namespace
|
|
33
|
+
* and also embedded in the content for grep-ability.
|
|
34
|
+
*/
|
|
35
|
+
export function createMemoryTranscriptSink(
|
|
36
|
+
memory: MemoryProvider,
|
|
37
|
+
missionId: string,
|
|
38
|
+
): MissionTranscriptSink {
|
|
39
|
+
const namespace = missionTranscriptNamespace(missionId);
|
|
40
|
+
return async (entry: MissionTranscriptEntry): Promise<void> => {
|
|
41
|
+
try {
|
|
42
|
+
const content = JSON.stringify({
|
|
43
|
+
// Echo mission_id in the payload too — recall() returns the
|
|
44
|
+
// content blob; mission_id in the namespace might be lost if
|
|
45
|
+
// the caller scopes their recall differently.
|
|
46
|
+
mission_id: entry.mission_id || missionId,
|
|
47
|
+
mission_name: entry.mission_name,
|
|
48
|
+
adapter: entry.adapter,
|
|
49
|
+
robot_id: entry.robot_id,
|
|
50
|
+
started_at: entry.started_at,
|
|
51
|
+
step_index: entry.step_index,
|
|
52
|
+
step_total: entry.step_total,
|
|
53
|
+
step: {
|
|
54
|
+
id: entry.result.id,
|
|
55
|
+
capability: entry.result.capability,
|
|
56
|
+
status: entry.result.status,
|
|
57
|
+
inputs: entry.result.inputs,
|
|
58
|
+
outputs: entry.result.outputs,
|
|
59
|
+
message: entry.result.message,
|
|
60
|
+
error: entry.result.error,
|
|
61
|
+
duration_ms: entry.result.duration_ms,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
// Tags are deliberately stable + greppable so a future
|
|
65
|
+
// `memory_recall(tags=…)` can filter to "every cancelled step"
|
|
66
|
+
// or "every step that touched capability=drive_base".
|
|
67
|
+
await memory.remember({
|
|
68
|
+
namespace,
|
|
69
|
+
content,
|
|
70
|
+
tags: [
|
|
71
|
+
"mission_transcript",
|
|
72
|
+
`step:${entry.result.status}`,
|
|
73
|
+
`capability:${entry.result.capability}`,
|
|
74
|
+
],
|
|
75
|
+
path: `${missionId}/${String(entry.step_index).padStart(3, "0")}-${entry.result.id}`,
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort: transcript loss must NEVER abort the mission.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|