Home Artificial Intelligence Create an Agent with OpenAI Function Calling Capabilities

Create an Agent with OpenAI Function Calling Capabilities

0
Create an Agent with OpenAI Function Calling Capabilities

Pre-requisites:

OpenAI API key: You may obtain this from the OpenAI platform.

Step 1: Prepare to call the model:

To initiate a conversation, begin with a system message and a user prompt for the duty:

  • Create a messages array to maintain track of the conversation history.
  • Include a system message within the messages array to to determine the assistant’s role and context.
  • Welcome the users with a greeting message and prompt them to specify their task.
  • Add the user prompt to the messages array.
const messages: ChatCompletionMessageParam[] = [];

console.log(StaticPromptMap.welcome);

messages.push(SystemPromptMap.context);

const userPrompt = await createUserMessage();
messages.push(userPrompt);

As my personal preference, all of the prompts are stored in map objects for easy accessibility and modification. Please consult with the next code snippets for all of the prompts utilized in the applying. Be at liberty to adopt or modify this approach as suits you.

  • StaticPromptMap: Static messages which might be used throughout the conversation.
export const StaticPromptMap = {
welcome:
"Welcome to the farm assistant! What can I allow you to with today? You may ask me what I can do.",
fallback: "I'm sorry, I do not understand.",
end: "I hope I used to be in a position to allow you to. Goodbye!",
} as const;
  • UserPromptMap: User messages which might be generated based on user input.
import { ChatCompletionUserMessageParam } from "openai/resources/index.mjs";

type UserPromptMapKey = "task";
type UserPromptMapValue = (
userInput?: string
) => ChatCompletionUserMessageParam;
export const UserPromptMap: Record = {
task: (userInput) => (),
};

  • SystemPromptMap: System messages which might be generated based on system context.
import { ChatCompletionSystemMessageParam } from "openai/resources/index.mjs";

type SystemPromptMapKey = "context";
export const SystemPromptMap: Record<
SystemPromptMapKey,
ChatCompletionSystemMessageParam
> = {
context: {
role: "system",
content:
"You're an farm visit assistant. You're upbeat and friendly. You introduce yourself when first saying `Howdy!`. For those who determine to call a function, you must retrieve the required fields for the function from the user. Your answer must be as precise as possible. If you've not yet retrieve the required fields of the function completely, you don't answer the query and inform the user you should not have enough information.",
},
};

  • FunctionPromptMap: Function messages which might be principally the return values of the functions.
import { ChatCompletionToolMessageParam } from "openai/resources/index.mjs";

type FunctionPromptMapKey = "function_response";
type FunctionPromptMapValue = (
args: Omit
) => ChatCompletionToolMessageParam;
export const FunctionPromptMap: Record<
FunctionPromptMapKey,
FunctionPromptMapValue
> = {
function_response: ({ tool_call_id, content }) => ({
role: "tool",
tool_call_id,
content,
}),
};

Step 2: Define the tools

As mentioned earlier, tools are essentially the descriptions of functions that the model can call. On this case, we define 4 tools to satisfy the necessities of the farm trip assistant agent:

  1. get_farms: Retrieves a listing of farm destinations based on user’s location.
  2. get_activities_per_farm: Provides detailed information on activities available at a particular farm.
  3. book_activity: Facilitates the booking of a specific activity.
  4. file_complaint: Offers a simple process for filing complaints.

The next code snippet demonstrates how these tools are defined:

import {
ChatCompletionTool,
FunctionDefinition,
} from "openai/resources/index.mjs";
import {
ConvertTypeNameStringLiteralToType,
JsonAcceptable,
} from "../utils/type-utils.js";

