The Full Loop

Ma Mahalakshmi V Updated 16 Sep 2026
11 min read ·Lesson 33 of 72

From a Single Function Call to a Complete Interaction

Lessons 1 and 2 showed the model requesting a function call, but stopped short of completing the interaction — no function was actually run, and no final answer was produced. This lesson covers what "completing the interaction" actually means: the full round trip of sending a request, receiving a function call, executing the real function, sending its result back to the model, and getting a final natural-language answer. This round trip is the actual mechanism that makes function calling useful, and it is worth understanding as a complete loop rather than as isolated pieces.

The Four Steps

Every function-calling interaction, at its simplest, follows the same four steps:

  1. Send a request with tools defined, along with the user's actual question.
  2. Receive a response containing one or more function_call items instead of (or alongside) a text answer.
  3. Execute the real function(s) named in those calls, using the arguments the model provided.
  4. Send the function's result back to the model in a follow-up request, referencing the original call, and receive a final answer — which may itself contain another function call, if the model needs more information before it can finish.

Step-by-Step Implementation

def get_current_temperature(city: str) -> dict:
    # A real implementation would call an actual weather API.
    # This is a stand-in returning fixed data for illustration.
    fake_data = {"Boston": 18, "Miami": 29, "Chicago": 12}
    return {"city": city, "temperature_celsius": fake_data.get(city, 20)}

tools = [
    {
        "type": "function",
        "name": "get_current_temperature",
        "description": "Get the current temperature for a given city, in Celsius.",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
            "additionalProperties": False,
        },
    }
]

# Step 1: initial request
input_messages = [{"role": "user", "content": "What's the temperature in Boston right now?"}]
response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)

# Step 2: find the function call in the response
function_call = None
for item in response.output:
    if item.type == "function_call":
        function_call = item
        break

At this point, function_call.name is "get_current_temperature" and function_call.arguments is a JSON string like '{"city": "Boston"}' — a string, not an already-parsed dictionary, which matters for the next step.

Executing the Function and Building the Follow-Up Request

import json

if function_call:
    # Step 3: parse the arguments and actually run the function
    args = json.loads(function_call.arguments)
    result = get_current_temperature(**args)

    # Step 4: send the result back, referencing the original call
    input_messages.append(function_call)  # the model's own function_call item
    input_messages.append({
        "type": "function_call_output",
        "call_id": function_call.call_id,
        "output": json.dumps(result),
    })

    final_response = client.responses.create(
        model="gpt-5.6-terra",
        input=input_messages,
        tools=tools,
    )
    print(final_response.output_text)

Running this prints something like "The current temperature in Boston is 18°C." — a natural-language answer synthesized from the actual data your function returned, not from anything the model guessed. Several details in this follow-up step matter and are easy to get wrong on a first attempt: function_call.arguments is always a JSON-encoded string that must be parsed with json.loads() before use, not a dictionary the SDK has already decoded for you; the follow-up input must include the entire prior conversation (the original user message, the model's own function_call item, and the new function_call_output), not just the new pieces, since the model has no memory between separate API calls and needs the full history to make sense of what it's looking at (a point Unit 4 covered in detail for ordinary conversation, which applies identically here); and the call_id on the function_call_output must exactly match the call_id the model generated on its own function_call item, since that identifier is what lets the model connect a given result back to the specific call it made — this matters especially once multiple function calls appear in a single turn, which Lesson 4 covers.

Why the Model's Own function_call Item Must Be Included

A detail worth calling out explicitly, since skipping it is a common source of confusing errors: the follow-up request must include the model's own function_call output item from the first response, not just your function_call_output. This can feel redundant — after all, your code already knows what function was called and with what arguments — but the model itself needs that item present in the conversation history to understand what its own previous turn actually was. Without it, the follow-up request effectively presents the model with a function_call_output that has no corresponding call in the visible history, which is an inconsistent conversation state the API is not designed to accept gracefully.

Handling the Case Where No Function Call Occurred

A robust implementation needs to handle the branch where the model answers directly without requesting any function call at all — the case Lesson 1 demonstrated with a general-knowledge question.

def ask_with_tools(question: str, tools: list) -> str:
    input_messages = [{"role": "user", "content": question}]
    response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)

    function_calls = [item for item in response.output if item.type == "function_call"]

    if not function_calls:
        return response.output_text

    for call in function_calls:
        args = json.loads(call.arguments)
        result = get_current_temperature(**args)
        input_messages.append(call)
        input_messages.append({
            "type": "function_call_output",
            "call_id": call.call_id,
            "output": json.dumps(result),
        })

    final_response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)
    return final_response.output_text

