/**
* Registry + dispatcher for `AgentTool` instances (v0.11).
*
* const registry = new ToolRegistry([bookAppointment, cancelAppointment]);
* const descriptors = registry.describe(); // hand to AgentLLM
* const result = await registry.execute(toolCall, ctx); // string result
*
* Enforces name uniqueness at construction time so overlapping tools fail
* loudly instead of silently shadowing. `execute` validates the tool call's
* arguments against the Zod schema BEFORE invoking the tool body — a Zod
* failure is returned as a stringified error (the LLM reads it and re-asks
* the user). Only genuine invariant violations (unknown tool, thrown
* exception inside the tool body) are surfaced as thrown errors.
*/
import type { AgentTool, AgentToolDescriptor, ToolCall } from './types.js';
export interface ToolExecuteResult {
/** Correlates back to the assistant's `toolCalls[i].id`. */
toolCallId: string;
toolName: string;
/** Serialized output (JSON.stringify of the tool's return value, or an error message). */
content: string;
/** True when execution succeeded; false when Zod validation failed or the tool body threw. */
ok: boolean;
}
// `any` (not `unknown`) on the input generic because AgentTool
// is invariant on Input — a homogeneous map would refuse tools with
// different Zod input shapes. Safety is preserved: execute() validates
// via inputSchema.safeParse before invoking the tool body.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyToolFor = AgentTool;
export class ToolRegistry {
private readonly tools: Map>;
constructor(tools: ReadonlyArray>) {
this.tools = new Map();
for (const tool of tools) {
if (!tool.name) throw new Error('ToolRegistry: tool.name required');
if (!tool.description) throw new Error(`ToolRegistry: tool "${tool.name}" missing description`);
if (!tool.inputSchema) throw new Error(`ToolRegistry: tool "${tool.name}" missing inputSchema`);
if (this.tools.has(tool.name)) {
throw new Error(`ToolRegistry: duplicate tool name "${tool.name}"`);
}
this.tools.set(tool.name, tool);
}
}
/** Number of registered tools. */
get size(): number {
return this.tools.size;
}
/** True if the tool is registered. */
has(name: string): boolean {
return this.tools.has(name);
}
/** Provider-neutral descriptors ready for `AgentLLM.generate({ tools })`. */
describe(): AgentToolDescriptor[] {
return Array.from(this.tools.values(), (t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
}));
}
/**
* Validate + dispatch one tool call. Never throws for user-content
* problems (bad arguments, tool returns error string) — the LLM reads
* the result and continues. Throws only for programming errors
* (unknown tool, tool body itself throws unexpectedly).
*/
async execute(call: ToolCall, ctx: TContext): Promise {
const tool = this.tools.get(call.name);
if (!tool) {
return {
toolCallId: call.id,
toolName: call.name,
content: `Error: unknown tool "${call.name}"`,
ok: false,
};
}
const parsed = tool.inputSchema.safeParse(call.arguments);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
.join('; ');
return {
toolCallId: call.id,
toolName: call.name,
content: `Error: invalid arguments — ${issues}`,
ok: false,
};
}
try {
const result = await tool.execute(parsed.data, ctx);
return {
toolCallId: call.id,
toolName: call.name,
content: typeof result === 'string' ? result : JSON.stringify(result),
ok: true,
};
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return {
toolCallId: call.id,
toolName: call.name,
content: `Error: tool threw — ${message}`,
ok: false,
};
}
}
}