Building Agent Orchestrators

Introduction & Context

Welcome back! In the previous lesson, you mastered building agentic pipelines in which specialized agents work together in a fixed sequence. Today, we're taking a significant architectural leap forward by learning how to build agent orchestration systems, where a central planner agent can dynamically decide which specialist agents to call based on the specific task at hand.

The key insight is that complex agentic systems can be wrapped as simple tools for other agents. This creates a powerful hierarchy in which agents can leverage the full capabilities of other agents as easily as they use basic functions, enabling much more flexible and intelligent problem-solving than fixed pipeline sequences.

The Agent-as-Tool Concept

The fundamental difference between pipelines and orchestration lies in decision-making authority. In a pipeline, you, as the developer, decide the sequence: every problem goes through analysis, then calculation, then presentation. In orchestration, the central agent makes these decisions dynamically based on the nature of each specific request. The key insight is that we can encapsulate an agent call as a tool, allowing an orchestrator agent with access to that tool to seamlessly call another specialist agent.

Here's how the agent orchestration flow works:

  1. User submits a request to the orchestrator agent.
  2. Orchestrator analyzes the request and decides whether to handle it directly or delegate to a specialist.
  3. If delegation is needed, the orchestrator calls the specialist agent as a tool.
  4. Specialist processes the request and returns results to the orchestrator.
  5. Orchestrator provides the final response to the user.

Consider how this changes the system's behavior. If someone asks, "What is the capital of France?", a pipeline system would still run the question through all three agents unnecessarily. An orchestrated system, however, allows the central agent to recognize that this is a straightforward knowledge question requiring no mathematical tools and respond directly. But when someone asks about solving equations, the orchestrator can intelligently delegate this to a calculator specialist. This dynamic delegation creates systems that are both more efficient and more capable.

Building Specialist Agents

Before we can orchestrate agents, we need to create the specialist agents that will serve as tools. Let's build a calculator assistant that specializes in mathematical problem-solving, designed to work as a standalone tool that can handle mathematical questions from start to finish.

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

# Helper to build a typed message for the Responses API
def text_message(role, text)
  {
    role: role,
    content: [
      { type: "input_text", text: text }
    ]
  }
end

# Load tool schemas
tool_schemas = JSON.parse(File.read("schemas.json"))

# Math tools
math_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 calculator assistant
calculator_assistant = Agent.new(
  name: "calculator_assistant",
  system_prompt: "You are a calculator assistant. Always use the available tools to perform calculations accurately.",
  tools: math_tools,
  tool_schemas: tool_schemas
)

This calculator_assistant is designed as a complete mathematical problem-solver with access to all the mathematical tools like sum_numbers, multiply_numbers, subtract_numbers, divide_numbers, power, and square_root. The system_prompt establishes the agent's expertise in mathematics and nudges it to rely on tools for accuracy. This balance is important for specialist agents — they need clear domain focus while maintaining enough flexibility to be useful as tools for different orchestrators.

Creating Agent Tool Wrappers

To enable agents to use other agents as tools, we need to wrap any agent into a callable function that follows the same interface as our other tools. This helper function create_agent_tool will streamline our workflow: we'll call it with our specialist agent to get back both a callable lambda function and its schema, which we can then pass to our orchestrator agent, enabling it to call the specialist agent just like any other tool.

def create_agent_tool(agent, description)
  tool_function = lambda do |message:|
    puts "🦾 Agent tool called (#{agent.name}): #{message}"

    _history, response = agent.run([text_message("user", message)])

    puts "📊 Agent response (#{agent.name}): #{response}"
    response
  end

  tool_schema = {
    "type" => "function",
    "name" => "#{agent.name}_agent",
    "description" => description,
    "parameters" => {
      "type" => "object",
      "properties" => {
        "message" => {
          "type" => "string",
          "description" => "The message to send to the agent"
        }
      },
      "required" => ["message"],
      "additionalProperties" => false
    }
  }

  [tool_function, tool_schema]
end

The key part of this function is the lambda that wraps the agent call. When we call agent.run, it returns two things: the complete message history and the final text response. We use _history to ignore the message history because when one agent calls another as a tool, it only needs the final answer — not the entire conversation history with all the intermediate function calls and reasoning steps.

For example, if our orchestrator asks the calculator agent to solve an equation, it just wants the final answer like x = 2 and x = 3, not the detailed trace of every mathematical function that was called along the way. This keeps the tool interface clean and focused on results.

Notice the schema follows the OpenAI function-calling format: a top-level "type" => "function", a "name", a "description", and a "parameters" JSON Schema object. We set "additionalProperties" => false so GPT-5 must send exactly the message field we expect. The function returns an array containing both the callable lambda and the tool_schema, allowing us to easily register the agent as a tool with any orchestrator.

Implementing the Orchestrator Agent

With our specialist agent ready and our wrapper function defined, we can now create the agent tool wrapper and build our orchestrator agent. The orchestrator will be a general-purpose assistant that can handle various types of questions, using the calculator_assistant when mathematical expertise is needed.

# Create agent tool for calculator
calculator_tool_function, calculator_tool_schema = create_agent_tool(
  calculator_assistant,
  "Call the calculator assistant to solve mathematical problems and equations."
)

