Introduction & Overview

Throughout this course, you have mastered the fundamentals of tool integration with GPT-5: creating tool schemas, understanding GPT-5's tool use responses, and executing single tool requests. However, the approach you've learned so far has a significant limitation — it only handles one tool call per conversation turn. 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 added to the conversation history.
  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:

import OpenAI from "openai";

export interface AgentOptions {
  name: string;
  systemPrompt?: string;
  model?: string;
  tools?: Record<string, Function>;
  toolSchemas?: Array<Record<string, unknown>>;
  maxTurns?: number;
  reasoningEffort?: "minimal" | "low" | "medium" | "high";
}

export class Agent {
  // Base system prompt to be used for all agents
  private static BASE_SYSTEM_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";

  private client: OpenAI;
  public name: string;
  private model: string;
  private systemPrompt: string;
  private maxTurns: number;
  private reasoningEffort: "minimal" | "low" | "medium" | "high";
  private tools: Record<string, Function>;
  private toolSchemas: Array<Record<string, unknown>>;

  constructor({
    name,
    systemPrompt = "You are a helpful assistant.",
    model = "gpt-5",
    tools = {},
    toolSchemas = [],
    maxTurns = 10,
    reasoningEffort = "minimal"
  }: AgentOptions) {
    this.client = new OpenAI();
    this.name = name;
    this.model = model;
    this.systemPrompt = Agent.BASE_SYSTEM_PROMPT + systemPrompt;
    this.maxTurns = maxTurns;
    this.reasoningEffort = reasoningEffort;

    // Copy to isolate from external mutation
    this.tools = { ...tools };
    this.toolSchemas = [...toolSchemas];
  }
}

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

  • BASE_SYSTEM_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're combining this with custom instructions to allow for domain-specific guidance while maintaining the autonomous behavior.

  • Constructor parameters: We use an AgentOptions interface to provide type safety and flexibility for different scenarios while ensuring some safe defaults:

    • name: Provides a clear identifier for the agent, useful for debugging, logging, and when working with multiple agents in complex systems.
    • systemPrompt allows customization for specific domains like math or data analysis.
    • model specifies which GPT-5 model to use for agent interactions.
    • tools and toolSchemas:
      • Default to empty objects/arrays to avoid shared mutable defaults.
      • Copied via spread operators ({...tools} and [...toolSchemas]) so each agent gets its own independent registry and schema list, and to prevent later external mutations from affecting the agent.
    • maxTurns prevents infinite loops by limiting the number of iterative steps.
    • reasoningEffort controls how much computational effort GPT-5 applies to problem-solving, with options like "minimal," "low," "medium," or "high."
  • Access modifiers: We use private for internal implementation details and public for properties that external code needs to access, providing clear encapsulation boundaries.

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

Implementing Tool Execution
Building the Core Loop - Part 1: Understanding Stateless Design

Now we're ready to implement the heart of our autonomous agent: the run method. This method will manage the iterative loop that enables multi-step problem-solving. Let's start by understanding how our agent handles conversation state:

public async run(
  inputMessages: Array<Record<string, unknown>>
): Promise<[Array<Record<string, unknown>>, string]> {
  // Create a copy of the input messages to avoid modifying the original
  const messages = [...inputMessages];
}

The spread operator [...inputMessages] is important because it ensures our agent remains stateless. Just like normal LLM API calls, where you pass the complete conversation history each time, our agent doesn't store any conversation state between calls. Each time you call agent.run(), you provide the full context through inputMessages, and the agent processes only that specific conversation without any memory of previous interactions.

By copying the input messages instead of modifying them directly, we preserve the original conversation and allow the same agent instance to handle multiple independent conversations. This design also gives you complete control over context management — you can decide exactly what conversation history to include, filter out irrelevant messages, or combine conversations as needed before passing them to the agent.

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:

public async run(
  inputMessages: Array<Record<string, unknown>>
): Promise<[Array<Record<string, unknown>>, string]> {
  // Create a copy of the input messages to avoid modifying the original
  const messages = [...inputMessages];

  // Initialize turn counter to track iterations
  let turn = 0;

  // Loop until the model returns a final answer or the max turns is reached
  while (turn < this.maxTurns) {
    // Increment the turn
    turn++;

    // Ask the model for a response
    const response = await this.client.responses.create({
      model: this.model,
      instructions: this.systemPrompt,
      input: messages,
      tools: this.toolSchemas,
      reasoning: { effort: this.reasoningEffort },
      store: false
    });
  }
}

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 in which GPT-5 makes a decision (potentially including tool calls), and we capture that decision in our conversation history. The turn counter prevents infinite loops while allowing sufficient iterations for complex problems.

