Parallelizing Agent Tools
Introduction & Context
Welcome back! In the previous lesson, you successfully enabled your agent system to handle multiple concurrent conversations using Ruby threads. Each conversation runs in its own thread, allowing your system to manage many users simultaneously. However, there is still a critical bottleneck within each individual conversation: when the model requests multiple tools in a single turn, those tools execute one after another.
In this lesson, we will remove that bottleneck by parallelizing tool execution within a single agent turn. You will learn how to use Ruby threads to execute multiple function_call items returned by client.responses.create concurrently, dramatically improving your system's efficiency when the model needs to perform several calculations or operations at once.
Understanding the Tool Execution Bottleneck
Let's examine why sequential tool execution creates a bottleneck within a single agent turn. Currently, when the model requests multiple tools in one response, our agent processes them one at a time. This sequential approach works, but it is inefficient.
Here is what happens with the current sequential approach: when the model is asked to find the square roots of three different numbers, it may request three separate square_root function calls in a single response. With sequential execution, the agent calls the first square_root function, waits for it to complete, then calls the second, waits again, and finally calls the third.
If each calculation takes 100 milliseconds, the total time is 300 milliseconds, even though these three calculations are completely independent and could happen simultaneously.
This becomes especially problematic when tools involve slow operations. Imagine you had a tool that performs a complex computation taking 2 seconds to complete. If the model requests that tool three times in one turn, sequential execution would take 6 seconds total. But since these are three independent operations, they could all happen at the same time, reducing the total wait to just 2 seconds. The solution is to execute independent function calls concurrently using Ruby threads, allowing multiple tools to run in parallel during a single agent turn. Let's see how to implement this.
Spawning Threads for Tool Execution
Our tool functions — like sum_numbers and square_root — are regular synchronous Ruby methods. We do not need to change them at all. Instead, we change how the agent calls these tools inside the run method of the Agent class.
The Responses API returns a response object whose output is an array of items. Each tool request from the model arrives as an item with type == "function_call". To find all tool requests in a response, we filter response.output by that type:
If function_calls is non-empty, the model wants to call tools. We then separate regular tool calls from the special handoff call — since they must be handled differently:
Regular tool calls — like square_root or sum_numbers — are collected in the tool_calls array. Any handoff request is stored separately in handoff_call. This separation is essential: regular tools are independent operations that can run concurrently, while a handoff transfers control to another agent and requires special handling. Once we have made this separation, we can execute all regular tools in parallel using threads.
Executing Tools Concurrently with Threads
With all regular function calls collected in the tool_calls array, we can execute them concurrently. We create one thread per function call, let them all run simultaneously, and then wait for every thread to finish:
For each function_call object in tool_calls, we create a new thread with Thread.new { call_tool(function_call) }. Each thread immediately starts executing its call_tool method in parallel with all the others — the map operation returns an array of thread objects stored in tool_threads.
Inside call_tool, the function is executed and the result is wrapped in a function_call_output hash — the format the Responses API expects when feeding tool results back into the conversation:
After spawning all threads, we gather their results with map(&:value):
The value method blocks until the thread finishes and returns its result. By mapping value over all threads, we wait for every tool to complete and collect all function_call_output hashes into a single array, preserving order. The total wait time is determined by the slowest individual tool rather than the sum of all tools.
Handling Handoffs with Concurrent Tools
The run method handles any handoff request before regular tool threads are started. Because a handoff can redirect the entire conversation to another agent, it must be resolved first:
If the model included a handoff function call in its response, call_handoff is invoked immediately. It returns two values: a boolean success and a result. If the handoff succeeds, the run method returns the target agent's response right away, ending this agent's involvement in the conversation.
If the handoff fails — for example, because the named target agent does not exist — success is false and result is a function_call_output hash containing an error message. In that case, the code appends the function_call_message for the failed handoff to the conversation history, and the error output is later merged into function_outputs so the model can see what went wrong:
This design means regular tools always run concurrently, while a handoff is either resolved immediately — returning control to the caller — or converted into a plain function_call_output error message that feeds back into the next model turn.
Complete Tool Execution Flow
Let's look at the complete run method to see how all the pieces fit together:
The loop runs until the model stops calling tools or max_turns is reached. Each iteration follows these steps:
- Creates a response by calling
create_response(messages), which sends the full conversation history to theResponses API. - Collects function calls by filtering
response.outputfor items whosetypeis"function_call". - Separates the handoff from regular tool calls, storing them in
handoff_callandtool_callsrespectively. - Attempts the handoff if present. A successful
handoffreturns immediately; a failedhandoffappends itsfunction_call_messageto history and stores the error output inresult. - Appends
function_call_messageitems for every regular tool call. These records tell theResponses APIwhich functions were invoked with which arguments and call IDs. - Spawns threads — one per regular tool call — and runs all of them concurrently.
- Builds
function_outputs: the failed-handoff error (if any) followed by allfunction_call_outputhashes from the concurrent threads. - Appends
function_outputsto the conversation so the model can read every tool result in the next turn.
When function_calls is empty, the model is done calling tools. The assistant's final text is extracted via response.output_text, appended to messages, and returned together with the full conversation history.
Observing Concurrent Tool Execution
When we run our agent with a request that triggers multiple tool calls, the output demonstrates how tools execute simultaneously:
Notice how all three square_root tool calls appear in rapid succession. This happens because they are executing in separate threads concurrently. The log lines may even interleave or appear in slightly different orders on different runs, depending on thread scheduling. The key observation is that all three tool calls start essentially at the same time — rather than waiting for each to complete before starting the next.
If each square_root calculation took 100 milliseconds, sequential execution would require 300 milliseconds total, but concurrent execution completes in just over 100 milliseconds — the time of the slowest single operation.
To observe this timing effect yourself, add a short sleep inside one of the existing tool functions in functions.rb:
With this change, asking the agent to compute three square roots concurrently will still take only about 1 second instead of 3 seconds, because all three threads sleep simultaneously. Remove the sleep call when you are done experimenting.
Summary & Practice Exercises
You have successfully parallelized tool execution within your Ruby agent system by using threads to run multiple function calls concurrently. You learned how to:
- Filter
response.outputforfunction_callitems to detect tool requests from the model - Separate regular tool calls from the
handoffcall insideAgent#run - Spawn a thread for each independent tool call using
Thread.new - Synchronize all threads with
map(&:value)to collectfunction_call_outputresults in order - Append both
function_call_messageandfunction_call_outputrecords to the conversation so theResponses APIloop can continue correctly
The performance improvement is significant: instead of executing tools one at a time, your agent now runs multiple tools simultaneously, reducing the total time to roughly the duration of the slowest single tool.
To practice these concepts, try the following exercises:
-
Multiple Tool Types: Modify
main.rbto ask a question that requires different tool types in one turn, such as "Calculate5 + 3and find the square root of64." Observe how bothsum_numbersandsquare_rootexecute concurrently in the logs. -
Error Handling: Request a calculation that will cause an error, like "Divide
10by0and also calculate5times3." Verify that the error individe_numbers(division by zero) does not preventmultiply_numbersfrom completing successfully, and that the model receives both results. -
Sequential vs Concurrent Timing: Add
sleep(1)inside one of your tool functions to simulate a slow operation. Request that tool three times in one question and observe how concurrent execution takes about1 secondtotal instead of3 seconds. -
Complex Calculations: Ask the agent to perform a calculation that requires many steps, such as "Find the square roots of
144,256,625, and900, then sum all the results." Observe how all foursquare_rootcalls execute concurrently, followed by thesum_numberscall in a second turn.
In the next lesson, we will explore how to combine conversation-level parallelism with tool-level parallelism, enabling your system to handle multiple users each making multi-tool requests simultaneously.
