The Full Loop
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:
- Send a request with
toolsdefined, along with the user's actual question. - Receive a response containing one or more
function_callitems instead of (or alongside) a text answer. - Execute the real function(s) named in those calls, using the arguments the model provided.
- 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.