Building the Core Loop - Part 3: Handling Tool Calls

Now let's add the logic for handling tool calls within our loop. This is where the magic of autonomous behavior happens:

public async run(
  inputMessages: Array<Record<string, unknown>>
): Promise<[Array<Record<string, unknown>>, string]> {
  // Create a copy of the input messages to avoid modifying the original...

  // Loop until the model returns a final answer or the max turns is reached
  while (turn < this.maxTurns) {
    // Increment the turn...
    // Ask the model for a response...
    
    // Check if GPT-5 wants to use any tools by looking for function_calls in output
    const functionCalls = response.output.filter(
      (item): item is FunctionCallItem => item.type === "function_call"
    );

    if (functionCalls.length > 0) {
      // Initialize an array to store tool results
      const functionOutputs: Array<Record<string, unknown>> = [];

      // First, add the function calls to messages
      for (const functionCall of functionCalls) {
        messages.push({
          type: "function_call",
          name: functionCall.name,
          arguments: functionCall.arguments,
          call_id: functionCall.call_id
        });
      }

      // Then execute and collect the outputs
      for (const functionCall of functionCalls) {
        // Execute the tool with the given input
        const toolResult = this.callTool(functionCall);
        // Add result to function outputs array
        functionOutputs.push(toolResult);
      }

      // Add all function outputs to messages
      messages.push(...functionOutputs);
    }
  }
}

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

  1. Detect function calls: We use TypeScript's .filter() method with a type guard (item): item is FunctionCallItem => item.type === "function_call" to find all items that represent function calls. This not only filters the array but also narrows the type for TypeScript's type system.

  2. Add function calls to conversation: Before executing anything, we add each function call to the messages array using .push(). This maintains a complete record of what GPT-5 requested.

  3. Execute all requested tools: GPT-5 might call multiple tools in a single turn, and we need to execute each one to gather all the information it needs for its next decision.

  4. Collect and add results: We execute each tool while collecting the results in an array, then add all function outputs to the messages array using the spread operator with .push(...functionOutputs). This two-step approach (calls first, then outputs) maintains the proper conversation structure that GPT-5 expects.

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 turn.

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:

public async run(
  inputMessages: Array<Record<string, unknown>>
): Promise<[Array<Record<string, unknown>>, string]> {
  // Create a copy of the input messages to avoid modifying the original...

  // Loop until the model returns a final answer or the max turns is reached
  while (turn < this.maxTurns) {
    // Increment the turn...
    // Ask the model for a response...
    
    // Check if GPT-5 wants to use any tools by looking for function_calls in output
    const functionCalls = response.output.filter(
      (item): item is FunctionCallItem => item.type === "function_call"
    );

    if (functionCalls.length > 0) {
      // Execute each function call and add results to messages...
    } else {
      // Add the final response to messages
      messages.push({
        role: "assistant",
        content: response.output_text
      });
      
      // Return the agent history and final output
      return [messages, response.output_text];
    }
  }

  // If the max turns is reached, throw an exception
  throw new Error("Max turns reached");
}

When GPT-5 reaches a final answer, we handle the completion through a structured return process:

  1. Detect completion: When GPT-5 doesn't want to use tools (no function calls in the output), it signals that it has reached a final answer and no further iterations are needed.

  2. Extract clean response: GPT-5 provides the final text directly through response.output_text, which contains the readable answer without any tool-related content.

  3. Add to conversation history: Before returning, we add the final assistant response to the messages array using .push() to maintain a complete conversation record.

  4. Return complete state: We return an array containing both the full conversation history (messages) and the final response text (response.output_text) to maintain our stateless design. The caller can use array destructuring to access both values: const [finalMessages, result] = await agent.run(messages). This gives the caller everything needed to understand what happened and allows the conversation history to be used for follow-up questions or multi-turn interactions.

  5. Safety net for runaway loops: The throw new Error() for reaching max turns prevents infinite loops if something goes wrong. You can control this limit through the maxTurns parameter, or alternatively implement a mechanism to force GPT-5 to provide a final answer when approaching the limit rather than throwing an error.

This dual return approach reinforces our stateless architecture by giving the caller complete control over conversation state while providing both the detailed interaction history for continued conversations and the clean final answer for immediate use. Note that the method is marked as async and returns Promise<[Array<Record<string, unknown>>, string]> to properly handle the asynchronous API calls.

Complete Run Method

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

