The console lists and calls a role's tools
seat:<seat>.<verb> addresses a role's tool (with @<node> for a node-scoped seat); the listing asks the mesh-controller seat's tools verb beside the modules and marks a role's tools; <seat>.<verb> resolves to the seat when the seat declares that verb, a module's own name otherwise (novox/hq ADR 0154).
This commit is contained in:
+11
-1
@@ -313,8 +313,18 @@ function eventSubject(type: string, self: string): string {
|
||||
}
|
||||
|
||||
/** A tool's subject. A bare name is this module's own tool; `<module>.<tool>` addresses
|
||||
* another's, which is how a request reaches a module that is not this one. */
|
||||
* another's, which is how a request reaches a module that is not this one; `seat:<seat>.<verb>`
|
||||
* addresses a role's tool, answered by whoever holds the seat (novox/hq ADR 0132) — with
|
||||
* `seat:<seat>.<verb>@<node>` for a node-scoped seat, whose tool carries the machine (design 33 §4). */
|
||||
function toolSubject(key: string, self: string): string {
|
||||
if (key.startsWith("seat:")) {
|
||||
const rest = key.slice("seat:".length);
|
||||
const dot = rest.indexOf(".");
|
||||
if (dot < 0) throw new Error(`"${key}" names a seat and no verb: seat:<seat>.<verb>`);
|
||||
const seat = rest.slice(0, dot);
|
||||
const [verb, node] = rest.slice(dot + 1).split("@", 2);
|
||||
return node ? `mesh.seat.${seat}.tool.${verb}.${node}` : `mesh.seat.${seat}.tool.${verb}`;
|
||||
}
|
||||
const dot = key.indexOf(".");
|
||||
if (dot < 0) return `mesh.mod.${self}.tool.${key}`;
|
||||
return `mesh.mod.${key.slice(0, dot)}.tool.${key.slice(dot + 1)}`;
|
||||
|
||||
+65
-9
@@ -26,13 +26,20 @@ import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js";
|
||||
/** Where the catalogue answers which modules the mesh holds. */
|
||||
const CATALOGUE_MODULES = "mesh-catalog.catalog_modules";
|
||||
|
||||
/** Where the mesh answers every role's tools, from its records: the mesh-controller seat's own
|
||||
* `tools` verb (novox/hq ADR 0154, design 33 §5). */
|
||||
const SEAT_TOOLS = "seat:mesh-controller.tools";
|
||||
|
||||
/** A tool as its module describes it. */
|
||||
export interface Tool {
|
||||
/** The module that serves it — or, for a role's tool, the seat. */
|
||||
module: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** The JSON schema of what it takes, as the module declared it. */
|
||||
input?: unknown;
|
||||
/** True for a role's tool: addressed to the seat, answered by whoever holds it (ADR 0132). */
|
||||
seat?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,10 +52,39 @@ export interface Tool {
|
||||
export interface Listing {
|
||||
tools: Tool[];
|
||||
/** Modules the catalogue holds whose runtime did not answer `tools`: not assigned, not up, or built
|
||||
* before the runtime answered it. Each may still be called by name. */
|
||||
* before the runtime answered it. Each may still be called by name. The mesh's own records are
|
||||
* listed here as `mesh-controller (seat)` when the control plane did not answer. */
|
||||
notAnswering: string[];
|
||||
}
|
||||
|
||||
/** The seats and the verbs each declares, from the last listing, so a call can tell a role's tool
|
||||
* from a module's when the two share a prefix (a module and a seat may share a name). */
|
||||
export type Seats = Map<string, Set<string>>;
|
||||
|
||||
/** The roles' tools, keyed the way `toolKey` names them. */
|
||||
export function seatsIn(have: Listing): Seats {
|
||||
const seats: Seats = new Map();
|
||||
for (const t of have.tools) {
|
||||
if (!t.seat) continue;
|
||||
if (!seats.has(t.module)) seats.set(t.module, new Set());
|
||||
seats.get(t.module)!.add(t.name);
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
/** The key a call uses for `<prefix>.<name>`: a role's when the prefix is a seat declaring that
|
||||
* verb, a module's otherwise. Both names for one capability are deliberate and bounded (ADR 0132);
|
||||
* the seat wins only for a verb it actually declares, so a module's own tool is never shadowed. */
|
||||
export function toolKey(name: string, seats?: Seats): string {
|
||||
if (name.startsWith("seat:")) return name;
|
||||
const dot = name.indexOf(".");
|
||||
if (dot < 0) return name;
|
||||
const prefix = name.slice(0, dot);
|
||||
const verb = name.slice(dot + 1);
|
||||
if (seats?.get(prefix)?.has(verb)) return `seat:${prefix}.${verb}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's credential, as `operator issue` prints it.
|
||||
*
|
||||
@@ -118,10 +154,17 @@ export async function connectAsTheConsole(path: string): Promise<{ bus: Broker;
|
||||
* refuses at once a request nothing serves, so the cost is bounded by the modules that are up.
|
||||
*/
|
||||
export async function toolsOn(bus: Broker): Promise<Listing> {
|
||||
const answered = await bus.request<Record<string, never>, { modules?: { module: string }[] }>(
|
||||
CATALOGUE_MODULES,
|
||||
{},
|
||||
);
|
||||
const [answered, roles] = await Promise.all([
|
||||
bus.request<Record<string, never>, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}),
|
||||
// The roles' tools, from the mesh's records (design 33 §5). Asked beside the modules rather
|
||||
// than first: a control plane that is restarting must not hide every module's tools with it.
|
||||
bus
|
||||
.request<Record<string, never>, { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[] }>(
|
||||
SEAT_TOOLS,
|
||||
{},
|
||||
)
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string");
|
||||
|
||||
const asked = await Promise.allSettled(
|
||||
@@ -129,6 +172,18 @@ export async function toolsOn(bus: Broker): Promise<Listing> {
|
||||
);
|
||||
const tools: Tool[] = [];
|
||||
const notAnswering: string[] = [];
|
||||
if (roles) {
|
||||
for (const s of roles.seats ?? []) {
|
||||
// A node-scoped seat's tool is asked of one machine, and the listing does not know which;
|
||||
// those wait for a caller naming the node (`seat:<seat>.<verb>@<node>`).
|
||||
if (s.scope === "node") continue;
|
||||
for (const t of s.tools ?? []) {
|
||||
tools.push({ module: s.seat, name: t.name, description: t.description, input: t.input, seat: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notAnswering.push("mesh-controller (seat)");
|
||||
}
|
||||
asked.forEach((outcome, i) => {
|
||||
const module = names[i]!;
|
||||
if (outcome.status === "fulfilled" && Array.isArray(outcome.value?.tools)) {
|
||||
@@ -146,13 +201,13 @@ 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
|
||||
* permits — one vocabulary, so a refusal names the thing they asked for. */
|
||||
export async function callTool(bus: Broker, key: string, args: unknown): Promise<unknown> {
|
||||
export async function callTool(bus: Broker, key: string, args: unknown, seats?: Seats): Promise<unknown> {
|
||||
if (!key.includes(".")) {
|
||||
throw new Error(
|
||||
`"${key}" does not name a tool: write <module>.<tool>, as \`mesh tools\` lists them`,
|
||||
);
|
||||
}
|
||||
return bus.request<unknown, unknown>(key, args ?? {});
|
||||
return bus.request<unknown, unknown>(toolKey(key, seats), args ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +220,9 @@ export async function callTool(bus: Broker, key: string, args: unknown): Promise
|
||||
export function whyItFailed(key: string, err: unknown): string {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (/no responders|503/i.test(message)) {
|
||||
return `nothing serves ${key}. The module may not be assigned to any machine, or it is down — ` +
|
||||
"`mesh tools` lists what answered.";
|
||||
return `nothing serves ${key}. The module may not be assigned to any machine, or it is down` +
|
||||
(key.startsWith("seat:") ? ", or nothing holds that seat" : "") +
|
||||
" — `mesh tools` lists what answered.";
|
||||
}
|
||||
if (/permissions violation|authorization/i.test(message)) {
|
||||
return `this account may not call ${key}. What it may call was fixed when it was issued — a ` +
|
||||
|
||||
+15
-5
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import type { Broker } from "@novox/mesh-sdk/messaging";
|
||||
|
||||
import { callTool, toolsOn, whyItFailed, type Listing } from "./client.js";
|
||||
import { callTool, seatsIn, 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
|
||||
* rather than discovering it through a shape it did not expect. */
|
||||
@@ -67,6 +67,16 @@ export function mcpSurface(bus: Broker, who: string): Surface {
|
||||
}
|
||||
return known.listing;
|
||||
};
|
||||
// The roles the last listing knew, so `<seat>.<verb>` resolves to the seat. Fetched once if a
|
||||
// call arrives before any list did; a listing that failed leaves no roles, and the name is then
|
||||
// a module's, which is the right fallback for a mesh whose control plane is away.
|
||||
const roles = async (): Promise<Seats | undefined> => {
|
||||
try {
|
||||
return seatsIn(await listing());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async handle(request) {
|
||||
@@ -86,9 +96,9 @@ export function mcpSurface(bus: Broker, who: string): Surface {
|
||||
`These are the tools of a Novox mesh, reached as ${who}. Every call goes to the module ` +
|
||||
`that serves it; what may be called was fixed when this account was issued, so a ` +
|
||||
`refusal means the account, not the tool. The list is what the running modules ` +
|
||||
`answered; a module that did not answer is named in the list's _meta and can still be ` +
|
||||
`called by <module>.<tool>. The mesh's own verbs (status, push, assign) are not served ` +
|
||||
`on the bus yet.`,
|
||||
`answered, plus every role's tools from the mesh's records — the mesh's own verbs ` +
|
||||
`(mesh-controller.status, .push, .assign …) among them; a module that did not answer ` +
|
||||
`is named in the list's _meta and can still be called by <module>.<tool>.`,
|
||||
});
|
||||
|
||||
case "notifications/initialized":
|
||||
@@ -122,7 +132,7 @@ export function mcpSurface(bus: Broker, who: string): Surface {
|
||||
const name = String(request.params?.name ?? "");
|
||||
const args = request.params?.arguments ?? {};
|
||||
try {
|
||||
const result = await callTool(bus, name, args);
|
||||
const result = await callTool(bus, name, args, await roles());
|
||||
// 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
|
||||
// somebody else's answer.
|
||||
|
||||
+7
-2
@@ -27,6 +27,7 @@ import {
|
||||
connectAs,
|
||||
connectAsTheConsole,
|
||||
credentialFrom,
|
||||
seatsIn,
|
||||
toolsOn,
|
||||
whyItFailed,
|
||||
type Listing,
|
||||
@@ -135,7 +136,8 @@ function printListing(have: Listing, who?: string): void {
|
||||
// "not yours", and those need different people to fix them.
|
||||
for (const t of have.tools) {
|
||||
const name = `${t.module}.${t.name}`;
|
||||
console.log(t.description ? `${name.padEnd(36)} ${t.description}` : name);
|
||||
const line = t.description ? `${name.padEnd(36)} ${t.description}` : name;
|
||||
console.log(t.seat ? `${line} (a role's tool: answered by whoever holds the ${t.module} seat)` : line);
|
||||
}
|
||||
if (have.notAnswering.length > 0) {
|
||||
console.log(
|
||||
@@ -169,7 +171,10 @@ async function calling(bus: Broker, args: string[]): Promise<number> {
|
||||
const parsed = await argumentsFrom(args);
|
||||
if (parsed === undefined) return 1;
|
||||
try {
|
||||
const answer = await callTool(bus, key, parsed);
|
||||
// `<seat>.<verb>` reaches the role when the mesh lists that verb for the seat; `seat:` says so
|
||||
// outright and asks nothing first.
|
||||
const roles = key.startsWith("seat:") ? undefined : seatsIn(await toolsOn(bus).catch(() => ({ tools: [], notAnswering: [] })));
|
||||
const answer = await callTool(bus, key, parsed, roles);
|
||||
console.log(JSON.stringify(answer, null, 2));
|
||||
return 0;
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user