Building an Autonomous GPT-5 Agent

Introduction & Overview

Throughout this course, you have mastered the fundamentals of tool integration with GPT-5: creating tool schemas, understanding GPT-5's function call responses, and executing single tool calls. However, the approach you've learned so far has a significant limitation — it handles only one tool-execution cycle per conversation. While this works perfectly for simple tasks, many real-world problems require multiple sequential steps, and often the number and nature of these steps cannot be determined in advance.

In this lesson, we'll work together to transform GPT-5 from a single-turn tool user into an autonomous agent capable of iterative problem-solving. We'll build an Agent class that can call tools, analyze results, decide what to do next, and continue this process until complex multi-step tasks are completed. This represents a fundamental shift from reactive tool usage to proactive, intelligent problem-solving that mirrors how humans approach complex challenges.

The Action-Feedback Loop Concept

Before we start coding, let's understand how autonomous agents operate through action-feedback loops in which each tool execution provides information that influences the next decision. This iterative process mirrors human problem-solving: we take an action, observe the result, decide what to do next, and repeat until we reach our goal. The action-feedback loop consists of four key phases that repeat until task completion:

  1. Decision Phase: GPT-5 analyzes the current situation and determines the next action, which may include calling one or more tools.
  2. Action Phase: Our agent executes the requested tool(s) based on GPT-5's instructions.
  3. Feedback Phase: The results from the tool execution(s) are captured and appended to the conversation as function_call_output items.
  4. Evaluation Phase: GPT-5 reviews the new information, decides whether the task is complete, or if additional steps are needed, and the loop continues.

This loop structure enables complex problem-solving because each iteration builds upon previous results. For example, when solving a quadratic equation, GPT-5 might first calculate the discriminant, then use that result to determine if real solutions exist, then calculate the square root of the discriminant, and finally compute the two solutions. The key insight is that GPT-5 doesn't need to plan all steps in advance — it can adapt its approach based on intermediate results, just like a human mathematician working through a problem.

Now let's start building our agent class to make this iterative process possible.

Building Our Agent Class Foundation

Let's begin by creating the foundation of our autonomous agent. We need to establish the core structure that will manage extended conversations, tool execution, and decision-making loops. We'll start with the class definition and constructor:

require "json"
require "openai"

class Agent
  BASE_DEVELOPER_PROMPT = (
    "You are an autonomous agent that can take multiple tool-calling steps when helpful. " \
    "The user only sees your response when you stop using tools, not your tool usage or reasoning steps. " \
    "When you provide your answer without calling tools, make it complete and standalone.\n" \
    "Additional instructions:\n"
  )

  def initialize(
    name:,
    system_prompt: "You are a helpful assistant.",
    model: "gpt-5",
    tools: nil,
    tool_schemas: nil,
    max_turns: 10,
    reasoning_effort: "low"
  )
    @client = OpenAI::Client.new
    @name = name
    @model = model
    @developer_prompt = BASE_DEVELOPER_PROMPT + system_prompt
    @max_turns = max_turns
    @reasoning_effort = reasoning_effort

    # Avoid shared mutable defaults and protect against external mutation
    @tools = tools ? tools.dup : {}                      # name -> Ruby callable
    @tool_schemas = tool_schemas ? tool_schemas.dup : [] # array of function schemas
  end
end

Our agent's foundation relies on key design decisions that enable autonomous behavior while maintaining flexibility for different use cases:

  • BASE_DEVELOPER_PROMPT: Explicitly tells GPT-5 that it can make multiple tool calls and that users won't see the intermediate steps — only the final result. We combine this with a custom system_prompt to allow for domain-specific instructions while maintaining the autonomous behavior.

  • Naming note: the constructor accepts a parameter called system_prompt (a familiar term), but internally we store the combined instructions in @developer_prompt because the Responses API expects them as a developer role message.

  • Constructor parameters provide flexibility for different scenarios while ensuring safe defaults:

    • name:: A clear identifier for the agent, useful for debugging and managing multiple agents.
    • system_prompt:: Domain-specific instructions appended to the base prompt.
    • model:: Defaults to "gpt-5".
    • tools: and tool_schemas::
      • Default to nil to avoid shared mutable defaults.
      • Are duplicated via .dup so each agent gets its own independent registry and schema list.
    • max_turns:: Prevents infinite loops by limiting iterative steps.
    • reasoning_effort:: GPT-5 is a reasoning model. "low" is fast and inexpensive — a good default — while "medium" or "high" will spend more tokens thinking, which can help on complex tasks.

This architecture separates concerns cleanly while preparing us to implement the core functionality that will make our agent truly autonomous.

Adding Helper Methods for State Management

As our agent works through complex problems, we need to manage conversation state properly. Let's add two essential helper methods that will support our main loop:

def text_message(role, text)
  {
    role: role,
    content: [
      { type: "input_text", text: text }
    ]
  }
end

def build_request_args(messages)
  args = {
    model: @model,
    input: [
      text_message("developer", @developer_prompt),
      *messages
    ],
    reasoning: { effort: @reasoning_effort },
    store: false
  }

  # Add tool schemas only if they exist
  args[:tools] = @tool_schemas unless @tool_schemas.empty?
  args
