Skip to content

Sending Function Responses Back in a Multi-Turn Gemini Conversation

9 min read · updated August 11, 2026

Gemini’s API is stateless: every request carries the entire conversation. A tool call therefore spans two requests, and the second one has to contain the model’s own function call as well as your answer to it. Getting that array right is the whole of function calling.

The three turns

Laid out as they exist in contents on the second request:

[
  { "role": "user",  "parts": [{"text": "What's the weather in Oslo right now?"}] },

  { "role": "model", "parts": [{"functionCall": {
      "name": "get_current_weather",
      "args": {"location": "Oslo, Norway", "unit": "celsius"}
  }}] },

  { "role": "user",  "parts": [{"functionResponse": {
      "name": "get_current_weather",
      "response": {"temperature_c": -3, "conditions": "light snow", "wind_kph": 14}
  }}] }
]

Three things in that array are counter-intuitive and are worth stating before the walkthrough.

  • The middle turn is role: “model” — you are replaying the model’s own output back to it. Gemini uses model where other APIs use assistant.
  • The function result is role: “user”, not a special tool role. Structurally it is you supplying information, so the alternation of user and model turns is preserved.
  • There is no id linking the pair. Matching is by name and by position.
Some SDKs and the Vertex AI surface label the function-result turn function or tool instead of user. The Gemini Developer API REST examples use user, and the Google Gen AI SDK constructs it that way. If you are hand-building JSON against a different surface, check its reference.

Building it step by step

  1. Declare the function with an OpenAPI-subset parameter schema. The description fields are not documentation — the model reads them to decide when to call it and what to pass:
    "tools": [{
      "functionDeclarations": [{
        "name": "get_current_weather",
        "description": "Get the current weather for a named city.",
        "parameters": {
          "type": "OBJECT",
          "properties": {
            "location": {"type": "STRING",
                         "description": "City and country, e.g. 'Oslo, Norway'"},
            "unit":     {"type": "STRING", "enum": ["celsius", "fahrenheit"]}
          },
          "required": ["location"]
        }
      }]
    }]
  2. Send the user turn with the tools attached, and inspect the parts of the returned candidate. Test for the presence of a functionCall key, not for finishReason — Gemini returns STOP for a turn that is a function call.
  3. Execute it. Validate args against your own schema first. The model produces arguments that conform to the declared types, but a model-supplied string still reaches your system, and the declaration is not a security boundary.
  4. Append two turns to the history: the model turn you received, verbatim, then a user turn containing the functionResponse. Not one. The model turn is not optional.
  5. Send the whole array again, with the same tools declaration, and the model produces prose using the result — or another function call, in which case you repeat from step three.

The same loop in the Python SDK, with the manual history assembly visible:

from google import genai
from google.genai import types

client = genai.Client()
config = types.GenerateContentConfig(tools=[weather_tool])

history = [types.Content(role="user",
                         parts=[types.Part(text="What's the weather in Oslo right now?")])]

resp = client.models.generate_content(
    model="gemini-2.5-flash", contents=history, config=config,
)

calls = [p.function_call for p in resp.candidates[0].content.parts if p.function_call]
if calls:
    history.append(resp.candidates[0].content)          # the model turn, verbatim
    history.append(types.Content(role="user", parts=[
        types.Part.from_function_response(
            name=c.name, response=execute(c.name, dict(c.args)),
        ) for c in calls                                 # every call, one turn
    ]))
    resp = client.models.generate_content(
        model="gemini-2.5-flash", contents=history, config=config,
    )

print(resp.text)

The list comprehension in the middle handles the fan-out case for free. If the model returned three calls, this sends three responses in one turn, which is the requirement described in parallel function calling.

What goes in the response object

response is a free-form JSON object. There is no schema for it, nothing validates it, and whatever you put there is serialised into the model’s context as-is. Three practical rules follow:

  • It must be an object. A bare string or number is not valid here. Wrap scalars: {"result": 42}, not 42.
  • Send what the model needs and nothing else. The whole object is input tokens on every subsequent turn of the conversation. Returning a full API payload when the model needs three fields is a recurring cost you pay for the rest of the session.
  • Report failures as data. {"error": "city not found", "suggestion": "did you mean Oslo, Norway?"} lets the model recover. Omitting the response entirely, or throwing, leaves a dangling call in the history and the next request will fail.

The second of those is a slow leak rather than a bug, and it is worth measuring once. Because the conversation is resent in full on every request, a verbose tool result is not paid for once — it is paid for on this turn and on every turn after it for the life of the session. Ten tool calls returning 2,000 tokens each leave 20,000 tokens permanently in the prompt. Return the fields the model needs to answer and keep the full payload on your side.

Tool calls in a streamed turn

With streamGenerateContent a function call does not arrive as one object. The parts are delivered across chunks, so you cannot dispatch on the first thing you see — you have to accumulate the candidate’s parts until the stream completes and only then decide whether the turn was prose, a tool call, or both.

This produces a design consequence people meet late. Streaming exists to show the user something immediately, and a turn that turns out to be a function call has nothing to show: the visible output arrives on the next request, after your tool has run. So a streamed agent loop has a silent interval whose length is the latency of your own backend, and the interface needs to say something during it. Text parts that stream alongside a call are the model narrating what it is about to do, and rendering them is the cheapest way to fill that gap.

Whatever you accumulate, keep the reassembled model turn intact for the history. The array you send back on the follow-up request must be the model’s content as delivered, not a reconstruction from the text you happened to render.

Why the history must be complete

A stateless API means the model has no memory of having asked. If you send only the function result without the model turn that requested it, the model sees an unexplained blob of JSON from the user and no reason for it — and will usually either ask what it is or hallucinate a question it might answer.

Preserve the model turn verbatim, including the exact args. Reconstructing it from your own record of what you executed introduces drift: normalise a city name, round a number, drop a field the model set, and the conversation the model sees is not the one it had.

The same applies to tools. Resend the declarations on the follow-up request, along with any toolConfig you set. Dropping them because “the call already happened” leaves the model with function calls in its history and no matching declarations, which is at best confusing and at worst an error.

The errors this produces

  • A 400 complaining about the number of function response parts. The count of functionResponse parts in your turn does not match the count of functionCall parts in the preceding model turn. Almost always a fan-out answered one call at a time.
  • The model repeats the same call. Your functionResponse did not reach it, usually because the model turn was omitted from the history so the call is not there to resolve.
  • The model answers as though the tool returned nothing. The name in your response does not match the name in the call. It is a string comparison and it is case-sensitive.
  • finishReason: MALFORMED_FUNCTION_CALL. The model tried to call a function and produced something unparseable. There is nothing to execute; simplify the parameter schema or retry.