public async run(
  inputMessages: Array<Record<string, unknown>>
): Promise<[Array<Record<string, unknown>>, string]> {
  // Create a copy of the input messages to avoid modifying the original
  const messages = [...inputMessages];

  // Initialize turn counter to track iterations
  let turn = 0;

  // Loop until the model returns a final answer or the max turns is reached
  while (turn < this.maxTurns) {
    // Increment the turn
    turn++;

    // Ask the model for a response
    const response = await this.client.responses.create({
      model: this.model,
      instructions: this.systemPrompt,
      input: messages,
      tools: this.toolSchemas,
      reasoning: { effort: this.reasoningEffort },
      store: false
    });

    // Check if GPT-5 wants to use any tools by looking for function_calls in output
    const functionCalls = response.output.filter(
      (item): item is FunctionCallItem => item.type === "function_call"
    );

    if (functionCalls.length > 0) {
      // Initialize an array to store tool results
      const functionOutputs: Array<Record<string, unknown>> = [];
      
      // First, add the function calls to messages
      for (const functionCall of functionCalls) {
        messages.push({
          type: "function_call",
          name: functionCall.name,
          arguments: functionCall.arguments,
          call_id: functionCall.call_id
        });
      }
      
      // Then execute and collect the outputs
      for (const functionCall of functionCalls) {
        // Execute the tool with the given input
        const toolResult = this.callTool(functionCall);
        // Add result to function outputs array
        functionOutputs.push(toolResult);
      }

      // Add all function outputs to messages
      messages.push(...functionOutputs);
    } else {
      // Add the final response to messages
      messages.push({
        role: "assistant",
        content: response.output_text
      });
      
      // Return the agent history and final output
      return [messages, response.output_text];
    }
  }

  // If the max turns is reached, throw an exception
  throw new Error("Max turns reached");
}
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'll provide more math tools following the same pattern used across the course, so you can easily extend your agent's capabilities as needed:

import fs from "fs";
import { Agent } from "./agent";
import {
  sumNumbers,
  multiplyNumbers,
  subtractNumbers,
  divideNumbers,
  power,
  squareRoot
} from "./functions";

// Load the schemas from JSON file
const schemasJson = fs.readFileSync("schemas.json", "utf-8");
const toolSchemas = JSON.parse(schemasJson);

// Create a map of tool names to functions
const tools: Record<string, Function> = {
  sum_numbers: sumNumbers,
  multiply_numbers: multiplyNumbers,
  subtract_numbers: subtractNumbers,
  divide_numbers: divideNumbers,
  power: power,
  square_root: squareRoot
};

// Create a stateless autonomous agent
const agent = new Agent({
  name: "math_assistant",
  systemPrompt: "You are a helpful math assistant. Always use the available tools to perform calculations accurately.",
  tools: tools,
  toolSchemas: toolSchemas,
  maxTurns: 15
});

// Initialize conversation with user message
const messages: any[] = [
  { role: "user", content: "Solve this equation: 2x² - 7x + 3 = 0 using tools" }
];

// Send message to the stateless agent
const [finalMessages, result] = await agent.run(messages);

// Display the response
console.log("\nFinal response:");
console.log(result);

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

🔧 Tool called: subtract_numbers({"a":49,"b":24})
🔧 Tool called: multiply_numbers({"a":2,"b":3})
🔧 Tool called: divide_numbers({"a":7,"b":2})
🔧 Tool called: square_root({"number":25})

Final response:
To solve 2x² - 7x + 3 = 0, use the quadratic formula x = [7 ± √(49 − 24)] / (2·2).

- Discriminant: 49 − 24 = 25, √25 = 5
- Solutions:
  x = (7 + 5)/4 = 12/4 = 3
  x = (7 − 5)/4 = 2/4 = 1/2

Answer: x = 3 or x = 1/2.

Our agent systematically applied the quadratic formula by calculating the discriminant (49-24), computing intermediate values (2×3 and 7÷2), and taking the square root (√25). Each tool call built upon previous results, demonstrating true autonomous reasoning. The agent made 4 tool calls across multiple conversation turns, yet the user only sees the final, complete answer.

Note that GPT-5 may use different tool call sequences depending on the reasoningEffort parameter — higher effort levels may result in more thorough step-by-step calculations, while lower effort levels might optimize for efficiency. You can experiment with this parameter to find the right balance for your use case.

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 dozens of 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 instructions and tool combinations, and tackle increasingly complex multi-step problems. You'll gain hands-on experience with the debugging and optimization techniques needed for production agent systems, building upon the solid foundation we've created together.

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