UNPKG

assistan-ts

Version:

A typesafe and code-first library to define and run OpenAI assistants

187 lines (160 loc) 5.91 kB
import { Static } from "@sinclair/typebox"; import { FunctionTool, functionsToPayload } from "./definition"; import { RequiredActionFunctionToolCall } from "openai/resources/beta/threads/runs/runs"; import { AssistantFunction, ToolOutput } from "./types/openai"; import { Value, ValueError } from "@sinclair/typebox/value"; import { registerTypeboxFormats } from "./lib/formats"; import { isNullType } from "./lib/typebox"; registerTypeboxFormats(); type Output = string | number | boolean | object; export type ToolsDefsToToolbox<T extends Record<string, FunctionTool>> = { [key in keyof T]: (params: Static<T[key]["parameters"]>) => Promise<Output>; }; type ToolContext = { action: RequiredActionFunctionToolCall; options: ToolOptions; toolDef?: FunctionTool; }; export type ToolOptions = { /* Run JSONSchema validation before calling tool. default: true */ validateArguments: boolean; /* Argument Parser */ jsonParser: (args: string, ctx: ToolContext) => unknown; /* Argument Validator */ validator: (args: unknown, ctx: ToolContext) => void; /* Chance to remap errors or throw a fatal 'fatal'. By Default only `AssistantVisibleError` will be passed along */ formatToolError: (error: unknown, ctx: ToolContext) => string; /* custom error messages on argument validation */ formatValidationError: (errors: ValueError[], ctx: ToolContext) => string; /* Output Formatter */ formatOutput: (output: Output, ctx: ToolContext) => string; }; export const defaultOptions: ToolOptions = { jsonParser: defaultJsonParser, validator: defaultValidator, formatValidationError: defaultValidateFormatter, formatToolError: defaultErrorFormatter, validateArguments: true, formatOutput: defaultOutputFormatter, }; export type ToolBox<T extends Record<string, FunctionTool>> = { toolDefs: T; toolsFn: ToolsDefsToToolbox<T>; options: ToolOptions; handleAction: ( action: RequiredActionFunctionToolCall ) => Promise<Required<ToolOutput>>; payload: AssistantFunction[]; }; // TODO: how to handle options and tool conflicts? export const join = (...toolboxes: ToolBox<any>[]): ToolBox<any> => { const toolDefs = Object.assign({}, ...toolboxes.map((it) => it.toolDefs)); const toolsFn = Object.assign({}, ...toolboxes.map((it) => it.toolsFn)); return toolbox(toolDefs, toolsFn, toolboxes[0]?.options); }; export const filter = <T extends Record<string, FunctionTool>>( tb: ToolBox<T>, filter: (key: string, tool: FunctionTool) => boolean ): ToolBox<any> => { const toolDefs = Object.fromEntries( Object.entries(tb.toolDefs).filter(([key, tool]) => filter(key, tool)) ); const toolsFn = Object.fromEntries( Object.entries(tb.toolsFn).filter(([key, tool]) => filter(key, tool)) ); return toolbox(toolDefs, toolsFn, tb.options); }; export const toolbox = <T extends Record<string, FunctionTool>>( toolDefs: T, toolsFn: ToolsDefsToToolbox<T>, options: Partial<ToolOptions> = defaultOptions ): ToolBox<T> => { const opts = { ...defaultOptions, ...options }; const { jsonParser, validator, formatOutput, formatToolError } = opts; return { toolDefs, toolsFn, options: opts, payload: functionsToPayload(toolDefs), handleAction: async (action) => { const toolDef = toolDefs?.[action.function.name]; const ctx: ToolContext = { action, options: opts, toolDef }; try { if (!toolDef) throw new AssistantVisibleError( `Tool key not found: ${ action.function.name }. Please select from ${Object.keys(toolDefs as any).toString()}` ); let output: Output; if (!toolDef?.parameters || isNullType(toolDef.parameters)) { output = await toolsFn[action.function.name](undefined); } else { const args = jsonParser(action.function.arguments, ctx); validator(args, ctx); output = await toolsFn[action.function.name](args); } return { tool_call_id: action.id, output: formatOutput(output, ctx), }; } catch (e) { return { tool_call_id: action.id, output: formatToolError(e, ctx), }; } }, }; }; function defaultJsonParser(args: string, ctx: ToolContext) { try { return JSON.parse(args); } catch (e) { throw new AssistantVisibleError(`Invalid JSON: ${args}`); } } function defaultValidator(args: unknown, ctx: ToolContext) { if (ctx.options.validateArguments && ctx.toolDef) { // const validate = ajv.compile(ctx.toolDef.parameters); const errors = [...Value.Errors(ctx.toolDef.parameters, args)]; const valid = errors.length === 0; if (valid === false) { throw new AssistantVisibleError( ctx.options.formatValidationError(errors ?? [], ctx) ); } } } function defaultValidateFormatter(errors: ValueError[]) { return errors.map((it) => `arguments${it.path} ${it.message}`).join("\n"); } function defaultOutputFormatter(output: Output) { switch (typeof output) { case "string": return output; case "number": case "boolean": return output + ""; case "object": return JSON.stringify(output); } } function defaultErrorFormatter(error: unknown, ctx: ToolContext) { if (error instanceof AssistantVisibleError) { return `Error calling ${ctx.action.function.name}: ${error.message}`; } throw error; } /** * Throw this if you want your assistant to receive the error message */ export class AssistantVisibleError extends Error { constructor(message?: string) { super(message); // Pass the message to the parent class Error // This line makes stack traces work correctly, do not remove it! Object.setPrototypeOf(this, AssistantVisibleError.prototype); // Set the name of the error class as CustomError. this.name = "AssistantVisibleError"; } }