Files
mesh-sdk/src/tools/index.ts
T
jochen e63dfc68cd collectToolsEach: each registration is given its own environment (hq ADR 0192)
A node's runtime serves many modules in one process; a module's composed paths and words must
reach its own tools and no other's. The runtime knows which bundle made which registration, so
it says which environment each contributor gets. collectTools(env) is unchanged. 0.1.3.
2026-10-03 15:28:48 +02:00

133 lines
5.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 0039).
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: [] };
}
});
}
/**
* Collect every registered module's tools, each contributor given ITS OWN environment (novox/hq ADR
* 0192): `envFor` is asked with the registration's module and its index in registration order, and
* whatever it answers is all that contributor sees. A node's runtime serves many modules in one
* process, and a module's paths and words must reach its own tools and no other's — so the runtime
* that knows which bundle made which registration says which environment each gets, and the shared
* `collectTools(env)` stays what it was for a runtime serving one module.
*/
export function collectToolsEach(
envFor: (module: string, index: number) => NodeJS.ProcessEnv,
): { module: string; tools: ToolDefinition[] }[] {
return registrations.map(({ module, contribute }, index) => {
try {
return { module, tools: contribute(envFor(module, index)) };
} 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 0047): 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 0047). */
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;
}