end

These helper methods are essential for clean separation between the complex orchestration logic we're about to write and the details of message construction:

  • text_message: Builds a properly-shaped Responses API message with a role and a single input_text content block.

  • build_request_args: Centralizes how we construct API requests, ensuring consistent parameters across all agent interactions. Note how we conditionally include tool schemas using unless @tool_schemas.empty? — this prevents API errors when we create agents without tools while still supporting full tool integration when needed.

Implementing Tool Execution

Now let's add the method that handles individual tool executions. This method needs to be robust because tool failures shouldn't break our entire autonomous process:

def call_tool(function_call)
  tool_name = function_call.name
  call_id = function_call.call_id
  tool_input = JSON.parse(function_call.arguments || "{}")

  puts "🔧 Tool called: #{tool_name}(#{tool_input})"

  result = begin
    callable = @tools.fetch(tool_name)
    callable.call(**tool_input.transform_keys(&:to_sym))
  rescue KeyError
    "Error: Tool #{tool_name} not found"
  rescue => e
    "Error: #{e}"
  end

  {
    type: "function_call_output",
    call_id: call_id,
    output: JSON.generate(result: result)
  }
end

This method handles the individual tool executions occurring within our larger iterative loop:

  1. Extracts function call information: Gets the tool_name, parses the arguments JSON string into a hash, and captures the call_id.
  2. Debug tracking: Prints which tool is being called with what arguments — invaluable for debugging and understanding how our agent thinks.
  3. Executes with comprehensive error handling:
    • Uses a begin...rescue block to handle errors gracefully.
    • rescue KeyError catches cases where the tool doesn't exist in our registry (when fetch fails).
    • rescue => e catches any other execution failures.
    • Transforms string keys from the API into Ruby symbols via transform_keys(&:to_sym) so they work with our keyword-argument methods.
  4. Returns structured outputs: Builds a function_call_output hash matching the call_id with the result JSON-encoded as a string.

Building the Core Loop - Part 1: Stateless Design

Now we're ready to implement the heart of our autonomous agent: the run method. Let's start by understanding how our agent handles conversation state:

def run(input_messages)
  # Create a copy of the input messages to avoid modifying the original
  messages = input_messages.map(&:dup)
end

The input_messages.map(&:dup) call ensures our agent remains stateless. Each time you call agent.run, you provide the full context through input_messages, and the agent processes only that specific conversation without any memory of previous interactions.

By creating a shallow copy of each item hash instead of modifying them directly, we preserve the original conversation and allow the same agent instance to handle multiple independent conversations.

Building the Core Loop - Part 2: Setting Up the Iteration

Now let's add the basic loop structure that will enable our agent's iterative problem-solving:

def run(input_messages)
  messages = input_messages.map(&:dup)
  turn = 0

  while turn < @max_turns
    turn += 1

    response = @client.responses.create(**build_request_args(messages))

    function_calls = response.output.select do |item|
      item.type.to_s == "function_call"
    end
  end
end

We're starting with a controlled loop that will continue until GPT-5 provides a final answer or we reach our maximum turn limit. Each iteration represents one complete action-feedback cycle: GPT-5 produces a response, and we filter out any function calls.

Notice that we use the double-splat operator ** to expand our build_request_args hash into keyword arguments for the create method, following Ruby's idiomatic approach to method calls.

Building the Core Loop - Part 3: Handling Function Calls

Now let's add the logic for handling function calls within our loop:

def run(input_messages)
  # ... copy + turn counter + while loop ...
  while turn < @max_turns
    # ... make response ...

    if function_calls.any?
      function_calls.each do |function_call|
        messages << {
          type: "function_call",
          name: function_call.name,
          arguments: function_call.arguments,
          call_id: function_call.call_id
        }
      end

      function_outputs = function_calls.map do |function_call|
        call_tool(function_call)
      end

      messages.concat(function_outputs)
    end
  end
end

When GPT-5 decides to use tools, we handle the execution through a systematic process:

  1. Append the function call items: We push each function_call produced by GPT-5 onto messages so the next API call has a complete view of the conversation.
  2. Execute all requested tools: We map over each function call through our call_tool helper, which returns a properly structured function_call_output.
  3. Append the outputs: Using messages.concat(function_outputs), we add every output to the conversation. GPT-5 will see them on the next turn.

Each tool result influences GPT-5's subsequent reasoning, allowing it to build upon what it just learned and make more informed decisions in the next iteration.

Building the Core Loop - Part 4: Managing Flow Control

Finally, let's complete our loop with the logic for handling final responses and error conditions:

def run(input_messages)
  # ... copy + turn counter + while loop + tool handling ...
  while turn < @max_turns
    # ... make response and check function_calls ...
    if function_calls.any?
      # ... handle function calls ...
    else
      messages << text_message("assistant", response.output_text)
      return [messages, response.output_text]
    end
  end

  raise "Max turns reached"
end

