A seat's verb keeps a node of its own; only a module's tool gives it to the subject #24
+10
-6
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { Broker } from "@novox/mesh-sdk/messaging";
|
import type { Broker } from "@novox/mesh-sdk/messaging";
|
||||||
|
|
||||||
import { callTool, seatsIn, toolsOn, whyItFailed, type Listing, type Seats } from "./client.js";
|
import { callTool, seatsIn, toolKey, toolsOn, whyItFailed, type Listing, type Seats } from "./client.js";
|
||||||
|
|
||||||
/** The protocol version this speaks. Stated, because a host that wants another should be told so
|
/** The protocol version this speaks. Stated, because a host that wants another should be told so
|
||||||
* rather than discovering it through a shape it did not expect. */
|
* rather than discovering it through a shape it did not expect. */
|
||||||
@@ -134,13 +134,17 @@ export function mcpSurface(bus: Broker, who: string): Surface {
|
|||||||
const given = String(request.params?.name ?? "");
|
const given = String(request.params?.name ?? "");
|
||||||
const args = { ...((request.params?.arguments as Record<string, unknown> | undefined) ?? {}) };
|
const args = { ...((request.params?.arguments as Record<string, unknown> | undefined) ?? {}) };
|
||||||
// The machine, when the caller names one, travels in the subject and never reaches the
|
// The machine, when the caller names one, travels in the subject and never reaches the
|
||||||
// module's arguments (novox/hq ADR 0159).
|
// module's arguments (novox/hq ADR 0159) — for a module's tool. A seat's verb takes no
|
||||||
const node = typeof args.node === "string" && args.node !== "" ? args.node : "";
|
// machine from the console (the seat's scope decides), so a `node` among its arguments
|
||||||
delete args.node;
|
// is the verb's own, as `push` and `assign` take one, and is handed through untouched.
|
||||||
|
const have = await listing().catch(() => undefined);
|
||||||
|
const roles = have && seatsIn(have);
|
||||||
|
const isSeatVerb = roles ? toolKey(given.split("@", 1)[0], roles).startsWith("seat:") : false;
|
||||||
|
const node = !isSeatVerb && typeof args.node === "string" && args.node !== "" ? args.node : "";
|
||||||
|
if (!isSeatVerb) delete args.node;
|
||||||
const name = node && !given.includes("@") ? `${given}@${node}` : given;
|
const name = node && !given.includes("@") ? `${given}@${node}` : given;
|
||||||
try {
|
try {
|
||||||
const have = await listing().catch(() => undefined);
|
const { result, node: answeredBy } = await callTool(bus, name, args, roles, have);
|
||||||
const { result, node: answeredBy } = await callTool(bus, name, args, have && seatsIn(have), have);
|
|
||||||
// Text, because that is what every host renders. The content is the module's answer
|
// Text, because that is what every host renders. The content is the module's answer
|
||||||
// as JSON, unshaped: an adapter that flattened it would be deciding what matters in
|
// as JSON, unshaped: an adapter that flattened it would be deciding what matters in
|
||||||
// somebody else's answer. Which machine answered follows it as its own line.
|
// somebody else's answer. Which machine answered follows it as its own line.
|
||||||
|
|||||||
+22
-4
@@ -30,9 +30,13 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise<void> | void)
|
|||||||
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
||||||
const controller = await connectNats({ url: url!, module: "mesh-controller" });
|
const controller = await connectNats({ url: url!, module: "mesh-controller" });
|
||||||
await controller.handle("seat:mesh-controller.tools", async () => ({
|
await controller.handle("seat:mesh-controller.tools", async () => ({
|
||||||
seats: [{ seat: "mesh-controller", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] }],
|
seats: [{ seat: "mesh-controller", scope: "mesh", tools: [
|
||||||
|
{ name: "status", description: "what is wrong", input: {} },
|
||||||
|
{ name: "push", description: "tell a machine", input: { node: { type: "string" } } },
|
||||||
|
] }],
|
||||||
}));
|
}));
|
||||||
await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true }));
|
await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true }));
|
||||||
|
await controller.handle("seat:mesh-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" }));
|
||||||
t.after(async () => {
|
t.after(async () => {
|
||||||
await catalogue.close();
|
await catalogue.close();
|
||||||
await shop.close();
|
await shop.close();
|
||||||
@@ -95,15 +99,16 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => {
|
|||||||
assert.match(hello.instructions, /ada/, "the handshake says whose authority a call is made under");
|
assert.match(hello.instructions, /ada/, "the handshake says whose authority a call is made under");
|
||||||
|
|
||||||
const listed = byId.get(2)!.result.tools;
|
const listed = byId.get(2)!.result.tools;
|
||||||
assert.deepEqual(listed.map((x: { name: string }) => x.name), ["mesh-controller.status", "shop.price"],
|
assert.deepEqual(listed.map((x: { name: string }) => x.name), ["mesh-controller.push", "mesh-controller.status", "shop.price"],
|
||||||
"the modules' tools and the roles', named the way a person names them");
|
"the modules' tools and the roles', named the way a person names them");
|
||||||
const price = listed[1];
|
const price = listed[2];
|
||||||
assert.ok(price.inputSchema, "a tool with no schema is one an agent cannot call");
|
assert.ok(price.inputSchema, "a tool with no schema is one an agent cannot call");
|
||||||
// A module's bare property map arrives as a schema an agent can read, its words kept — and
|
// A module's bare property map arrives as a schema an agent can read, its words kept — and
|
||||||
// `node`, the machine to ask when the module runs on several (novox/hq ADR 0159), beside them.
|
// `node`, the machine to ask when the module runs on several (novox/hq ADR 0159), beside them.
|
||||||
assert.deepEqual(price.inputSchema.properties.of, { type: "string" });
|
assert.deepEqual(price.inputSchema.properties.of, { type: "string" });
|
||||||
assert.equal(price.inputSchema.properties.node.type, "string", "a module's tool takes the machine to ask");
|
assert.equal(price.inputSchema.properties.node.type, "string", "a module's tool takes the machine to ask");
|
||||||
assert.ok(!listed[0].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides");
|
assert.ok(!listed[1].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides");
|
||||||
|
assert.equal(listed[0].inputSchema.properties?.node?.type, "string", "a seat's verb that takes a node of its own keeps it");
|
||||||
// Silence is named: the module the catalogue holds and nothing answered for.
|
// Silence is named: the module the catalogue holds and nothing answered for.
|
||||||
assert.deepEqual(byId.get(2)!.result._meta.notAnswering, ["ghost"]);
|
assert.deepEqual(byId.get(2)!.result._meta.notAnswering, ["ghost"]);
|
||||||
|
|
||||||
@@ -141,6 +146,19 @@ test("a method this surface does not have is refused, and a notification is not"
|
|||||||
assert.match(replies[0].error.message, /resources\/list/);
|
assert.match(replies[0].error.message, /resources\/list/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A seat's verb that takes a machine as its own argument — `push <node>` — keeps it: the console
|
||||||
|
// moves `node` into the subject for a module's tool only (ADR 0159), never for a role's verb.
|
||||||
|
test("a seat's verb keeps a node of its own; only a module's tool gives it to the subject", async (t) => {
|
||||||
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
||||||
|
const credential = await aMeshAndACredential(t);
|
||||||
|
const replies = await driving(credential, [
|
||||||
|
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-controller.push", arguments: { node: "anchor" } } },
|
||||||
|
]);
|
||||||
|
const result = replies[0].result;
|
||||||
|
assert.ok(!result.isError, JSON.stringify(replies[0]));
|
||||||
|
assert.deepEqual(JSON.parse(result.content[0].text), { told: "anchor" });
|
||||||
|
});
|
||||||
|
|
||||||
test("a host calls the mesh's own verb through the seat", async (t) => {
|
test("a host calls the mesh's own verb through the seat", async (t) => {
|
||||||
if (!url) return t.skip("MESH_TEST_NATS unset");
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
||||||
const credential = await aMeshAndACredential(t);
|
const credential = await aMeshAndACredential(t);
|
||||||
|
|||||||
Reference in New Issue
Block a user