print(ask_with_tools("What's the temperature in Miami?", tools))
print(ask_with_tools("What is 15% of 200?", tools))

This function checks whether function_calls is empty and, if so, returns response.output_text directly from the first response — correctly handling both the case where a tool is needed and the case where it isn't, rather than assuming every call to this function will produce exactly one function call to handle. Note also that this version loops over function_calls rather than assuming there's exactly one, which anticipates Lesson 4's coverage of a single turn producing multiple function calls at once (the model asking for the temperature in two different cities in response to one user question, for instance).

Allowing Multiple Rounds of Function Calls

Some interactions require more than one round trip — the model might need the result of one function call before it knows what to ask for next. A general implementation should loop until the model stops requesting function calls, rather than assuming a fixed number of rounds.

def run_conversation_with_tools(question: str, tools: list, available_functions: dict, max_rounds: int = 5) -> str:
    input_messages = [{"role": "user", "content": question}]

    for _ in range(max_rounds):
        response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)
        function_calls = [item for item in response.output if item.type == "function_call"]

        if not function_calls:
            return response.output_text

        for call in function_calls:
            args = json.loads(call.arguments)
            function_to_run = available_functions[call.name]
            result = function_to_run(**args)
            input_messages.append(call)
            input_messages.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })

    return "Reached maximum number of tool-calling rounds without a final answer."

available_functions = {"get_current_temperature": get_current_temperature}
answer = run_conversation_with_tools("What's the temperature in Boston?", tools, available_functions)
print(answer)

The max_rounds limit here is a deliberate safety measure, not an incidental detail: without some cap on how many rounds of function calling a single conversation can go through, a model that gets stuck repeatedly requesting function calls without ever converging on a final answer (due to a schema mismatch, contradictory tool results, or a genuinely difficult multi-step task) would loop indefinitely, silently consuming cost and time with no forward progress. Returning a clear message when the cap is hit, rather than looping forever or crashing on an unrelated error once resources are exhausted, keeps this failure mode visible and controlled rather than mysterious. The available_functions dictionary mapping tool names to actual callables is what makes this loop generic across however many different tools are registered, rather than hardcoding a single specific function name inside the loop — a pattern Lesson 4 builds on directly when covering several distinct tools available at once.

Testing the Loop Without Real API Calls

Following this course's established dependency-injection pattern, the loop's control flow — when it stops, how it dispatches to the right function, how it handles the max-rounds cap — can be tested with a fake client that returns a scripted sequence of responses.

class FakeFunctionCall:
    def __init__(self, name, arguments, call_id):
        self.type = "function_call"
        self.name = name
        self.arguments = arguments
        self.call_id = call_id

class FakeResponse:
    def __init__(self, output, output_text=None):
        self.output = output
        self.output_text = output_text

class FakeClient:
    def __init__(self, scripted_responses):
        self._responses = scripted_responses
        self._call_count = 0
        self.responses = self

    def create(self, **kwargs):
        response = self._responses[self._call_count]
        self._call_count += 1
        return response

def test_loop_stops_after_function_call_then_final_answer():
    scripted = [
        FakeResponse(output=[FakeFunctionCall("get_current_temperature", '{"city": "Boston"}', "call_1")]),
        FakeResponse(output=[], output_text="It's 18°C in Boston."),
    ]
    fake_client = FakeClient(scripted)

    input_messages = [{"role": "user", "content": "Temperature in Boston?"}]
    rounds_run = 0
    for _ in range(5):
        response = fake_client.responses.create(input=input_messages, tools=[])
        rounds_run += 1
        function_calls = [item for item in response.output if item.type == "function_call"]
        if not function_calls:
            assert response.output_text == "It's 18°C in Boston."
            break
        for call in function_calls:
            input_messages.append({"role": "function_result", "call_id": call.call_id, "content": "18"})

    assert rounds_run == 2
    print("PASS: loop runs exactly two rounds and returns the expected final answer")

