agenticros 0.1.18 → 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/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/sync-skill-tools.mjs +67 -10
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Transport lifecycle for the AgenticROS MCP server (Claude Code adapter).
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around `@agenticros/core`'s shared `TransportPool`. The
|
|
5
|
+
* pool owns the per-robot routing logic — see its docstring for the
|
|
6
|
+
* full contract. This module just exposes a module-level singleton so
|
|
7
|
+
* the rest of the adapter doesn't need to thread a pool reference
|
|
8
|
+
* through every call site.
|
|
9
|
+
*
|
|
10
|
+
* Why a module-level singleton: the MCP server is one process. Two
|
|
11
|
+
* pools would mean two transports — exactly what the pool is designed
|
|
12
|
+
* to prevent. Tests inject their own pool via `_swapPoolForTests`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgenticROSConfig, ResolvedRobot, RosTransport } from "@agenticros/core";
|
|
16
|
+
import { TransportPool } from "@agenticros/core";
|
|
4
17
|
|
|
5
|
-
let
|
|
18
|
+
let pool = new TransportPool();
|
|
6
19
|
|
|
7
20
|
/**
|
|
8
|
-
*
|
|
21
|
+
* Async per-robot transport accessor — the path every tool call takes
|
|
22
|
+
* once it has resolved a target robot. Lazy-connects on first use.
|
|
9
23
|
*/
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function getTransportOrNull(): RosTransport | null {
|
|
18
|
-
return transport;
|
|
24
|
+
export async function getTransportForRobot(
|
|
25
|
+
config: AgenticROSConfig,
|
|
26
|
+
robot: ResolvedRobot,
|
|
27
|
+
): Promise<RosTransport> {
|
|
28
|
+
return pool.acquire(config, robot);
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
const CONNECT_TIMEOUT_MS = 15_000;
|
|
22
|
-
|
|
23
31
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
32
|
+
* Pre-warm the active robot's transport at server start. Called from
|
|
33
|
+
* `index.ts` before the first tool dispatch — keeps the legacy
|
|
34
|
+
* "connect at startup" UX while routing through the pool internally.
|
|
26
35
|
*/
|
|
27
36
|
export async function connect(config: AgenticROSConfig): Promise<void> {
|
|
28
|
-
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
if (transport) {
|
|
32
|
-
await transport.disconnect();
|
|
33
|
-
transport = null;
|
|
34
|
-
}
|
|
35
|
-
const transportCfg = getTransportConfig(config);
|
|
36
|
-
const newTransport = await createTransport(transportCfg);
|
|
37
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
38
|
-
setTimeout(
|
|
39
|
-
() => reject(new Error(
|
|
40
|
-
`Transport connection timed out after ${CONNECT_TIMEOUT_MS / 1000}s. ` +
|
|
41
|
-
"Is zenohd running? (e.g. ws://localhost:10000). Check config and MCP log.",
|
|
42
|
-
)),
|
|
43
|
-
CONNECT_TIMEOUT_MS,
|
|
44
|
-
);
|
|
45
|
-
});
|
|
46
|
-
await Promise.race([newTransport.connect(), timeoutPromise]);
|
|
47
|
-
transport = newTransport;
|
|
37
|
+
await pool.connectActive(config);
|
|
48
38
|
}
|
|
49
39
|
|
|
50
40
|
/**
|
|
51
|
-
*
|
|
41
|
+
* Drain every connection. Called on SIGINT/SIGTERM.
|
|
52
42
|
*/
|
|
53
43
|
export async function disconnect(): Promise<void> {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
44
|
+
await pool.disconnectAll();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* For tests only — replace the module singleton with a custom pool
|
|
49
|
+
* (typically one constructed with a fake factory). Restores via the
|
|
50
|
+
* returned undo function.
|
|
51
|
+
*/
|
|
52
|
+
export function _swapPoolForTests(replacement: TransportPool): () => void {
|
|
53
|
+
const previous = pool;
|
|
54
|
+
pool = replacement;
|
|
55
|
+
return () => {
|
|
56
|
+
pool = previous;
|
|
57
|
+
};
|
|
58
58
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1.f — Gemini adapter tests for `mission_cancel` and the
|
|
3
|
+
* mission_id round-trip from `run_mission`.
|
|
4
|
+
*
|
|
5
|
+
* Drives the `executeTool` entry point directly (no network) and
|
|
6
|
+
* verifies:
|
|
7
|
+
* 1. `mission_cancel` returns success / found:false on unknown ids
|
|
8
|
+
* (idempotent, never errors out — safe to spam from the agent).
|
|
9
|
+
* 2. `mission_cancel` rejects empty / missing mission_id with
|
|
10
|
+
* success:false and a useful error message.
|
|
11
|
+
* 3. `run_mission` echoes a mission_id in its compact JSON payload
|
|
12
|
+
* so a sibling `mission_cancel` call can target it.
|
|
13
|
+
* 4. `mission_cancel` is in the function-declaration list.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors the Claude Code + OpenClaw tests; together they pin the
|
|
16
|
+
* cross-adapter contract for Phase 1.f.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { executeTool, GEMINI_FUNCTION_DECLARATIONS } from "../tools.js";
|
|
22
|
+
import type { AgenticROSConfig } from "@agenticros/core";
|
|
23
|
+
|
|
24
|
+
function makeHermeticConfig(): AgenticROSConfig {
|
|
25
|
+
// Minimal config that satisfies the Zod schema with defaults. We
|
|
26
|
+
// never reach the transport because mission_cancel + the empty
|
|
27
|
+
// run_mission paths short-circuit before getTransport(). The
|
|
28
|
+
// rosbridge mode + port 1 ensures Phase 1.g goal tests fail FAST
|
|
29
|
+
// (ECONNREFUSED) when a goal compiles to a transport-bound step.
|
|
30
|
+
return {
|
|
31
|
+
transport: { mode: "rosbridge" },
|
|
32
|
+
zenoh: {
|
|
33
|
+
routerEndpoint: "ws://localhost:10000",
|
|
34
|
+
domainId: 0,
|
|
35
|
+
keyFormat: "ros2dds",
|
|
36
|
+
},
|
|
37
|
+
rosbridge: { url: "ws://127.0.0.1:1", reconnect: false, reconnectInterval: 3000 },
|
|
38
|
+
local: { domainId: 0 },
|
|
39
|
+
webrtc: { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] },
|
|
40
|
+
robot: { name: "Test", namespace: "test_robot", cameraTopic: "" },
|
|
41
|
+
safety: {
|
|
42
|
+
maxLinearVelocity: 1,
|
|
43
|
+
maxAngularVelocity: 1.5,
|
|
44
|
+
workspaceLimits: { xMin: -10, xMax: 10, yMin: -10, yMax: 10 },
|
|
45
|
+
},
|
|
46
|
+
teleop: {
|
|
47
|
+
cameraTopic: "",
|
|
48
|
+
cameraTopics: [],
|
|
49
|
+
cmdVelTopic: "",
|
|
50
|
+
speedDefault: 0.3,
|
|
51
|
+
cameraPollMs: 150,
|
|
52
|
+
},
|
|
53
|
+
describer: {
|
|
54
|
+
enabled: false,
|
|
55
|
+
url: "http://localhost:11435/v1/chat/completions",
|
|
56
|
+
model: "qwen2.5vl:7b",
|
|
57
|
+
maxTokens: 400,
|
|
58
|
+
timeoutMs: 60000,
|
|
59
|
+
maxImageDimension: 896,
|
|
60
|
+
},
|
|
61
|
+
memory: {
|
|
62
|
+
enabled: false,
|
|
63
|
+
backend: "local",
|
|
64
|
+
local: { storePath: "~/.agenticros/memory.json" },
|
|
65
|
+
mem0: {
|
|
66
|
+
inferOnWrite: false,
|
|
67
|
+
historyDbPath: "~/.agenticros/memory-history.db",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
skills: {},
|
|
71
|
+
skillPaths: [],
|
|
72
|
+
skillPackages: [],
|
|
73
|
+
robots: [],
|
|
74
|
+
} as unknown as AgenticROSConfig;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("gemini: GEMINI_FUNCTION_DECLARATIONS includes mission_cancel with required mission_id", () => {
|
|
78
|
+
const decl = GEMINI_FUNCTION_DECLARATIONS.find((d) => d.name === "mission_cancel");
|
|
79
|
+
assert.ok(decl, "mission_cancel must be in the function declaration set");
|
|
80
|
+
const schema = decl!.parametersJsonSchema as {
|
|
81
|
+
properties?: Record<string, unknown>;
|
|
82
|
+
required?: string[];
|
|
83
|
+
};
|
|
84
|
+
assert.ok(schema?.properties?.["mission_id"], "must advertise 'mission_id'");
|
|
85
|
+
assert.ok(schema?.properties?.["reason"], "must advertise optional 'reason'");
|
|
86
|
+
assert.deepEqual(schema?.required, ["mission_id"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("gemini: mission_cancel on unknown id returns success+found:false (no error, idempotent)", async () => {
|
|
90
|
+
const config = makeHermeticConfig();
|
|
91
|
+
const result = await executeTool(
|
|
92
|
+
"mission_cancel",
|
|
93
|
+
{ mission_id: "mn_unknown_xyz" },
|
|
94
|
+
config,
|
|
95
|
+
);
|
|
96
|
+
const payload = JSON.parse(result.output) as {
|
|
97
|
+
success: boolean;
|
|
98
|
+
mission_id: string;
|
|
99
|
+
found: boolean;
|
|
100
|
+
already_cancelled: boolean;
|
|
101
|
+
reason: string | null;
|
|
102
|
+
};
|
|
103
|
+
assert.equal(payload.success, true);
|
|
104
|
+
assert.equal(payload.found, false);
|
|
105
|
+
assert.equal(payload.already_cancelled, false);
|
|
106
|
+
assert.equal(payload.mission_id, "mn_unknown_xyz");
|
|
107
|
+
assert.equal(payload.reason, null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("gemini: mission_cancel rejects empty mission_id with success:false + useful error", async () => {
|
|
111
|
+
const config = makeHermeticConfig();
|
|
112
|
+
const result = await executeTool("mission_cancel", { mission_id: " " }, config);
|
|
113
|
+
const payload = JSON.parse(result.output) as { success: boolean; error?: string };
|
|
114
|
+
assert.equal(payload.success, false);
|
|
115
|
+
assert.ok(payload.error?.toLowerCase().includes("mission_id"));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("gemini: run_mission echoes a mission_id so a sibling mission_cancel can target it", async () => {
|
|
119
|
+
const config = makeHermeticConfig();
|
|
120
|
+
// Empty steps means we don't dispatch any sub-tool — no transport needed.
|
|
121
|
+
const result = await executeTool(
|
|
122
|
+
"run_mission",
|
|
123
|
+
{ mission: { name: "yield mission_id", steps: [] } },
|
|
124
|
+
config,
|
|
125
|
+
);
|
|
126
|
+
// The text payload is "<summary>\n<json>" — pull the JSON line.
|
|
127
|
+
const lines = result.output.split("\n");
|
|
128
|
+
const jsonLine = lines.find((l) => l.trim().startsWith("{")) ?? "";
|
|
129
|
+
const payload = JSON.parse(jsonLine) as {
|
|
130
|
+
mission_id?: string;
|
|
131
|
+
status: string;
|
|
132
|
+
steps: unknown[];
|
|
133
|
+
};
|
|
134
|
+
assert.ok(
|
|
135
|
+
typeof payload.mission_id === "string" && payload.mission_id.startsWith("mn_"),
|
|
136
|
+
`run_mission compact JSON must include a mission_id; got: ${jsonLine}`,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --- Phase 1.g: run_mission { goal } natural-language compile path ---
|
|
141
|
+
|
|
142
|
+
test("gemini: GEMINI_FUNCTION_DECLARATIONS run_mission advertises both 'mission' AND 'goal' (Phase 1.g)", () => {
|
|
143
|
+
const decl = GEMINI_FUNCTION_DECLARATIONS.find((d) => d.name === "run_mission");
|
|
144
|
+
assert.ok(decl);
|
|
145
|
+
const schema = decl!.parametersJsonSchema as {
|
|
146
|
+
properties?: Record<string, unknown>;
|
|
147
|
+
required?: string[];
|
|
148
|
+
};
|
|
149
|
+
assert.ok(schema?.properties?.["mission"], "must still accept 'mission'");
|
|
150
|
+
assert.ok(schema?.properties?.["goal"], "must also accept natural-language 'goal'");
|
|
151
|
+
assert.ok(schema?.properties?.["robot_id"], "must accept top-level robot_id when goal is used");
|
|
152
|
+
// Neither field is individually required — the handler enforces
|
|
153
|
+
// "at least one of mission/goal" at runtime.
|
|
154
|
+
assert.deepEqual(schema?.required ?? [], []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("gemini: run_mission { goal: 'take a picture' } compiles + echoes planner info (Phase 1.g)", async () => {
|
|
158
|
+
const config = makeHermeticConfig();
|
|
159
|
+
// The compiled mission step (take_snapshot) WILL try to dispatch
|
|
160
|
+
// ros2_camera_snapshot which fails fast against the bogus zenoh
|
|
161
|
+
// endpoint — but the run_mission response still includes the
|
|
162
|
+
// planner info + mission_id, which is what we assert here.
|
|
163
|
+
const result = await executeTool("run_mission", { goal: "take a picture" }, config);
|
|
164
|
+
const lines = result.output.split("\n");
|
|
165
|
+
const jsonLine = lines.find((l) => l.trim().startsWith("{")) ?? "";
|
|
166
|
+
const payload = JSON.parse(jsonLine) as {
|
|
167
|
+
mission_id?: string;
|
|
168
|
+
planner?: {
|
|
169
|
+
compiled_from_goal: string;
|
|
170
|
+
candidates: Array<{ capability_id: string }>;
|
|
171
|
+
};
|
|
172
|
+
steps?: Array<{ capability: string }>;
|
|
173
|
+
};
|
|
174
|
+
assert.equal(payload.planner?.compiled_from_goal, "take a picture");
|
|
175
|
+
assert.equal(payload.planner?.candidates[0].capability_id, "take_snapshot");
|
|
176
|
+
assert.ok(typeof payload.mission_id === "string" && payload.mission_id.startsWith("mn_"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("gemini: run_mission { goal: 'paint the wall' } surfaces a clean compile error (Phase 1.g)", async () => {
|
|
180
|
+
const config = makeHermeticConfig();
|
|
181
|
+
const result = await executeTool("run_mission", { goal: "paint the wall blue" }, config);
|
|
182
|
+
const payload = JSON.parse(result.output) as {
|
|
183
|
+
success: boolean;
|
|
184
|
+
error?: string;
|
|
185
|
+
suggestions?: string[];
|
|
186
|
+
};
|
|
187
|
+
assert.equal(payload.success, false);
|
|
188
|
+
assert.ok(payload.error);
|
|
189
|
+
assert.ok(Array.isArray(payload.suggestions) && payload.suggestions.length > 0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("gemini: run_mission with NEITHER mission nor goal returns a clear error (Phase 1.g)", async () => {
|
|
193
|
+
const config = makeHermeticConfig();
|
|
194
|
+
const result = await executeTool("run_mission", {}, config);
|
|
195
|
+
assert.ok(result.output.toLowerCase().includes("mission"));
|
|
196
|
+
assert.ok(result.output.toLowerCase().includes("goal"));
|
|
197
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1.f — Gemini adapter test for mission transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Pins the end-to-end wiring: when `memory.enabled` is true and we
|
|
5
|
+
* `executeTool("run_mission", ...)`, every step the runner executes
|
|
6
|
+
* MUST land in the configured memory provider under the canonical
|
|
7
|
+
* `mission:<id>` namespace. A second agent (or the same agent
|
|
8
|
+
* later) can then call `memory_recall({ namespace: "mission:<id>"
|
|
9
|
+
* })` to read the full timeline.
|
|
10
|
+
*
|
|
11
|
+
* The test:
|
|
12
|
+
* 1. Spins up a hermetic config with the local-JSON memory
|
|
13
|
+
* backend pointing at a tmp file.
|
|
14
|
+
* 2. Runs a mission with one step whose capability isn't in the
|
|
15
|
+
* registry — the runner records an "error" step (no transport
|
|
16
|
+
* needed) AND emits a transcript entry for it.
|
|
17
|
+
* 3. Asserts the transcript landed in memory under
|
|
18
|
+
* `mission:<id>`, with the expected tags + content.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the cross-adapter contract; the Claude Code and OpenClaw
|
|
21
|
+
* adapters share the same wiring (see ros2-mission.ts / tools.ts).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { test } from "node:test";
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { executeTool } from "../tools.js";
|
|
30
|
+
import { createMemory } from "@agenticros/core";
|
|
31
|
+
import type { AgenticROSConfig } from "@agenticros/core";
|
|
32
|
+
|
|
33
|
+
function makeMemoryConfig(storePath: string): AgenticROSConfig {
|
|
34
|
+
return {
|
|
35
|
+
transport: { mode: "zenoh" },
|
|
36
|
+
zenoh: {
|
|
37
|
+
routerEndpoint: "ws://localhost:10000",
|
|
38
|
+
domainId: 0,
|
|
39
|
+
keyFormat: "ros2dds",
|
|
40
|
+
},
|
|
41
|
+
rosbridge: { url: "ws://localhost:9090", reconnect: true, reconnectInterval: 3000 },
|
|
42
|
+
local: { domainId: 0 },
|
|
43
|
+
webrtc: { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] },
|
|
44
|
+
robot: { name: "Test", namespace: "test_robot", cameraTopic: "" },
|
|
45
|
+
safety: {
|
|
46
|
+
maxLinearVelocity: 1,
|
|
47
|
+
maxAngularVelocity: 1.5,
|
|
48
|
+
workspaceLimits: { xMin: -10, xMax: 10, yMin: -10, yMax: 10 },
|
|
49
|
+
},
|
|
50
|
+
teleop: {
|
|
51
|
+
cameraTopic: "",
|
|
52
|
+
cameraTopics: [],
|
|
53
|
+
cmdVelTopic: "",
|
|
54
|
+
speedDefault: 0.3,
|
|
55
|
+
cameraPollMs: 150,
|
|
56
|
+
},
|
|
57
|
+
describer: {
|
|
58
|
+
enabled: false,
|
|
59
|
+
url: "http://localhost:11435/v1/chat/completions",
|
|
60
|
+
model: "qwen2.5vl:7b",
|
|
61
|
+
maxTokens: 400,
|
|
62
|
+
timeoutMs: 60000,
|
|
63
|
+
maxImageDimension: 896,
|
|
64
|
+
},
|
|
65
|
+
memory: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
backend: "local",
|
|
68
|
+
local: { storePath },
|
|
69
|
+
mem0: {
|
|
70
|
+
inferOnWrite: false,
|
|
71
|
+
historyDbPath: "~/.agenticros/memory-history.db",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
skills: {},
|
|
75
|
+
skillPaths: [],
|
|
76
|
+
skillPackages: [],
|
|
77
|
+
robots: [],
|
|
78
|
+
} as unknown as AgenticROSConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test("gemini transcripts: run_mission writes per-step transcripts under mission:<id> namespace", async () => {
|
|
82
|
+
const dir = await mkdtemp(path.join(tmpdir(), "agenticros-gemini-transcripts-"));
|
|
83
|
+
const storePath = path.join(dir, "memory.json");
|
|
84
|
+
try {
|
|
85
|
+
const config = makeMemoryConfig(storePath);
|
|
86
|
+
|
|
87
|
+
// Sanity check: the local provider initialises cleanly so we can
|
|
88
|
+
// attribute any later failure to the adapter wiring rather than
|
|
89
|
+
// the backend itself.
|
|
90
|
+
const probe = await createMemory(config);
|
|
91
|
+
assert.ok(probe, "local memory provider must initialise from hermetic config");
|
|
92
|
+
|
|
93
|
+
// Run a mission with one step whose capability isn't in the
|
|
94
|
+
// registry — the runner emits an "error" step result + transcript
|
|
95
|
+
// entry for it. No transport / no sub-tool dispatch needed.
|
|
96
|
+
const result = await executeTool(
|
|
97
|
+
"run_mission",
|
|
98
|
+
{
|
|
99
|
+
mission: {
|
|
100
|
+
name: "transcript wiring smoke test",
|
|
101
|
+
steps: [
|
|
102
|
+
{ id: "noop", capability: "this_capability_does_not_exist" },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
config,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const jsonLine = result.output.split("\n").find((l) => l.trim().startsWith("{")) ?? "";
|
|
110
|
+
const payload = JSON.parse(jsonLine) as {
|
|
111
|
+
mission_id?: string;
|
|
112
|
+
transcript_namespace?: string;
|
|
113
|
+
steps: Array<{ status: string }>;
|
|
114
|
+
};
|
|
115
|
+
assert.ok(payload.mission_id, "run_mission must echo mission_id when memory is enabled");
|
|
116
|
+
assert.equal(
|
|
117
|
+
payload.transcript_namespace,
|
|
118
|
+
`mission:${payload.mission_id}`,
|
|
119
|
+
"compact result must surface the transcript namespace so the agent can recall",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// The transcript sink is fire-and-forget; runMission returns
|
|
123
|
+
// before the on-disk write necessarily settles. Poll the file
|
|
124
|
+
// directly (the local backend's atomic-rename guarantees a
|
|
125
|
+
// self-consistent snapshot once memory.json exists), so we don't
|
|
126
|
+
// race the writer. Creating a fresh MemoryProvider per poll
|
|
127
|
+
// sidesteps the per-instance load() cache the local backend
|
|
128
|
+
// keeps around (it never reloads after the first read).
|
|
129
|
+
const ns = `mission:${payload.mission_id}`;
|
|
130
|
+
const deadline = Date.now() + 2000;
|
|
131
|
+
let recordCount = 0;
|
|
132
|
+
let reader = null as Awaited<ReturnType<typeof createMemory>>;
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
reader = await createMemory(config);
|
|
135
|
+
const status = await reader!.status(ns);
|
|
136
|
+
recordCount = status.recordCount;
|
|
137
|
+
if (recordCount >= 1) break;
|
|
138
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
139
|
+
}
|
|
140
|
+
assert.ok(recordCount >= 1, `expected >=1 transcript entry under ${ns}, got ${recordCount}`);
|
|
141
|
+
|
|
142
|
+
// Use a meaningful query token (the step id "noop" appears
|
|
143
|
+
// verbatim inside the serialized transcript JSON) so the
|
|
144
|
+
// tokenized scorer in the local backend returns hits — not the
|
|
145
|
+
// hit-everything empty-query path.
|
|
146
|
+
const hits = await reader!.recall({ query: "noop", namespace: ns, limit: 10 });
|
|
147
|
+
assert.ok(hits.length >= 1, `recall by content should return >=1 hit, got ${hits.length}`);
|
|
148
|
+
|
|
149
|
+
// Every recalled entry should carry the transcript tags so a
|
|
150
|
+
// future filter-by-tag UI can locate them efficiently.
|
|
151
|
+
const tagsSeen = new Set<string>();
|
|
152
|
+
for (const h of hits) {
|
|
153
|
+
for (const t of h.tags ?? []) tagsSeen.add(t);
|
|
154
|
+
}
|
|
155
|
+
assert.ok(tagsSeen.has("mission_transcript"), `tags should include 'mission_transcript'; got: ${[...tagsSeen].join(", ")}`);
|
|
156
|
+
|
|
157
|
+
// And the content should be JSON-parseable, carrying the
|
|
158
|
+
// mission_id back out the other side (round-trip). The sink
|
|
159
|
+
// packs the per-step snapshot under `step` (not `result`) — see
|
|
160
|
+
// packages/core/src/mission-transcript-sink.ts.
|
|
161
|
+
const firstContent = JSON.parse(hits[0].content) as {
|
|
162
|
+
mission_id: string;
|
|
163
|
+
adapter?: string;
|
|
164
|
+
step: { id: string; status: string; capability: string };
|
|
165
|
+
};
|
|
166
|
+
assert.equal(firstContent.mission_id, payload.mission_id);
|
|
167
|
+
assert.equal(firstContent.adapter, "gemini", "transcript should record the originating adapter");
|
|
168
|
+
assert.equal(firstContent.step.id, "noop");
|
|
169
|
+
assert.equal(firstContent.step.status, "error");
|
|
170
|
+
assert.equal(firstContent.step.capability, "this_capability_does_not_exist");
|
|
171
|
+
} finally {
|
|
172
|
+
// The transcript sink writes are fire-and-forget — give any
|
|
173
|
+
// still-in-flight atomic rename (tmp → final) a moment to settle
|
|
174
|
+
// before we rmdir, otherwise ENOTEMPTY trips the cleanup.
|
|
175
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
176
|
+
await rm(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|