Lesson 3 of 3 · Mastering Function Calling & Tool Use

Handling Tool Calls

reading13 min

You have defined your tools and the model is returning tool_calls. Now you need to build the machinery that receives those calls, executes the right function, handles errors, and feeds results back to the model. This is where function calling becomes a real engineering problem.

The Tool Call Response Structure

When the model wants to call a function, the response looks like this:

Python
choice = response.choices[0]

# Check if the model wants to call tools
if choice.finish_reason == "tool_calls":
    for tool_call in choice.message.tool_calls:
        print(f"Call ID: {tool_call.id}")           # "call_abc123"
        print(f"Function: {tool_call.function.name}") # "get_order_status"
        print(f"Args: {tool_call.function.arguments}") # '{"order_id": "ORD-78901"}'

Three critical fields:

  • id: A unique identifier for this specific call. You must return this ID when sending the result back.
  • function.name: Which function the model wants to call.
  • function.arguments: A JSON string (not an object -- you must parse it) containing the arguments.
Arguments Are a JSON String

The arguments field is a JSON string, not a parsed object. You must call json.loads() on it. This is the single most common source of bugs in function calling implementations. If you try to access tool_call.function.arguments["order_id"], you will get a character from the string, not the value.

Building a Function Dispatcher

In production, you need a dispatcher that routes tool calls to the correct function:

Python
import json

# Registry of available functions
FUNCTION_REGISTRY = {
    "get_order_status": get_order_status,
    "search_products": search_products,
    "create_support_ticket": create_support_ticket,
}

def handle_tool_calls(response):
    """Process all tool calls from a model response."""
    tool_messages = []
    
    for tool_call in response.choices[0].message.tool_calls:
        function_name = tool_call.function.name
        call_id = tool_call.id
        
        # Parse arguments
        try:
            arguments = json.loads(tool_call.function.arguments)
        except json.JSONDecodeError as e:
            # Model produced invalid JSON -- rare but possible
            tool_messages.append({
                "role": "tool",
                "tool_call_id": call_id,
                "content": json.dumps({"error": f"Invalid arguments: {e}"})
            })
            continue
        
        # Look up the function
        function = FUNCTION_REGISTRY.get(function_name)
        if not function:
            tool_messages.append({
                "role": "tool",
                "tool_call_id": call_id,
                "content": json.dumps({"error": f"Unknown function: {function_name}"})
            })
            continue
        
        # Execute the function
        try:
            result = function(**arguments)
            tool_messages.append({
                "role": "tool",
                "tool_call_id": call_id,
                "content": json.dumps(result)
            })
        except Exception as e:
            tool_messages.append({
                "role": "tool",
                "tool_call_id": call_id,
                "content": json.dumps({"error": str(e)})
            })
    
    return tool_messages

Returning Results to the Model

After executing the function(s), you send the results back by appending to the conversation:

Python
def complete_tool_calls(messages, tools, response):
    """Execute tool calls and get the model's final response."""
    
    # Add the assistant's tool_call message to history
    messages.append(response.choices[0].message)
    
    # Execute all tool calls and get results
    tool_messages = handle_tool_calls(response)
    messages.extend(tool_messages)
    
    # Send back to the model for synthesis
    follow_up = client.chat.completions.create(
        model="gpt-4.1",
        messages=messages,
        tools=tools
    )
    
    return follow_up
Always Include the Original tool_call Message

When sending results back, you must include the assistant's original message (with the tool_calls) in the conversation history, followed by the tool result messages. The model needs to see the full sequence: it asked for data, you provided data, now it can respond. Skipping the assistant message breaks the conversation flow.

Error Handling Patterns

What happens when a function call fails? Do not hide the error from the model. Return it as a tool result:

Python
try:
    result = get_order_status(order_id=arguments["order_id"])
    content = json.dumps(result)
except OrderNotFoundError:
    content = json.dumps({
        "error": "order_not_found",
        "message": f"No order found with ID {arguments['order_id']}"
    })
except DatabaseError:
    content = json.dumps({
        "error": "service_unavailable",
        "message": "The order system is temporarily unavailable."
    })

The model handles errors gracefully when you give it structured error information. It might tell the user "I could not find that order number -- could you double-check it?" or "Our order system is temporarily down -- please try again in a few minutes."

Do

Return errors as structured JSON in the tool result. The model is excellent at translating error codes into helpful, human-friendly messages. Include enough detail for the model to suggest next steps.

Don't

Raise exceptions that crash your application. Do not return empty strings -- the model will be confused. Do not hide errors by returning fake data -- the model will present false information to the user.

The Complete Conversation Flow

Here is the full flow in one piece:

Python
def chat(user_message, conversation_history, tools):
    conversation_history.append({"role": "user", "content": user_message})
    
    response = client.chat.completions.create(
        model="gpt-4.1",
        messages=conversation_history,
        tools=tools
    )
    
    # Loop: the model might need multiple rounds of tool calls
    while response.choices[0].finish_reason == "tool_calls":
        # Add the assistant's tool request
        conversation_history.append(response.choices[0].message)
        
        # Execute all tool calls
        tool_results = handle_tool_calls(response)
        conversation_history.extend(tool_results)
        
        # Get next response (might be another tool call or final answer)
        response = client.chat.completions.create(
            model="gpt-4.1",
            messages=conversation_history,
            tools=tools
        )
    
    # Final text response
    assistant_message = response.choices[0].message.content
    conversation_history.append({"role": "assistant", "content": assistant_message})
    
    return assistant_message

Notice the while loop. The model can request multiple rounds of tool calls before producing a final answer. For example, it might first look up an order, then check the shipping carrier, then check the delivery schedule -- three separate tool calls before answering.

The Importance of call_id
Concept Card