test_loop_stops_after_function_call_then_final_answer()

FakeClient here returns a pre-scripted sequence of responses rather than making any real API call, letting the test verify that the loop correctly stops on the first response with no function calls, correctly counts the number of rounds, and correctly surfaces the final output_text — all without needing a real model call, a real function execution, or any network access. This is the same cost- and determinism-motivated testing pattern used throughout this course, applied here to the specific control-flow logic of the tool-calling loop, which is exactly the kind of logic that benefits most from fast, deterministic tests since a real model's behavior (whether it decides to call a function, and how many rounds it takes) is not something a test should depend on to be reliable.

Feeding a Function's Failure Back Into the Loop

Real functions fail — a lookup fails to find a record, a downstream service times out, an argument turns out to be invalid despite passing schema validation. The full loop needs a defined behavior for this case too, and the natural one is to feed the failure back to the model as the function's result, rather than letting an exception escape the loop and crash the whole interaction.

def run_function_safely(function_to_run, args: dict) -> dict:
    try:
        result = function_to_run(**args)
        return {"success": True, "result": result}
    except Exception as e:
        return {"success": False, "error": str(e)}

def run_conversation_with_tools_safely(question: str, tools: list, available_functions: dict, max_rounds: int = 5) -> str:
    input_messages = [{"role": "user", "content": question}]

    for _ in range(max_rounds):
        response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)
        function_calls = [item for item in response.output if item.type == "function_call"]

        if not function_calls:
            return response.output_text

        for call in function_calls:
            args = json.loads(call.arguments)
            function_to_run = available_functions[call.name]
            outcome = run_function_safely(function_to_run, args)
            input_messages.append(call)
            input_messages.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(outcome),
            })

    return "Reached maximum number of tool-calling rounds without a final answer."

Wrapping the actual function execution in run_function_safely() and always returning a JSON-serializable outcome (whether success or failure) rather than letting an exception propagate keeps the loop itself simple and uniform: the loop doesn't need a special code path for "the function raised an exception," because a failure is just another kind of function result, expressed in the same {"success": ..., ...} shape the model already expects to see. This gives the model useful information to work with — it can tell the user a lookup failed, try calling the same function again with different arguments if the failure suggests the arguments were the problem, or attempt a different tool entirely, all of which are more useful outcomes than the whole conversation crashing on an unhandled exception the user never sees a coherent explanation for. Lesson 5 goes further into designing what information is safe and useful to include in an error message returned this way, since a raw exception message can sometimes leak internal implementation details that shouldn't be exposed to a model whose output may eventually reach an end user.

Common Mistakes

Forgetting to parse function_call.arguments with json.loads(), treating it as an already-parsed dictionary when it is actually a JSON-encoded string, producing a TypeError when the raw string is passed directly into a function expecting keyword arguments.

Omitting the model's own function_call item from the follow-up request's input, sending only the function_call_output and leaving the conversation history in an inconsistent state the model cannot correctly interpret.

Mismatching call_id between the model's function_call and your function_call_output, breaking the model's ability to connect a result back to the specific call that produced it.

Writing a tool-calling loop with no maximum round limit, risking an unbounded, silently expensive loop if the model never converges on a final answer.

Best Practices

Always include the full prior conversation — including the model's own function-call items — in every follow-up request, since the model has no memory between calls and needs complete history to reason correctly about function results.

Impose an explicit maximum number of tool-calling rounds and handle the case where that limit is reached with a clear message, rather than allowing an unbounded loop.

Check for the presence of function_call items rather than assuming every response contains one, correctly handling both direct answers and tool-calling responses in the same code path.

Test the loop's control flow with a fake client returning scripted responses, verifying round-counting, dispatch, and termination logic deterministically and without incurring real API cost.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about The Full Loop and get answers drawn from it.

Signed-in readers only.