/// /** * Agentic Tools - Elegant utilities for creating LLM tool handlers * * Design goals: * - Define schema ONCE, derive everything from it * - Single-call tool creation (no currying) * - Type-safe: dedupe/timestamp fields checked against schema * * Usage: * ```typescript * import { defineItemSchema, listTool } from "./util/agentic-tools.ts"; * * // 1. Define schema ONCE * const MembershipSchema = defineItemSchema({ * hotelBrand: { type: "string", description: "Hotel chain name" }, * membershipNumber: { type: "string", description: "Membership number" }, * tier: { type: "string", description: "Status tier" }, * }, ["hotelBrand", "membershipNumber"]); // required fields * * // 2. Create tool in ONE call - dedupe fields are type-checked! * const reportMembership = listTool(MembershipSchema, { * items: memberships, * dedupe: ["hotelBrand", "membershipNumber"], // ✓ TypeScript checks these! * // dedupe: ["typo"], // ✗ TypeScript error! * }); * * // 3. Use in additionalTools * additionalTools: { * reportMembership: { * description: "Report a found membership", * handler: reportMembership, * }, * } * ``` */ import { handler, JSONSchema, Writable } from "commontools"; // ============================================================================= // SCHEMA UTILITIES // ============================================================================= /** * Property definition for a schema field. */ export interface PropertyDef { type: "string" | "number" | "boolean" | "array" | "object"; description: string; items?: PropertyDef; properties?: Record; } /** * A typed schema that preserves field names for type checking. * This allows listTool to verify dedupe/timestamp fields at compile time. */ export interface TypedSchema { type: "object"; properties: Record; required: string[]; // Phantom type to carry field names through the type system __fields?: Fields; } /** * Defines an item schema for LLM tools. * * Automatically adds: * - `result: { type: "object", asCell: true }` for tool response * * The returned schema preserves field names for type-safe listTool usage. * * @param fields - The data fields the LLM should provide * @param required - Array of required field names * @returns A typed schema ready for handler use */ export function defineItemSchema>( fields: T, required: (keyof T)[], ): TypedSchema> { return { type: "object", properties: { ...fields, // Automatically add result cell for tool response result: { type: "object", asCell: true }, }, required: required as string[], } as TypedSchema>; } // ============================================================================= // LIST TOOL - Add items to a list with deduplication // ============================================================================= /** * Configuration for listTool - generic over field names for type safety. * Note: items is typed as `any` to accept pattern input cells which have * different types than plain Cell after CTS transformation. */ export interface ListToolConfig { /** Cell containing the list of items */ items: any; // Accepts pattern input cells (OpaqueCell, etc.) /** Fields that make up the dedup key - MUST be valid field names from schema */ dedupe: Fields[]; /** Prefix for generated IDs (default: "item") */ idPrefix?: string; /** Field name for the timestamp - MUST be a valid field name or new field */ timestamp?: string; } // State schema for list tools const LIST_TOOL_STATE_SCHEMA = { type: "object", properties: { items: { type: "array", items: {}, asCell: true }, dedupeFields: { type: "array", items: { type: "string" } }, idPrefix: { type: "string" }, timestampField: { type: "string" }, }, required: ["items", "dedupeFields", "idPrefix", "timestampField"], } as const satisfies JSONSchema; /** * Creates a tool handler that adds items to a list with deduplication. * * Type-safe: dedupe fields are checked against the schema at compile time. * * @param schema - Typed schema created with defineItemSchema() * @param config - Tool configuration with type-checked field names * @returns A bound handler ready for use in additionalTools */ export function listTool( schema: TypedSchema, config: ListToolConfig, ) { const { items, dedupe, idPrefix = "item", timestamp = "savedAt" } = config; // Convert TypedSchema to JSONSchema by extracting the relevant properties // TypedSchema is structurally compatible with JSONSchema, just with extra phantom type const jsonSchema: JSONSchema = { type: schema.type, properties: schema.properties, required: schema.required, }; return handler( jsonSchema, LIST_TOOL_STATE_SCHEMA, (input: Record, state: { items: Writable; dedupeFields: readonly string[]; idPrefix: string; timestampField: string; }) => { const currentItems = state.items.get() || []; // Generate dedup key const dedupeKey = state.dedupeFields .map((field) => String(input[field] ?? "")) .join(":") .toLowerCase(); const existingKeys = new Set( currentItems.map((item: Record) => state.dedupeFields .map((field) => String(item[field] ?? "")) .join(":") .toLowerCase() ), ); let resultMessage: string; if (existingKeys.has(dedupeKey)) { resultMessage = `Duplicate: ${dedupeKey} already saved`; } else { const id = `${state.idPrefix}-${Date.now()}-${ Math.random().toString(36).slice(2, 8) }`; const newRecord = { ...input, id, [state.timestampField]: Date.now(), }; delete newRecord.result; // Don't save the result cell state.items.set([...currentItems, newRecord]); resultMessage = `Saved new item`; } // Write result for LLM if (input.result) { input.result.set({ success: true, message: resultMessage }); } return { success: true, message: resultMessage }; }, )({ // Bind the config immediately items, dedupeFields: dedupe, idPrefix, timestampField: timestamp, }); } // ============================================================================= // TYPE INFERENCE (for TypeScript convenience) // ============================================================================= /** * Infer TypeScript type from a typed schema. * * Usage: * ```typescript * const MembershipSchema = defineItemSchema({ ... }, [...]); * type Membership = InferItem; * ``` */ export type InferItem = S extends TypedSchema ? { [K in Fields]: any } & { id: string } : never;