serveTools now serves each tool on serve.<module>.<tool> instead of one tools.invoke that dispatched by name — so a module's account is scoped to serve.<module>.* and one module cannot answer another's calls. toolKey and invokeTool are the caller's side. A tool name need only be unique within its module now, not across the mesh.
112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
// The tool-serving harness. A module declares its tools through registerModuleTools; a runtime
|
|
// (the mesh's per-node tool host) collects the registrations and serves them through the command
|
|
// surface. HOW a tool is declared and served is settled and lives here; the tools themselves, and
|
|
// the API client they call, live in the module (novox/hq ADR 0044).
|
|
|
|
import type { ToolDefinition } from "../contracts/index.js";
|
|
import type { Broker } from "../messaging/index.js";
|
|
|
|
export type { ToolDefinition };
|
|
|
|
/** One tool invocation crossing the broker: which tool, and its arguments. */
|
|
export interface Invocation {
|
|
readonly tool: string;
|
|
readonly args: Readonly<Record<string, unknown>>;
|
|
}
|
|
|
|
/** A module contributes its tools as a function of its resolved environment. Returning [] (e.g.
|
|
* when a token is absent) is normal — the module simply exposes nothing until it can. */
|
|
export type ToolContributor = (env: NodeJS.ProcessEnv) => ToolDefinition[];
|
|
|
|
interface Registration {
|
|
readonly module: string;
|
|
readonly contribute: ToolContributor;
|
|
}
|
|
|
|
const registrations: Registration[] = [];
|
|
|
|
/**
|
|
* Declare the tools a module exposes. Called once, at module-tool load time, from the module's
|
|
* tools/ entrypoint. The client and the tool implementations are imported from the module itself.
|
|
*/
|
|
export function registerModuleTools(module: string, contribute: ToolContributor): void {
|
|
registrations.push({ module, contribute });
|
|
}
|
|
|
|
/**
|
|
* Collect every registered module's tools against an environment. The tool runtime calls this
|
|
* after loading the assigned modules' tool entrypoints. A module whose contributor throws is
|
|
* skipped with its error surfaced, never taking the others down.
|
|
*/
|
|
export function collectTools(env: NodeJS.ProcessEnv = process.env): { module: string; tools: ToolDefinition[] }[] {
|
|
return registrations.map(({ module, contribute }) => {
|
|
try {
|
|
return { module, tools: contribute(env) };
|
|
} catch (err) {
|
|
console.error(`[tools] ${module}: contributor failed, exposing none: ${err}`);
|
|
return { module, tools: [] };
|
|
}
|
|
});
|
|
}
|
|
|
|
/** List the tools every registered module exposes — for discovery, without invoking anything. */
|
|
export function listTools(env: NodeJS.ProcessEnv = process.env): { module: string; name: string; description: string }[] {
|
|
return collectTools(env).flatMap(({ module, tools }) =>
|
|
tools.map((t) => ({ module, name: t.name, description: t.description })),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Serve the registered modules' tools over the mesh broker — the tool runtime's core. It collects
|
|
* every module's tools, indexes them by name, and answers `tools.invoke` requests by running the
|
|
* named tool and returning its result. This is the stable half of serving; a specific tool's work
|
|
* (and its client) lives in its module. Returns a stop function.
|
|
*
|
|
* A duplicate tool name across modules is refused loudly rather than one silently shadowing the
|
|
* other — two tools answering one name is a fault, not a race to resolve.
|
|
*/
|
|
export async function serveTools(broker: Broker, env: NodeJS.ProcessEnv = process.env): Promise<() => void> {
|
|
// Each tool is served on its own key, namespaced by its module (novox/hq ADR 0052): a caller
|
|
// invokes `<module>.<tool>`, only the module that serves it answers, and the module's account is
|
|
// scoped to serve.<module>.* — so one module cannot answer another's calls. The module is the
|
|
// namespace, so a tool name need only be unique within its module, not across the whole mesh.
|
|
const stops: Array<() => void> = [];
|
|
for (const { module, tools } of collectTools(env)) {
|
|
const seen = new Set<string>();
|
|
for (const t of tools) {
|
|
if (seen.has(t.name)) {
|
|
throw new Error(`${module} exposes two tools named ${t.name} — refused`);
|
|
}
|
|
seen.add(t.name);
|
|
const stop = await broker.handle<Readonly<Record<string, unknown>>, unknown>(
|
|
toolKey(module, t.name),
|
|
(args) => t.run(args ?? {}),
|
|
);
|
|
stops.push(stop);
|
|
}
|
|
}
|
|
return () => {
|
|
for (const stop of stops) stop();
|
|
};
|
|
}
|
|
|
|
/** The broker key a tool is served on and invoked by — the module namespaces the tool (ADR 0052). */
|
|
export function toolKey(module: string, tool: string): string {
|
|
return `${module}.${tool}`;
|
|
}
|
|
|
|
/** Invoke a module's tool over the broker — the caller's side of serving. */
|
|
export async function invokeTool(
|
|
broker: Broker,
|
|
module: string,
|
|
tool: string,
|
|
args: Readonly<Record<string, unknown>> = {},
|
|
): Promise<unknown> {
|
|
return broker.request(toolKey(module, tool), args);
|
|
}
|
|
|
|
/** Testing/inspection: drop all registrations. */
|
|
export function resetTools(): void {
|
|
registrations.length = 0;
|
|
}
|