assistan-ts
Version:
A typesafe and code-first library to define and run OpenAI assistants
187 lines (160 loc) • 5.91 kB
text/typescript
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";
}
}