// An enum to define the names of the functions. This might be shared between the function descriptions and the actual functions
export enum DescribedFunctionName {
FileComplaint = "file_complaint",
getFarms = "get_farms",
getActivitiesPerFarm = "get_activities_per_farm",
bookActivity = "book_activity",
}
// It is a utility type to narrow down the `parameters` type within the `FunctionDefinition`.
// It pairs with the keyword `satisfies` to be certain that the properties of parameters are accurately defined.
// It is a workaround because the default sort of `parameters` in `FunctionDefinition` is `type FunctionParameters = Record` which is overly broad.
type FunctionParametersNarrowed<
T extends Record>
> = {
type: JsonAcceptable; // principally all the categories that JSON can accept
properties: T;
required: (keyof T)[];
};
// It is a base type for every property of the parameters
type PropBase = {
type: T;
description: string;
};
// This utility type transforms parameter property string literals into usable types for function parameters.
// Example: { email: { type: "string" } } -> { email: string }
export type ConvertedFunctionParamProps<
Props extends Record>
> = {
[K in keyof Props]: ConvertTypeNameStringLiteralToType;
};
// Define the parameters for every function
export type FileComplaintProps = {
name: PropBase;
email: PropBase;
text: PropBase;
};
export type GetFarmsProps = {
location: PropBase;
};
export type GetActivitiesPerFarmProps = {
farm_name: PropBase;
};
export type BookActivityProps = {
farm_name: PropBase;
activity_name: PropBase;
datetime: PropBase;
name: PropBase;
email: PropBase;
number_of_people: PropBase<"number">;
};
// Define the function descriptions
const functionDescriptionsMap: Record<
DescribedFunctionName,
FunctionDefinition
> = {
[DescribedFunctionName.FileComplaint]: {
name: DescribedFunctionName.FileComplaint,
description: "File a grievance as a customer",
parameters: {
type: "object",
properties: {
name: {
type: "string",
description: "The name of the user, e.g. John Doe",
},
email: {
type: "string",
description: "The e-mail address of the user, e.g. john@doe.com",
},
text: {
type: "string",
description: "Description of issue",
},
},
required: ["name", "email", "text"],
} satisfies FunctionParametersNarrowed,
},
[DescribedFunctionName.getFarms]: {
name: DescribedFunctionName.getFarms,
description: "Get the data of farms based on the situation",
parameters: {
type: "object",
properties: {
location: {
type: "string",
description: "The situation of the farm, e.g. Melbourne VIC",
},
},
required: ["location"],
} satisfies FunctionParametersNarrowed,
},
[DescribedFunctionName.getActivitiesPerFarm]: {
name: DescribedFunctionName.getActivitiesPerFarm,
description: "Get the activities available on a farm",
parameters: {
type: "object",
properties: {
farm_name: {
type: "string",
description: "The name of the farm, e.g. Collingwood Kid's Farm",
},
},
required: ["farm_name"],
} satisfies FunctionParametersNarrowed,
},
[DescribedFunctionName.bookActivity]: {
name: DescribedFunctionName.bookActivity,
description: "Book an activity on a farm",
parameters: {
type: "object",
properties: {
farm_name: {
type: "string",
description: "The name of the farm, e.g. Collingwood Kid's Farm",
},
activity_name: {
type: "string",
description: "The name of the activity, e.g. Goat Feeding",
},
datetime: {
type: "string",
description: "The date and time of the activity",
},
name: {
type: "string",
description: "The name of the user",
},
email: {
type: "string",
description: "The e-mail address of the user",
},
number_of_people: {
type: "number",
description: "The number of individuals attending the activity",
},
},
required: [
"farm_name",
"activity_name",
"datetime",
"name",
"email",
"number_of_people",
],
} satisfies FunctionParametersNarrowed,
},
};
// Format the function descriptions into tools and export them
export const tools = Object.values(
functionDescriptionsMap
).map((description) => ({
type: "function",
function: description,
}));

Understanding Function Descriptions

Function descriptions require the next keys:

  • name: Identifies the function.
  • description: Provides a summary of what the function does.
  • parameters: Defines the function’s parameters, including their type, description, and whether or not they are required.
  • type: Specifies the parameter type, typically an object.
  • properties: Details each parameter, including its type and outline.
  • required: Lists the parameters which might be essential for the function to operate.

Adding a Latest Function

To introduce a latest function, proceed as follows:

  1. Extend DescribedFunctionName with a latest enum, equivalent to DoNewThings.
  2. Define a Props type for the parameters, e.g., DoNewThingsProps.
  3. Insert a latest entry within the functionDescriptionsMap object.
  4. Implement the brand new function within the function directory, naming it after the enum value.

Step 3: Call the model with the messages and the tools

With the messages and tools arrange, we’re able to call the model using them.

It’s necessary to notice that as of March 2024, function calling is supported only by the gpt-3.5-turbo-0125 and gpt-4-turbo-preview models.

Code implementation:

export const startChat = async (messages: ChatCompletionMessageParam[]) => {
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
top_p: 0.95,
temperature: 0.5,
max_tokens: 1024,

messages, // The messages array we created earlier
tools, // The function descriptions we defined earlier
tool_choice: "auto", // The model will determine whether to call a function and which function to call
});
const { message } = response.decisions[0] ?? {};
if (!message) {
throw latest Error("Error: No response from the API.");
}
messages.push(message);
return processMessage(message);
};

tool_choice Options

The tool_choice option controls the model’s approach to operate calls:

  • Specific Function: To specify a specific function, set tool_choice to an object with type: "function" and include the function’s name and details. As an example, tool_choice: { type: "function", function: { name: "get_farms"}} tells the model to call the get_farms function whatever the context. Even a straightforward user prompt like “Hi.” will trigger this function call.
  • No Function: To have the model generate a response with none function calls, use tool_choice: "none". This selection prompts the model to rely solely on the input messages for generating its response.
  • Automatic Selection: The default setting, tool_choice: "auto", lets the model autonomously determine if and which function to call, based on the conversation’s context. This flexibility is useful for dynamic decision-making regarding function calls.

Step 4: Handling Model Responses

The model’s responses fall into two primary categories, with a possible for errors that necessitate a fallback message:

  1. Function Call Request: The model indicates a desire to call function(s). That is the true potential of function calling. The model intelligently selects which function(s) to execute based on context and user queries. As an example, if the user asks for farm recommendations, the model may suggest calling the get_farms function.

But it surely doesn’t just stop there, the model also analyzes the user input to find out if it accommodates the mandatory information (arguments) for the function call. If not, the model would prompt the user for the missing details.

Once it has gathered all required information (arguments), the model returns a JSON object detailing the function name and arguments. This structured response will be effortlessly translated right into a JavaScript object inside our application, enabling us to invoke the required function seamlessly, thereby ensuring a fluid user experience.

Moreover, the model can decide to call multiple functions, either concurrently or in sequence, each requiring specific details. Managing this inside the applying is crucial for smooth operation.

Example of model’s response:

{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_JWoPQYmdxNXdNu1wQ1iDqz2z",
"type": "function",
"function": {
"name": "get_farms", // The function name to be called
"arguments": "{"location":"Melbourne"}" // The arguments required for the function
}
}
... // multiple function calls can be present
]
}

2. Plain Text Response: The model provides a direct text response. That is the usual output we’re accustomed to from AI models, offering straightforward answers to user queries. Simply returning the text content suffices for these responses.

Example of model’s response:

{
"role": "assistant",
"content": {
"text": "I can allow you to with that. What's your location?"
}
}

The important thing distinction is the presence of a tool_calls key for function calls. If tool_calls is present, the model is requesting to execute a function; otherwise, it delivers a simple text response.

To process these responses, consider the next approach based on the response type:

type ChatCompletionMessageWithToolCalls = RequiredAll<
Omit
>;

// If the message accommodates tool_calls, it extracts the function arguments. Otherwise, it returns the content of the message.
export function processMessage(message: ChatCompletionMessage) {
if (isMessageHasToolCalls(message)) {
return extractFunctionArguments(message);
} else {
return message.content;
}
}
// Check if the message has `tool calls`
function isMessageHasToolCalls(
message: ChatCompletionMessage
): message is ChatCompletionMessageWithToolCalls {
return isDefined(message.tool_calls) && message.tool_calls.length !== 0;
}
// Extract function name and arguments from the message
function extractFunctionArguments(message: ChatCompletionMessageWithToolCalls) {
return message.tool_calls.map((toolCall) => {
if (!isDefined(toolCall.function)) {
throw latest Error("No function present in the tool call");
}
try {
return {
tool_call_id: toolCall.id,
function_name: toolCall.function.name,
arguments: JSON.parse(toolCall.function.arguments),
};
} catch (error) {
throw latest Error("Invalid JSON in function arguments");
}
});
}

The arguments extracted from the function calls are then used to execute the actual functions in the applying, while the text content helps to hold on the conversation.

Below is an if-else block illustrating how this process unfolds:

const result = await startChat(messages);

