The Full Loop

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

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.output_text 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 Memorizing Names 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 Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
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.