When GPT-5 reaches a final answer (no function calls in the output), we:

  1. Capture the assistant's reply: We use response.output_text to grab GPT-5's text response and add it to messages as an assistant message.
  2. Return complete state: We return both the full conversation history (messages) and the final text (response.output_text) as an array. This stateless design lets the caller decide how to use the result.
  3. Safety net for runaway loops: The raise "Max turns reached" outside the loop prevents infinite iterations if something goes wrong.

Complete Run Method

Here's how our complete run method looks when put together:

def run(input_messages)
  messages = input_messages.map(&:dup)
  turn = 0

  while turn < @max_turns
    turn += 1

    response = @client.responses.create(**build_request_args(messages))

    function_calls = response.output.select do |item|
      item.type.to_s == "function_call"
    end

    if function_calls.any?
      function_calls.each do |function_call|
        messages << {
          type: "function_call",
          name: function_call.name,
          arguments: function_call.arguments,
          call_id: function_call.call_id
        }
      end

      function_outputs = function_calls.map do |function_call|
        call_tool(function_call)
      end

      messages.concat(function_outputs)
    else
      messages << text_message("assistant", response.output_text)
      return [messages, response.output_text]
    end
  end

  raise "Max turns reached"
end

Testing Our Autonomous Agent

Now let's put our agent to work! We'll create a math-focused autonomous agent and see how it handles a complex quadratic equation. We will provide a richer toolbox with subtract_numbers, divide_numbers, power, and square_root in addition to our sum_numbers and multiply_numbers. Each function uses keyword arguments to match the inputs GPT-5 provides.

For example, square_root looks like this:

def square_root(a:)
  raise ArgumentError, "a must be non-negative" if a < 0
  Math.sqrt(a)
end

And its corresponding schema defines a as the only required parameter.

Now let's set up the agent and ask it to solve a quadratic equation:

require "json"
require_relative "agent"
require_relative "functions"

def text_message(role, text)
  {
    role: role,
    content: [
      { type: "input_text", text: text }
    ]
  }
end

# Load the schemas from JSON file
tool_schemas = JSON.parse(File.read("schemas.json"))

# Build the tool registry
tools = {
  "sum_numbers" => method(:sum_numbers),
  "multiply_numbers" => method(:multiply_numbers),
  "subtract_numbers" => method(:subtract_numbers),
  "divide_numbers" => method(:divide_numbers),
  "power" => method(:power),
  "square_root" => method(:square_root)
}

# Create a stateless autonomous agent
agent = Agent.new(
  name: "math_assistant",
  system_prompt: "You are a helpful math assistant.",
  tools: tools,
  tool_schemas: tool_schemas,
  max_turns: 15
)

# Initialize conversation with user message
messages = [
  text_message("user", "Solve this equation: 2x² - 7x + 3 = 0 using tools")
]

# Run the agent
history, result = agent.run(messages)

# Display the final response
puts "\nFinal response:"
puts result

When we run this code, our agent demonstrates sophisticated autonomous reasoning:

🔧 Tool called: power({"a"=>-7, "b"=>2})
🔧 Tool called: multiply_numbers({"a"=>2, "b"=>3})
🔧 Tool called: multiply_numbers({"a"=>4, "b"=>6})
🔧 Tool called: subtract_numbers({"a"=>49, "b"=>24})
🔧 Tool called: square_root({"a"=>25})
🔧 Tool called: subtract_numbers({"a"=>7, "b"=>5})
🔧 Tool called: divide_numbers({"a"=>2, "b"=>4})
🔧 Tool called: sum_numbers({"a"=>7, "b"=>5})
🔧 Tool called: divide_numbers({"a"=>12, "b"=>4})

Final response:
The solutions are x = 3 and x = 1/2.

Using the quadratic formula for 2x² − 7x + 3 = 0:
- Discriminant: b² − 4ac = (−7)² − 4·2·3 = 49 − 24 = 25
- sqrt(discriminant) = 5
- x = [7 ± 5]/(2·2) ⇒ x = (7 − 5)/4 = 2/4 = 1/2, and x = (7 + 5)/4 = 12/4 = 3

Our agent systematically applied the quadratic formula by calculating ((-7)²), computing ac and then 4ac, finding the discriminant, taking the square root (√25), and finally calculating both solutions by dividing (7 − 5) and (7 + 5) by 4. Each tool call built upon previous results, demonstrating true autonomous reasoning across multiple iterations of the loop.

Summary & Practice Preparation

Together, we've successfully built an autonomous agent capable of complex, multi-step problem-solving. Our Agent class encapsulates conversation management, tool execution, and iterative decision-making in a reusable structure that can tackle problems requiring many sequential operations.

The architecture we created enables GPT-5 to operate as a true autonomous agent: it can assess situations, make decisions, execute tools, learn from results, and continue iterating until complex tasks are completed. This represents a fundamental advancement from simple tool usage to intelligent, adaptive problem-solving.

In the upcoming practice exercises, you'll implement your own autonomous agents, experiment with different developer_prompt and tool combinations, and tackle increasingly complex multi-step problems.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal