Implement a simple ReAct Agent using OpenAI function calling

Simple example of using OpenAI Function Calling in a ReAct Loop .

ReAct Loop

The ReAct loop interleaves reasoning and acting in a thought-action-observation loop. It has been quite popular and successful as a foundation for building simple LLM-based agents, allowing the agent to iteratively solve a multi-step task.

ReAct Loop

I explored integrating the ReAct loop with a Python REPL and embedding-based function search in a previous post titled "ReAct REPL Agent" . In this post we revisit the ReAct loop, but this time using the OpenAI Function Calling API. I'll provide a simple example of how to easily setup a ReAct loop using the OpenAI Function Calling API to perform the actions.

OpenAI Function Calling

The OpenAI Function Calling API allows us to have an LLM-based agent call functions. Based on a set of pre-defined functions that we'll provide, and some context, the LLM will select a function to call and provide the arguments to the function. We then have to call the function ourselves and provide the result back to the LLM Agent.

In [1]:
# Imports
import json
import requests
from collections.abc import Callable
from typing import Annotated as A, Literal as L

import openai

from annotated_docs.json_schema import as_json_schema  # https://github.com/peterroelants/annotated-docs
In [8]:
# Setup OpenAI Client
# Get your OpenAI API key from https://platform.openai.com/
openai_client: openai.OpenAI = openai.OpenAI(api_key="<your-api-key-here>")

Agent implementation

As an example, we'll implement a simple agent that can help us find the current weather for our current location.

Defining the functions

Let's start by defining a few simple example functions that we can call. The functions the agent can call are:

  • get_current_location : To find the current location of the agent (Found based on the IP address).
  • get_current_weather : To find the current weather for a given location.
  • calculate : To help calculate any conversion between units.
  • finish : To finish the task and formulate a response. We let the finish function raise a StopException so we can easily detect when the agent is done.

I'm making use of Python's typing library to annotate the function arguments with extra information useful for the LLM. We'll specifically make use of typing.Annotated to provide extra information about the arguments, and typing.Literal to specify the possible values for the arguments.

In [3]:
class StopException(Exception):
    """
    Stop Execution by raising this exception (Signal that the task is Finished).
    """


def finish(answer: A[str, "Answer to the user's question."]) -> None:
    """Answer the user's question, and finish the conversation."""
    raise StopException(answer)


def get_current_location() -> str:
    """Get the current location of the user."""
    return json.dumps(requests.get("http://ip-api.com/json?fields=lat,lon").json())


def get_current_weather(
    latitude: float,
    longitude: float,
    temperature_unit: L["celsius", "fahrenheit"],
) -> str:
    """Get the current weather in a given location."""
    resp = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": latitude,
            "longitude": longitude,
            "temperature_unit": temperature_unit,
            "current_weather": True,
        },
    )
    return json.dumps(resp.json())


def calculate(
    formula: A[str, "Numerical expression to compute the result of, in Python syntax."],
) -> str:
    """Calculate the result of a given formula."""
    return str(eval(formula))

Providing the function specifications to OpenAI

OpenAI function calling requires us to provide the function specifications to the API as a JSON schema .

I'm using the as_json_schema function from annotated-docs to convert the function annotations to JSON schemas describing the functions. as_json_schema leverages the typing annotations to provide extra information about the arguments to the LLM.

In [4]:
# All functions that can be called by the LLM Agent
name_to_function_map: dict[str, Callable] = {
    get_current_location.__name__: get_current_location,
    get_current_weather.__name__: get_current_weather,
    calculate.__name__: calculate,
    finish.__name__: finish,
}

# JSON Schemas for all functions
function_schemas = [
    {"function": as_json_schema(func), "type": "function"}
    for func in name_to_function_map.values()
]

# Print the JSON Schemas
for schema in function_schemas:
    print(json.dumps(schema, indent=2))
{
  "function": {
    "name": "get_current_location",
    "description": "Get the current location of the user.",
    "parameters": {
      "type": "object",
      "properties": {},
      "required": []
    }
  },
  "type": "function"
}
{
  "function": {
    "name": "get_current_weather",
    "description": "Get the current weather in a given location.",
    "parameters": {
      "type": "object",
      "properties": {
        "latitude": {
          "type": "number"
        },
        "longitude": {
          "type": "number"
        },
        "temperature_unit": {
          "type": "string",
          "enum": [
            "celsius",
            "fahrenheit"
          ]
        }
      },
      "required": [
        "latitude",
        "longitude",
        "temperature_unit"
      ]
    }
  },
  "type": "function"
}
{
  "function": {
    "name": "calculate",
    "description": "Calculate the result of a given formula.",
    "parameters": {
      "type": "object",
      "properties": {
        "formula": {
          "type": "string",
          "description": "Numerical expression to compute the result of, in Python syntax."
        }
      },
      "required": [
        "formula"
      ]
    }
  },
  "type": "function"
}
{
  "function": {
    "name": "finish",
    "description": "Answer the user's question, and finish the conversation.",
    "parameters": {
      "type": "object",
      "properties": {
        "answer": {
          "type": "string",
          "description": "Answer to the user's question."
        }
      },
      "required": [
        "answer"
      ]
    }
  },
  "type": "function"
}

Define the question to ask the LLM Agent

In [9]:
QUESTION_PROMPT = "\
    What's the current weather for my location? Give me the temperature in degrees Celsius and the wind speed in knots."

Run the ReAct loop

The ReAct loop builds up the context iteratively, taking one step at the time. Using the OpenAI Chat Completions API the messages, function calls, and function call results are all appended to the messages list. The full messages list is then used as the context for the next step.

Each step of the ReAct loop consists of the following steps:

  1. Send the current context of messages to the LLM Agent and get a response.
  2. Append the response to the messages list.
  3. For each function call in the response, call the function and append the result to the messages list.

Let's run our question through the ReAct loop and see what happens.

In [6]:
# Initial "chat" messages
messages = [
    {
        "role": "system",
        "content": "You are a helpful assistant who can answer multistep questions by sequentially calling functions. Follow a pattern of THOUGHT (reason step-by-step about which function to call next), ACTION (call a function to as a next step towards the final answer), OBSERVATION (output of the function). Reason step by step which actions to take to get to the answer. Only call functions with arguments coming verbatim from the user or the output of other functions.",
    },
    {
        "role": "user",
        "content": QUESTION_PROMPT,
    },
]


def run(messages: list[dict]) -> list[dict]:
    """
    Run the ReAct loop with OpenAI Function Calling.
    """
    # Run in loop
    max_iterations = 20
    for i in range(max_iterations):
        # Send list of messages to get next response
        response = openai_client.chat.completions.create(
            model="gpt-4-1106-preview",
            messages=messages,
            tools=function_schemas,
            tool_choice="auto",
        )
        response_message = response.choices[0].message
        messages.append(response_message)  # Extend conversation with assistant's reply
        # Check if GPT wanted to call a function
        tool_calls = response_message.tool_calls
        if tool_calls:
            for tool_call in tool_calls:
                function_name = tool_call.function.name
                # Validate function name
                if function_name not in name_to_function_map:
                    print(f"Invalid function name: {function_name}")
                    messages.append(
                        {
                            "tool_call_id": tool_call.id,
                            "role": "tool",
                            "name": function_name,
                            "content": f"Invalid function name: {function_name!r}",
                        }
                    )
                    continue
                # Get the function to call
                function_to_call: Callable = name_to_function_map[function_name]
                # Try getting the function arguments
                try:
                    function_args_dict = json.loads(tool_call.function.arguments)
                except json.JSONDecodeError as exc:
                    # JSON decoding failed
                    print(f"Error decoding function arguments: {exc}")
                    messages.append(
                        {
                            "tool_call_id": tool_call.id,
                            "role": "tool",
                            "name": function_name,
                            "content": f"Error decoding function call `{function_name}` arguments {tool_call.function.arguments!r}! Error: {exc!s}",
                        }
                    )
                    continue
                # Call the selected function with generated arguments
                try:
                    print(
                        f"Calling function {function_name} with args: {json.dumps(function_args_dict)}"
                    )
                    function_response = function_to_call(**function_args_dict)
                    # Extend conversation with function response
                    messages.append(
                        {
                            "tool_call_id": tool_call.id,
                            "role": "tool",
                            "name": function_name,
                            "content": function_response,
                        }
                    )
                except StopException as exc:
                    # Agent wants to stop the conversation (Expected)
                    print(f"Finish task with message: '{exc!s}'")
                    return messages
                except Exception as exc:
                    # Unexpected error calling function
                    print(
                        f"Error calling function `{function_name}`: {type(exc).__name__}: {exc!s}"
                    )
                    messages.append(
                        {
                            "tool_call_id": tool_call.id,
                            "role": "tool",
                            "name": function_name,
                            "content": f"Error calling function `{function_name}`: {type(exc).__name__}: {exc!s}!",
                        }
                    )
                    continue
    return messages


messages = run(messages)
Calling function get_current_location with args: {}
Calling function get_current_weather with args: {"latitude": 50.935, "longitude": 5.3372, "temperature_unit": "celsius"}
Calling function calculate with args: {"formula": "*B1['wind_speed'] * 0.539956803"}
Error calling function `calculate`: SyntaxError: invalid syntax (<string>, line 1)
Calling function calculate with args: {"formula": "26.8 * 0.539956803"}
Calling function finish with args: {"answer": "The current temperature at your location is 4.8 degrees Celsius and the wind speed is approximately 14.47 knots."}
Finish task with message: 'The current temperature at your location is 4.8 degrees Celsius and the wind speed is approximately 14.47 knots.'

Looking at the output it seems like the agent is able to answer our question in a few steps.

Let's look at the full context of messages to see which functions were called and what the results were:

In [7]:
for message in messages:
    if not isinstance(message, dict):
        message = message.model_dump()  # Pydantic model
    print(json.dumps(message, indent=2))
{
  "role": "system",
  "content": "You are a helpful assistant who can answer multistep questions by sequentially calling functions. Follow a pattern of THOUGHT (reason step-by-step about which function to call next), ACTION (call a function to as a next step towards the final answer), OBSERVATION (output of the function). Reason step by step which actions to take to get to the answer. Only call functions with arguments coming verbatim from the user or the output of other functions."
}
{
  "role": "user",
  "content": "What's the current weather for my location? Give me the temperature in degrees Celsius and the wind speed in knots."
}
{
  "content": null,
  "role": "assistant",
  "function_call": null,
  "tool_calls": [
    {
      "id": "call_UQTJx1xD7KXwpOCVWAgUUDK3",
      "function": {
        "arguments": "{}",
        "name": "get_current_location"
      },
      "type": "function"
    }
  ]
}
{
  "tool_call_id": "call_UQTJx1xD7KXwpOCVWAgUUDK3",
  "role": "tool",
  "name": "get_current_location",
  "content": "{\"lat\": 50.935, \"lon\": 5.3372}"
}
{
  "content": null,
  "role": "assistant",
  "function_call": null,
  "tool_calls": [
    {
      "id": "call_cXPomLevnPmxtWfSWvxx9S8H",
      "function": {
        "arguments": "{\"latitude\": 50.935, \"longitude\": 5.3372, \"temperature_unit\": \"celsius\"}",
        "name": "get_current_weather"
      },
      "type": "function"
    },
    {
      "id": "call_5hbeZUcUl3A9RNhAI4QIbv0n",
      "function": {
        "arguments": "{\"formula\": \"*B1['wind_speed'] * 0.539956803\"}",
        "name": "calculate"
      },
      "type": "function"
    }
  ]
}
{
  "tool_call_id": "call_cXPomLevnPmxtWfSWvxx9S8H",
  "role": "tool",
  "name": "get_current_weather",
  "content": "{\"latitude\": 50.94, \"longitude\": 5.3399997, \"generationtime_ms\": 0.07295608520507812, \"utc_offset_seconds\": 0, \"timezone\": \"GMT\", \"timezone_abbreviation\": \"GMT\", \"elevation\": 30.0, \"current_weather_units\": {\"time\": \"iso8601\", \"interval\": \"seconds\", \"temperature\": \"\\u00b0C\", \"windspeed\": \"km/h\", \"winddirection\": \"\\u00b0\", \"is_day\": \"\", \"weathercode\": \"wmo code\"}, \"current_weather\": {\"time\": \"2024-01-21T20:30\", \"interval\": 900, \"temperature\": 4.8, \"windspeed\": 26.8, \"winddirection\": 195, \"is_day\": 0, \"weathercode\": 3}}"
}
{
  "tool_call_id": "call_5hbeZUcUl3A9RNhAI4QIbv0n",
  "role": "tool",
  "name": "calculate",
  "content": "Error calling function `calculate`: SyntaxError: invalid syntax (<string>, line 1)!"
}
{
  "content": null,
  "role": "assistant",
  "function_call": null,
  "tool_calls": [
    {
      "id": "call_FKEuet6UV0qHY9IBiRMYcvbk",
      "function": {
        "arguments": "{\"formula\":\"26.8 * 0.539956803\"}",
        "name": "calculate"
      },
      "type": "function"
    }
  ]
}
{
  "tool_call_id": "call_FKEuet6UV0qHY9IBiRMYcvbk",
  "role": "tool",
  "name": "calculate",
  "content": "14.470842320400001"
}
{
  "content": null,
  "role": "assistant",
  "function_call": null,
  "tool_calls": [
    {
      "id": "call_OdnItcBz57OeKOYWbcg8xbmB",
      "function": {
        "arguments": "{\"answer\":\"The current temperature at your location is 4.8 degrees Celsius and the wind speed is approximately 14.47 knots.\"}",
        "name": "finish"
      },
      "type": "function"
    }
  ]
}

Looking at the full list of messages we can see that the agent has answered our question by:

  1. Finding the current location of the agent using the get_current_location function.
  2. Getting the current weather for the location using the get_current_weather function.
  3. Converted the wind speed from km/h to knots using the calculate function.
  4. Finished the task with a final answer by calling the finish function.

This post at peterroelants.github.io is generated from an IPython notebook file. Link to the full IPython notebook file

Originally published on January 21, 2014.
LLM ReAct Loop OpenAI Agents