A tool call names the machine it is for, every answer says which machine answered, and a holder's

runtime serves its seat's verbs (novox/hq ADR 0159)

A module on several machines served one subject in one queue group, so a call reached whichever
instance answered first and nobody could ask one machine's instance. Now each instance also serves
its subject with its machine as the last token, `<module>.<tool>@<node>` addresses it, and every
answer carries the machine that gave it: the console lists `node` on every module tool, strips it
into the subject, and appends "answered by <node>" to the answer; `mesh call` prints it.

And a seat's verbs are served by whoever claims the seat: the credential names the claimed seats
and the verbs each promises, the runtime serves each verb with the module's tool of the same name
on the seat's own subject, and the bus — which admits only the holder's subscription — decides where
that serving is real. Design 33 §3 and §4, built.
This commit is contained in:
2026-10-01 13:58:22 +02:00
parent 6a91d144b3
commit c65f1993ba
8 changed files with 227 additions and 63 deletions
+86 -32
View File
@@ -40,6 +40,25 @@ export interface Credential {
module?: string; module?: string;
user?: string; user?: string;
password?: string; password?: string;
/** The seats this module claims, with the verbs each promises (novox/hq ADR 0159). The runtime
* serves each claimed seat's verbs with its tools of the same name; the bus admits only the
* holder's subscription, so claiming and not holding costs a refused subscription and nothing else. */
claims?: { seat: string; scope?: string; serves?: string[] }[];
}
/** What a tool call answers: the module's own result, and which machine answered it
* (novox/hq ADR 0159) — a module on several machines is otherwise an answer from nowhere. */
export interface Answered<Res> {
result: Res;
node?: string;
}
/** The bus as the runtime sees it: the sdk's contract, and the two things only the runtime needs —
* an answer that says which machine gave it, and serving a subject that is not a module's own tool
* (a seat's verb). */
export interface RuntimeBroker extends Broker {
ask<Req, Res>(key: string, body: Req): Promise<Answered<Res>>;
handleSubject<Req, Res>(subject: string, handler: (body: Req) => Promise<Res>): Promise<() => void>;
} }
/** Whether a connection failure is worth retrying, or is a fact about this configuration that /** Whether a connection failure is worth retrying, or is a fact about this configuration that
@@ -69,7 +88,7 @@ export function fatalBrokerReason(err: unknown): string | null {
export async function connectNats( export async function connectNats(
target: string | Credential, target: string | Credential,
opts: { module?: string } = {}, opts: { module?: string } = {},
): Promise<Broker> { ): Promise<RuntimeBroker> {
const cred: Credential = typeof target === "string" ? { url: target } : target; const cred: Credential = typeof target === "string" ? { url: target } : target;
const self = cred.module ?? opts.module; const self = cred.module ?? opts.module;
if (!self) { if (!self) {
@@ -102,36 +121,19 @@ export async function connectNats(
let reading: Awaited<ReturnType<Awaited<ReturnType<typeof js.consumers.get>>["consume"]>> | undefined; let reading: Awaited<ReturnType<Awaited<ReturnType<typeof js.consumers.get>>["consume"]>> | undefined;
let closed = false; let closed = false;
return { const node = cred.node;
/**
* Ask one question and await one answer.
*
* Core NATS request/reply, not JetStream: a tool call must never be persisted (design 25 §3),
* and a lost one is a timeout the caller already handles. The reply travels on the inbox the
* request carries, which the responder may answer because its account has `allow_responses`
* — one reply to a message it actually received, and nothing wider.
*/
async request<Req, Res>(key: string, body: Req): Promise<Res> {
const msg = await conn.request(toolSubject(key, self), sc.encode(JSON.stringify(body)), {
timeout: REQUEST_TIMEOUT_MS,
});
const reply = JSON.parse(sc.decode(msg.data)) as { result?: Res; error?: string };
if (reply.error) throw new Error(reply.error);
return reply.result as Res;
},
/** /** Answer one subject with one handler, and say which machine answered (novox/hq ADR 0159). */
* Answer a question. const answerOn = <Req, Res>(
* subject: string,
* A queue group, so several nodes may serve one tool and exactly one of them answers each queue: string | undefined,
* call. handler: (body: Req) => Promise<Res>,
*/ ): (() => void) => {
async handle<Req, Res>(key: string, handler: (body: Req) => Promise<Res>): Promise<() => void> { const sub = conn.subscribe(subject, queue ? { queue } : {});
const sub = conn.subscribe(toolSubject(key, self), { queue: `serve.${self}` });
subs.push(sub); subs.push(sub);
void (async () => { void (async () => {
for await (const msg of sub) { for await (const msg of sub) {
let reply: { result?: Res; error?: string }; let reply: { result?: Res; error?: string; node?: string };
try { try {
reply = { result: await handler(JSON.parse(sc.decode(msg.data)) as Req) }; reply = { result: await handler(JSON.parse(sc.decode(msg.data)) as Req) };
} catch (err) { } catch (err) {
@@ -139,12 +141,52 @@ export async function connectNats(
// different failure from a tool nobody serves, and only one of them is worth retrying. // different failure from a tool nobody serves, and only one of them is worth retrying.
reply = { error: err instanceof Error ? err.message : String(err) }; reply = { error: err instanceof Error ? err.message : String(err) };
} }
if (node) reply.node = node;
msg.respond(sc.encode(JSON.stringify(reply))); msg.respond(sc.encode(JSON.stringify(reply)));
} }
})(); })();
return () => { return () => sub.unsubscribe();
sub.unsubscribe();
}; };
/**
* Ask one question and await one answer, with the machine that gave it.
*
* Core NATS request/reply, not JetStream: a tool call must never be persisted (design 25 §3),
* and a lost one is a timeout the caller already handles. The reply travels on the inbox the
* request carries, which the responder may answer because its account has `allow_responses`
* — one reply to a message it actually received, and nothing wider.
*/
const ask = async <Req, Res>(key: string, body: Req): Promise<Answered<Res>> => {
const msg = await conn.request(toolSubject(key, self), sc.encode(JSON.stringify(body)), {
timeout: REQUEST_TIMEOUT_MS,
});
const reply = JSON.parse(sc.decode(msg.data)) as { result?: Res; error?: string; node?: string };
if (reply.error) throw new Error(reply.error);
return { result: reply.result as Res, node: reply.node };
};
return {
async request<Req, Res>(key: string, body: Req): Promise<Res> {
return (await ask<Req, Res>(key, body)).result;
},
ask,
/**
* Answer a question, two ways (novox/hq ADR 0159): on the module's subject in a queue group,
* so several machines may serve one tool and exactly one of them answers each call; and on the
* same subject with this machine as its last token, so a caller that names the machine reaches
* this instance and no other. A runtime that does not know its machine serves only the first,
* which is how it always behaved.
*/
async handle<Req, Res>(key: string, handler: (body: Req) => Promise<Res>): Promise<() => void> {
const stops = [answerOn(toolSubject(key, self), `serve.${self}`, handler)];
if (node) stops.push(answerOn(`${toolSubject(key, self)}.${node}`, undefined, handler));
return () => stops.forEach((stop) => stop());
},
async handleSubject<Req, Res>(subject: string, handler: (body: Req) => Promise<Res>): Promise<() => void> {
return answerOn(subject, undefined, handler);
}, },
/** /**
@@ -325,9 +367,21 @@ function toolSubject(key: string, self: string): string {
const [verb, node] = rest.slice(dot + 1).split("@", 2); const [verb, node] = rest.slice(dot + 1).split("@", 2);
return node ? `mesh.seat.${seat}.tool.${verb}.${node}` : `mesh.seat.${seat}.tool.${verb}`; return node ? `mesh.seat.${seat}.tool.${verb}.${node}` : `mesh.seat.${seat}.tool.${verb}`;
} }
const dot = key.indexOf("."); // `<module>.<tool>@<node>` names the machine (novox/hq ADR 0159): the same subject with the
if (dot < 0) return `mesh.mod.${self}.tool.${key}`; // machine as its last token, which is what that instance serves beside the queue.
return `mesh.mod.${key.slice(0, dot)}.tool.${key.slice(dot + 1)}`; const [name, node] = key.split("@", 2);
const dot = name.indexOf(".");
const base = dot < 0
? `mesh.mod.${self}.tool.${name}`
: `mesh.mod.${name.slice(0, dot)}.tool.${name.slice(dot + 1)}`;
return node ? `${base}.${node}` : base;
}
/** A seat's verb, as its holder serves it: flat for a mesh seat, carrying the machine for a
* node-scoped one (design 33 §4) — the same shape the controller grants. */
export function seatToolSubject(seat: string, verb: string, scope: string | undefined, node: string | undefined): string {
const base = `mesh.seat.${seat}.tool.${verb}`;
return scope === "node" && node ? `${base}.${node}` : base;
} }
function normalizeFingerprint(fingerprint: string): string { function normalizeFingerprint(fingerprint: string): string {
+15 -5
View File
@@ -20,7 +20,7 @@ import { readFile } from "node:fs/promises";
import type { Broker } from "@novox/mesh-sdk/messaging"; import type { Broker } from "@novox/mesh-sdk/messaging";
import { connectNats, type Credential } from "./broker-nats.js"; import { connectNats, type Answered, type Credential } from "./broker-nats.js";
import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js"; import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js";
/** Where the catalogue answers which modules the mesh holds. */ /** Where the catalogue answers which modules the mesh holds. */
@@ -201,13 +201,23 @@ export async function toolsOn(bus: Broker): Promise<Listing> {
/** Call one tool. The key is `<module>.<tool>`, which is what a person types and what the account /** Call one tool. The key is `<module>.<tool>`, which is what a person types and what the account
* permits — one vocabulary, so a refusal names the thing they asked for. */ * permits — one vocabulary, so a refusal names the thing they asked for. */
export async function callTool(bus: Broker, key: string, args: unknown, seats?: Seats): Promise<unknown> { /** Call a tool and learn which machine answered (novox/hq ADR 0159). `<module>.<tool>@<node>` asks
if (!key.includes(".")) { * the instance on one machine; without it, whichever instance answers first does, and the answer
* says which. */
export async function callTool(bus: Broker, key: string, args: unknown, seats?: Seats): Promise<Answered<unknown>> {
const at = key.indexOf("@");
const name = at < 0 ? key : key.slice(0, at);
const node = at < 0 ? "" : key.slice(at + 1);
if (!name.includes(".")) {
throw new Error( throw new Error(
`"${key}" does not name a tool: write <module>.<tool>, as \`mesh tools\` lists them`, `"${key}" does not name a tool: write <module>.<tool>, as \`mesh tools\` lists them, ` +
"or <module>.<tool>@<node> for the instance on one machine",
); );
} }
return bus.request<unknown, unknown>(toolKey(key, seats), args ?? {}); const resolved = toolKey(name, seats) + (node ? `@${node}` : "");
const asking = bus as Broker & { ask?: <Req, Res>(k: string, b: Req) => Promise<Answered<Res>> };
if (typeof asking.ask === "function") return asking.ask<unknown, unknown>(resolved, args ?? {});
return { result: await bus.request<unknown, unknown>(resolved, args ?? {}) };
} }
/** /**
+5 -1
View File
@@ -29,6 +29,9 @@ import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { connectNats, fatalBrokerReason as fatalNatsReason, type Credential } from "./broker-nats.js"; import { connectNats, fatalBrokerReason as fatalNatsReason, type Credential } from "./broker-nats.js";
import { runTools } from "./runtime.js"; import { runTools } from "./runtime.js";
/** The credential this process connected with, for what it says beyond the connection (ADR 0159). */
let lastCredential: Credential | undefined;
import { invokeTool } from "@novox/mesh-sdk/tools"; import { invokeTool } from "@novox/mesh-sdk/tools";
import { useBroker } from "@novox/mesh-sdk/messaging"; import { useBroker } from "@novox/mesh-sdk/messaging";
import type { Broker } from "@novox/mesh-sdk/messaging"; import type { Broker } from "@novox/mesh-sdk/messaging";
@@ -51,6 +54,7 @@ async function connectBroker(): Promise<Broker> {
let credential: Credential; let credential: Credential;
try { try {
credential = JSON.parse(readFileSync(file, "utf8")) as Credential; credential = JSON.parse(readFileSync(file, "utf8")) as Credential;
lastCredential = credential;
} catch (err) { } catch (err) {
console.error(`mesh-tools: cannot read the broker credential at ${file}: ${err}`); console.error(`mesh-tools: cannot read the broker credential at ${file}: ${err}`);
process.exit(1); process.exit(1);
@@ -121,7 +125,7 @@ async function serve(): Promise<void> {
.filter(Boolean); .filter(Boolean);
const broker = await connectBrokerPatiently(); const broker = await connectBrokerPatiently();
const stop = await runTools({ broker, moduleEntrypoints }); const stop = await runTools({ broker, moduleEntrypoints, credential: lastCredential });
const shutdown = async (): Promise<void> => { const shutdown = async (): Promise<void> => {
stop(); stop();
+32 -10
View File
@@ -118,9 +118,11 @@ export function mcpSurface(bus: Broker, who: string): Surface {
tools: have.tools.map((t) => ({ tools: have.tools.map((t) => ({
name: `${t.module}.${t.name}`, name: `${t.module}.${t.name}`,
description: t.description ?? `${t.name}, served by ${t.module}`, description: t.description ?? `${t.name}, served by ${t.module}`,
// The module's own schema, passed through. An empty object is a tool that takes // The module's own schema, passed through — with `node`, the machine to ask when
// nothing, which is a real answer and not a missing one. // the module runs on several (novox/hq ADR 0159); a seat's verb takes none, the
inputSchema: asSchema(t.input), // seat's scope decides. An empty object is a tool that takes nothing, which is a
// real answer and not a missing one.
inputSchema: t.seat ? asSchema(t.input) : withNode(asSchema(t.input)),
})), })),
// Silence, named (design 34 §3): the modules the catalogue holds and nothing answered // Silence, named (design 34 §3): the modules the catalogue holds and nothing answered
// for. Not a tool, so not in `tools`; not dropped either. // for. Not a tool, so not in `tools`; not dropped either.
@@ -129,16 +131,23 @@ export function mcpSurface(bus: Broker, who: string): Surface {
} }
case "tools/call": { case "tools/call": {
const name = String(request.params?.name ?? ""); const given = String(request.params?.name ?? "");
const args = request.params?.arguments ?? {}; 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
// module's arguments (novox/hq ADR 0159).
const node = typeof args.node === "string" && args.node !== "" ? args.node : "";
delete args.node;
const name = node && !given.includes("@") ? `${given}@${node}` : given;
try { try {
const result = await callTool(bus, name, args, await roles()); const { result, node: answeredBy } = await callTool(bus, name, args, await roles());
// 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. // somebody else's answer. Which machine answered follows it as its own line.
return answer(request.id, { const content: { type: string; text: string }[] = [
content: [{ type: "text", text: JSON.stringify(result, null, 2) }], { type: "text", text: JSON.stringify(result, null, 2) },
}); ];
if (answeredBy) content.push({ type: "text", text: `answered by ${answeredBy}` });
return answer(request.id, { content });
} catch (e) { } catch (e) {
// **An error the agent can act on, not a stack.** isError rather than a protocol // **An error the agent can act on, not a stack.** isError rather than a protocol
// failure, because the call was well-formed and the mesh answered it — with a refusal, // failure, because the call was well-formed and the mesh answered it — with a refusal,
@@ -212,3 +221,16 @@ async function* lines(): AsyncGenerator<string> {
} }
if (buffered.trim() !== "") yield buffered.trim(); if (buffered.trim() !== "") yield buffered.trim();
} }
/** Every module tool takes an optional `node`: the machine to ask when the module runs on several
* (novox/hq ADR 0159). Added to the listing, stripped before the call, never seen by the module. */
function withNode(schema: Record<string, unknown>): Record<string, unknown> {
const properties = { ...((schema.properties as Record<string, unknown> | undefined) ?? {}) };
if (!("node" in properties)) {
properties.node = {
type: "string",
description: "the machine to ask, when this module runs on several; else whichever answers, and the answer says which",
};
}
return { ...schema, type: "object", properties };
}
+3 -2
View File
@@ -174,8 +174,9 @@ async function calling(bus: Broker, args: string[]): Promise<number> {
// `<seat>.<verb>` reaches the role when the mesh lists that verb for the seat; `seat:` says so // `<seat>.<verb>` reaches the role when the mesh lists that verb for the seat; `seat:` says so
// outright and asks nothing first. // outright and asks nothing first.
const roles = key.startsWith("seat:") ? undefined : seatsIn(await toolsOn(bus).catch(() => ({ tools: [], notAnswering: [] }))); const roles = key.startsWith("seat:") ? undefined : seatsIn(await toolsOn(bus).catch(() => ({ tools: [], notAnswering: [] })));
const answer = await callTool(bus, key, parsed, roles); const { result, node } = await callTool(bus, key, parsed, roles);
console.log(JSON.stringify(answer, null, 2)); console.log(JSON.stringify(result, null, 2));
if (node) console.error(`answered by ${node}`);
return 0; return 0;
} catch (e) { } catch (e) {
console.error(whyItFailed(key, e)); console.error(whyItFailed(key, e));
+35
View File
@@ -8,6 +8,7 @@ import { resolve } from "node:path";
import { useBroker } from "@novox/mesh-sdk/messaging"; import { useBroker } from "@novox/mesh-sdk/messaging";
import { collectTools, serveTools, listTools, toolKey } from "@novox/mesh-sdk/tools"; import { collectTools, serveTools, listTools, toolKey } from "@novox/mesh-sdk/tools";
import type { Broker } from "@novox/mesh-sdk/messaging"; import type { Broker } from "@novox/mesh-sdk/messaging";
import { seatToolSubject, type Credential, type RuntimeBroker } from "./broker-nats.js";
/** /**
* The one verb every module's runtime answers for it (novox/hq ADR 0152, design 34 §3): the * The one verb every module's runtime answers for it (novox/hq ADR 0152, design 34 §3): the
@@ -27,6 +28,9 @@ export interface RuntimeOptions {
broker: Broker; broker: Broker;
/** Absolute paths to the assigned modules' compiled tool entrypoints (e.g. .../umami/tools/index.js). */ /** Absolute paths to the assigned modules' compiled tool entrypoints (e.g. .../umami/tools/index.js). */
moduleEntrypoints: string[]; moduleEntrypoints: string[];
/** The credential the mesh delivered, for what it says about the seats this module claims
* (novox/hq ADR 0159). Absent for a runtime started by hand, which then serves no seat. */
credential?: Credential;
} }
/** Load the modules, bind the broker, and serve. Returns a stop function that unhooks serving. */ /** Load the modules, bind the broker, and serve. Returns a stop function that unhooks serving. */
@@ -65,7 +69,38 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> {
} }
console.log(`[mesh-tools] serving ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ") || "(none)"}`); console.log(`[mesh-tools] serving ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ") || "(none)"}`);
stops.push(await serveClaimedSeats(opts.broker as RuntimeBroker, opts.credential));
return () => { return () => {
for (const s of stops) s(); for (const s of stops) s();
}; };
} }
/**
* Holding a seat means serving its tools (design 33 §3, novox/hq ADR 0159). The credential names the
* seats this module claims and the verbs each promises; each verb is served on the seat's own
* subject by the module's tool of the same name. Whether this instance *holds* the seat is the bus's
* to decide: only the holder's account may subscribe the seat's subjects, so a claimant that does not
* hold it here is refused the subscription and serves nothing — never a failure of its own tools.
*/
async function serveClaimedSeats(broker: RuntimeBroker, credential?: Credential): Promise<() => void> {
const claims = credential?.claims ?? [];
if (claims.length === 0 || typeof broker.handleSubject !== "function") return () => {};
const byName = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
for (const { tools } of collectTools()) {
for (const t of tools) byName.set(t.name, (args) => t.run(args));
}
const stops: (() => void)[] = [];
for (const claim of claims) {
for (const verb of claim.serves ?? []) {
const run = byName.get(verb);
if (!run) {
console.log(`[mesh-tools] claims ${claim.seat} and has no tool named ${verb}, which that seat promises; not served`);
continue;
}
const subject = seatToolSubject(claim.seat, verb, claim.scope, credential?.node);
stops.push(await broker.handleSubject(subject, run));
console.log(`[mesh-tools] serving ${claim.seat}'s ${verb} on ${subject}, admitted where this module holds the seat`);
}
}
return () => stops.forEach((s) => s());
}
+38 -3
View File
@@ -86,7 +86,7 @@ test("a person calls a tool and gets the module's own answer, unshaped", async (
const person = await connectNats({ url, module: "person.ada" }); const person = await connectNats({ url, module: "person.ada" });
try { try {
const answer = await callTool(person, "shop.price", { of: "a hat" }); const answer = await callTool(person, "shop.price", { of: "a hat" });
assert.deepEqual(answer, { of: "a hat", cost: 12 }); assert.deepEqual(answer.result, { of: "a hat", cost: 12 });
} finally { } finally {
await person.close(); await person.close();
await mesh.close(); await mesh.close();
@@ -140,11 +140,46 @@ test("a role's tool is reached through the seat, and a module's own name is neve
assert.equal(toolKey("mesh-controller.other", roles), "mesh-controller.other", "a verb the seat does not declare is a module's"); assert.equal(toolKey("mesh-controller.other", roles), "mesh-controller.other", "a verb the seat does not declare is a module's");
assert.equal(toolKey("shop.price", roles), "shop.price"); assert.equal(toolKey("shop.price", roles), "shop.price");
const answer = await callTool(person, "mesh-controller.status", {}, roles); const answer = await callTool(person, "mesh-controller.status", {}, roles);
assert.deepEqual(answer, { output: "all quiet", ok: true }); assert.deepEqual(answer.result, { output: "all quiet", ok: true });
const direct = await callTool(person, "seat:mesh-controller.status", {}); const direct = await callTool(person, "seat:mesh-controller.status", {});
assert.deepEqual(direct, { output: "all quiet", ok: true }); assert.deepEqual(direct.result, { output: "all quiet", ok: true });
} finally { } finally {
await person.close(); await person.close();
await mesh.close(); await mesh.close();
} }
}); });
// A module on two machines (novox/hq ADR 0159): a call names the machine and reaches that instance
// and no other; a call that names none reaches one of them and says which; a seat's verb is served
// by the claimant's tool of the same name on the seat's own subject.
test("a tool call names the machine it is for, and every answer says which machine answered", async (t) => {
if (!url) return t.skip("MESH_TEST_NATS unset");
const onAnchor = await connectNats({ url: url!, module: "store", node: "anchor" });
const onHome = await connectNats({ url: url!, module: "store", node: "home-server" });
const asker = await connectNats({ url: url!, module: "console", node: "workstation" });
try {
await onAnchor.handle("databases", async () => ({ at: "anchor" }));
await onHome.handle("databases", async () => ({ at: "home-server" }));
const home = await callTool(asker, "store.databases@home-server", {});
assert.deepEqual(home.result, { at: "home-server" });
assert.equal(home.node, "home-server");
const anchor = await callTool(asker, "store.databases@anchor", {});
assert.deepEqual(anchor.result, { at: "anchor" });
assert.equal(anchor.node, "anchor");
const whichever = await callTool(asker, "store.databases", {});
assert.ok(["anchor", "home-server"].includes(whichever.node ?? ""), `an unnamed call still says who answered: ${whichever.node}`);
assert.deepEqual(whichever.result, { at: whichever.node });
// A seat's verb, served by the claimant on the seat's subject; a mesh seat's is flat.
await onAnchor.handleSubject("mesh.seat.mesh-store.tool.databases", async () => ({ seat: "mesh-store", at: "anchor" }));
const viaSeat = await callTool(asker, "seat:mesh-store.databases", {});
assert.deepEqual(viaSeat.result, { seat: "mesh-store", at: "anchor" });
assert.equal(viaSeat.node, "anchor");
} finally {
await onAnchor.close();
await onHome.close();
await asker.close();
}
});
+5 -2
View File
@@ -99,8 +99,11 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => {
"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[1];
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. // A module's bare property map arrives as a schema an agent can read, its words kept — and
assert.deepEqual(price.inputSchema, { type: "object", properties: { of: { type: "string" } } }); // `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.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");
// 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"]);