# Create a general assistant
helpful_assistant = Agent.new(
  name: "helpful_assistant",
  system_prompt: (
    "You are a central planner who calls specialist agents. " \
    "If a relevant tool exists, delegate and use its result; do not answer yourself. " \
    "Only answer directly when no relevant tool exists."
  ),
  tools: { calculator_tool_schema["name"] => calculator_tool_function },
  tool_schemas: [calculator_tool_schema]
)

The orchestrator agent (helpful_assistant) has a system_prompt that frames it as a central planner. The instruction to "delegate and use its result; do not answer yourself" encourages the model to route mathematical work to the specialist rather than computing on its own. Notice how we pass the calculator tool to the orchestrator just like any other tool — from the orchestrator's perspective, calling another agent is no different from calling a mathematical function.

The tools hash maps the tool name (from the schema's "name") to the actual callable lambda, while tool_schemas provides the array of schemas that describe available tools to the model. When GPT-5 emits a function_call for calculator_assistant_agent, our Agent#call_tool looks up that name in the tools hash and invokes the lambda, passing the JSON-parsed message argument as a keyword.

Testing General Knowledge Questions

Let's test our orchestrated system with a general knowledge question to see how the orchestrator handles tasks that don't require specialist agents.

messages = [
  text_message("user", "What is the capital of France?")
]

result_messages, response = helpful_assistant.run(messages)

puts response

When we run this code, the orchestrator agent will receive the question and analyze whether it needs specialist assistance. Since this is a straightforward factual question about geography with no relevant tool, the agent answers directly from its general knowledge without calling any tools.

Here's what happens when we execute this request:

Paris.

The orchestrator correctly identified this as a question it could handle directly. Because there's no geography tool available, it followed its instructions and answered immediately, demonstrating the system's efficiency for simple tasks that don't require specialist assistance.

Testing Mathematical Problem Delegation

Now let's test the system with a mathematical question that requires the specialist agent's expertise.

messages = [
  text_message("user", "What is the solution to the equation x² - 5x + 6 = 0?")
]

result_messages, response = helpful_assistant.run(messages)

puts "\n=== Final Response ===\n\n"
puts response

In this example, the orchestrator should recognize that solving a quadratic equation is best handled by the calculator specialist. The agent emits a function_call for the calculator_assistant_agent tool, which runs the calculator agent. That agent then uses its mathematical functions to solve the equation step by step.

Here's an example of the execution showing the delegation and calculation process:

🔧 Tool called: calculator_assistant_agent({"message"=>"Solve the quadratic equation x^2 - 5x + 6 = 0."})
🦾 Agent tool called (calculator_assistant): Solve the quadratic equation x^2 - 5x + 6 = 0.
🔧 Tool called: power({"a"=>-5, "b"=>2})
🔧 Tool called: subtract_numbers({"a"=>25, "b"=>24})
🔧 Tool called: square_root({"a"=>1})
🔧 Tool called: sum_numbers({"a"=>5, "b"=>1})
🔧 Tool called: divide_numbers({"a"=>6, "b"=>2})
🔧 Tool called: subtract_numbers({"a"=>5, "b"=>1})
🔧 Tool called: divide_numbers({"a"=>4, "b"=>2})
📊 Agent response (calculator_assistant): The solutions are x = 2 and x = 3.

=== Final Response ===

The solutions are x = 2 and x = 3.

For the mathematical equation x² - 5x + 6 = 0, the orchestrator recognized that this required mathematical expertise and delegated the task to the calculator_assistant. The debug output (from our Agent class's puts statements) shows:

  1. The orchestrator calling calculator_assistant_agent with the mathematical question (a top-level function_call).
  2. The calculator assistant being invoked with the delegated message (logged by our lambda).
  3. The calculator performing multiple tool calls (power, subtract_numbers, square_root, etc.) to solve the equation.
  4. The calculator assistant returning its complete solution to the orchestrator.
  5. The orchestrator presenting the final formatted answer to the user.

Note that the exact sequence of tool calls and reasoning steps is determined by GPT-5's decision-making process, so while the format of the debug output comes from our Ruby Agent implementation, the specific tools called and their order may vary between runs. The key point is that the orchestrator successfully delegates to the specialist, the specialist solves the problem using its available tools, and the results flow back through the orchestration hierarchy.

When the orchestrator calls the calculator agent tool (via the lambda we created), it internally runs calculator_assistant.run(...), which returns both the history and response. The text response becomes the value returned from the lambda, which our Agent#call_tool packages into a function_call_output item (with its result JSON-encoded) and feeds back to the orchestrator on the next turn — exactly how a normal tool result is handled.

Summary & Exercises

You've now learned how to build agent orchestration systems in which a central planner can dynamically delegate tasks to specialist agents. This architectural pattern offers significant advantages over fixed pipelines: it's more efficient for simple tasks, more flexible for complex problems, and allows you to build increasingly sophisticated specialist agents without complicating the overall system design.

In your upcoming practice exercises, you'll build your own orchestrated agent systems with multiple specialists for different domains. This foundation will enable you to create much more sophisticated and flexible agentic systems that can adapt their approach based on the specific requirements of each task.

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