if (!result) {
// Fallback message if response is empty (e.g., network error)
return console.log(StaticPromptMap.fallback);
} else if (isNonEmptyString(result)) {
// If the response is a string, log it and prompt the user for the following message
console.log(`Assistant: ${result}`);
const userPrompt = await createUserMessage();
messages.push(userPrompt);
} else {
// If the response accommodates function calls, execute the functions and call the model again with the updated messages
for (const item of result) {
const { tool_call_id, function_name, arguments: function_arguments } = item;
// Execute the function and get the function return
const function_return = await functionMap[
function_name as keyof typeof functionMap
](function_arguments);
// Add the function output back to the messages with a task of "tool", the id of the tool call, and the function return because the content
messages.push(
FunctionPromptMap.function_response({
tool_call_id,
content: function_return,
})
);
}
}

Step 5: Execute the function and call the model again

When the model requests a function call, we execute that function in our application after which update the model with the brand new messages. This keeps the model informed in regards to the function’s result, allowing it to present a pertinent reply to the user.

Maintaining the right sequence of function executions is crucial, especially when the model chooses to execute multiple functions in a sequence to finish a task. Using a for loop as a substitute of Promise.all preserves the execution order, essential for a successful workflow. Nevertheless, if the functions are independent and will be executed in parallel, consider custom optimizations to boost performance.

Here’s the right way to execute the function:

for (const item of result) {
const { tool_call_id, function_name, arguments: function_arguments } = item;

console.log(
`Calling function "${function_name}" with ${JSON.stringify(
function_arguments
)}`
);
// Available functions are stored in a map for easy accessibility
const function_return = await functionMap[
function_name as keyof typeof functionMap
](function_arguments);
}

And here’s the right way to update the messages array with the function response:

for (const item of result) {
const { tool_call_id, function_name, arguments: function_arguments } = item;

console.log(
`Calling function "${function_name}" with ${JSON.stringify(
function_arguments
)}`
);
const function_return = await functionMap[
function_name as keyof typeof functionMap
](function_arguments);
// Add the function output back to the messages with a task of "tool", the id of the tool call, and the function return because the content
messages.push(
FunctionPromptMap.function_response({
tool_call_id,
content: function_return,
})
);
}

Example of the functions that will be called:

// Mocking getting farms based on location from a database
export async function get_farms(
args: ConvertedFunctionParamProps
): Promise {
const { location } = args;
return JSON.stringify({
location,
farms: [
{
name: "Farm 1",
location: "Location 1",
rating: 4.5,
products: ["product 1", "product 2"],
activities: ["activity 1", "activity 2"],
},
...
],
});
}

Example of the tool message with function response:

{
"role": "tool",
"tool_call_id": "call_JWoPQYmdxNXdNu1wQ1iDqz2z",
"content": {
// Function return value
"location": "Melbourne",
"farms": [
{
"name": "Farm 1",
"location": "Location 1",
"rating": 4.5,
"products": [
"product 1",
"product 2"
],
"activities": [
"activity 1",
"activity 2"
]
},
...
]
}
}

Step 6: Summarize the outcomes back to the user

After running the functions and updating the message array, we re-engage the model with these updated messages to temporary the user on the outcomes. This involves repeatedly invoking the startChat function via a loop.

To avoid limitless looping, it’s crucial to watch for user inputs signaling the tip of the conversation, like “Goodbye” or “End,” ensuring the loop terminates appropriately.

Code implementation:

const CHAT_END_SIGNALS = [
"end",
"goodbye",
...
];

export function isChatEnding(
message: ChatCompletionMessageParam | undefined | null
) {
// If the message will not be defined, log a fallback message
if (!isDefined(message)) {
return console.log(StaticPromptMap.fallback);
}
// Check if the message is from the user
if (!isUserMessage(message)) {
return false;
}
const { content } = message;
return CHAT_END_SIGNALS.some((signal) => {
if (typeof content === "string") {
return includeSignal(content, signal);
} else {
// content has a typeof ChatCompletionContentPart, which will be either ChatCompletionContentPartText or ChatCompletionContentPartImage
// If user attaches a picture to the present message first, we assume they will not be ending the chat
const contentPart = content.at(0);
if (contentPart?.type !== "text") {
return false;
} else {
return includeSignal(contentPart.text, signal);
}
}
});
}
function isUserMessage(
message: ChatCompletionMessageParam
): message is ChatCompletionUserMessageParam {
return message.role === "user";
}
function includeSignal(content: string, signal: string) {
return content.toLowerCase().includes(signal);
}

LEAVE A REPLY

Please enter your comment!
Please enter your name here