Multi-Turn Conversation
On this page
Beyond Single Calls
In a real-world application, users don’t ask one question and disappear. They chain requests, circle back to previous topics, and drop hints like “what about this one?” that the sentence alone can’t resolve. Function calling must work within this multi-turn conversation context, where the model can call functions at every step of the dialogue without ever losing track of what came before.
The Principle: Maintain History
The key comes down to one sentence: keep all messages in a list that grows with each exchange. The model has no memory of its own between API calls; the only context it has is what you send back. This complete history is what allows it to understand implicit references.
import os
import json
from mistralai.client import Mistral
from functools import partial
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
model = "mistral-large-latest"
# Conversation history
messages = [
{
"role": "system",
"content": "You are a payment tracking assistant. "
"Respond in concise English."
}
]
Complete Conversation Loop
The code below works in two stages. process_tool_calls executes all calls requested by the model and restarts the conversation with the results; chat handles a full turn, from the user’s input to the final text.
def process_tool_calls(response, messages, available_functions, tools):
"""Processes tool_calls and returns the final response."""
message = response.choices[0].message
messages.append(message)
# Execute each tool_call
for tool_call in message.tool_calls:
function_name = tool_call.function.name
function_params = json.loads(tool_call.function.arguments)
if function_name in available_functions:
result = available_functions[function_name](**function_params)
else:
result = json.dumps({"error": f"Unknown function {function_name}"})
messages.append({
"role": "tool",
"name": function_name,
"content": result,
"tool_call_id": tool_call.id
})
# Call the model again with the results
return client.chat.complete(
model=model, messages=messages, tools=tools
)
def chat(user_input: str, messages: list, tools: list,
available_functions: dict) -> str:
"""Handles a complete conversation turn."""
messages.append({"role": "user", "content": user_input})
response = client.chat.complete(
model=model, messages=messages, tools=tools
)
message = response.choices[0].message
# Loop as long as the model wants to call functions
while message.tool_calls and len(message.tool_calls) > 0:
response = process_tool_calls(
response, messages, available_functions, tools
)
message = response.choices[0].message
# The model is done—it responds with text
messages.append(message)
return message.content
The while loop is the critical piece. After receiving a function result, the model might well request another one: the loop continues as long as it produces tool_calls and stops only when it finally returns text. A simple if would handle the first call and then return an empty response in any case where two calls were needed.
Multi-Turn Conversation Example
Let’s see what this loop looks like across four chained turns.
# Turn 1
reponse = chat("What is the status of payment T1001?",
messages, tools, available_functions)
print(f"Assistant : {reponse}")
# → "Payment T1001 status: paid."
# Turn 2 — follow-up question
reponse = chat("When was it made?",
messages, tools, available_functions)
print(f"Assistant : {reponse}")
# → "Payment T1001 was made on March 15, 2026."
# Turn 3 — new transaction
reponse = chat("Compare with payment T1003.",
messages, tools, available_functions)
print(f"Assistant : {reponse}")
# → "Payment T1003 is pending, unlike T1001 which is paid."
# Turn 4 — question without function
reponse = chat("Thanks, that's all for today.",
messages, tools, available_functions)
print(f"Assistant : {reponse}")
# → "You're welcome! Feel free to ask if you have other questions."
Turn 2 is the one to examine closely. “When was it made?” contains no identifier; the history is what allows the model to understand this still refers to T1001 and to call retrieve_payment_date with the correct argument. Remove the messages from Turn 1 and this sentence becomes unsolvable. In Turn 3, the model may decide to call multiple functions—status and date for T1003—to build the comparison with results already obtained for T1001. Turn 4 triggers no calls at all: the model knows when to hold back too.
Chaining Sequential Calls
Some requests require the model to wait for one result before knowing what to ask for next. The while loop in chat() handles this case without a single extra line of code.
# User asks for something requiring 2 sequential calls
reponse = chat(
"Show me the status and date of payment T1002.",
messages, tools, available_functions
)
# The model may:
# 1. Call retrieve_payment_status(T1002)
# 2. Receive the result
# 3. Call retrieve_payment_date(T1002)
# 4. Receive the result
# 5. Formulate the final response with both pieces of information
Viewed from the message history, the sequential flow looks like this:
user → assistant(tool_call_1) → tool(result_1) → assistant(tool_call_2) → tool(result_2) → assistant(response)
Count the assistant turns: three, meaning three requests billed to the API. That’s the cost of sequential execution, and the reason the next lesson focuses on parallel calls.
Managing History Size
In production, a long-running conversation gets expensive: every turn sends back the entire set of previous messages, and the token bill grows quadratically. A simple strategy is to keep only the system message and the most recent exchanges.
def trim_messages(messages: list, max_messages: int = 20) -> list:
"""Keeps the system message and the last N messages."""
system_messages = [m for m in messages if hasattr(m, "role")
and m.get("role") == "system"
or (isinstance(m, dict) and m.get("role") == "system")]
non_system = [m for m in messages if m not in system_messages]
if len(non_system) > max_messages:
non_system = non_system[-max_messages:]
return system_messages + non_system
One precaution before wiring up this function: never cut a tool_call without its corresponding tool message. If truncation lands between the two, the API receives an unanswered call request and rejects the query. Adjust the cut point to preserve complete pairs rather than counting messages blindly.
Key Takeaways
- Maintain a complete history of messages so the model understands context
- Use a while loop to handle sequential function calls
- The model understands implicit references (“that one”, “the previous one”) thanks to context
- In production, limit history size while keeping tool_call/tool pairs intact
- The
chat()function shown here is a reusable pattern for any function calling application