Google Vertex AI developer on retainer: Gemini emits TextPart before FunctionCallPart — parts[0] assumed to be the function call — AttributeError in 23% of responses for 4 weeks — google-cloud-aiplatform 1.57.0 — Vertex AI developer on monthly retainer

google-cloud-aiplatform 1.57.0 • Python 3.11 • gemini-1.5-pro on Vertex AI • 3 FunctionDeclarations in tools • Gemini emits TextPart preamble before FunctionCallPart • client reads parts[0] assuming FunctionCallPart • AttributeError: 'Part' object has no attribute 'function_call' • 23% of function-call queries fail for 4 weeks • fix: iterate all parts with hasattr(p, 'function_call') • 2.5h retainer engagement

Clearbrook Cloud Analytics Ltd. built a data analytics assistant on Google Vertex AI in June 2026. The assistant let analysts describe queries in plain English and used Gemini function calling to translate them into SQL, fetch data from a warehouse, and summarize the results. The assistant worked correctly in development, but in staging and production a subset of queries returned the error message “I could not complete this analysis” instead of results. The failure was silent: no exception appeared in the application logs, only in the function calling layer. The team spent four weeks adjusting prompt phrasing before engaging a Vertex AI retainer developer to investigate.

The function calling setup at the time of the engagement:

# analytics_agent.py
# Clearbrook Cloud Analytics Ltd.
# google-cloud-aiplatform 1.57.0, Python 3.11

import vertexai
from vertexai.generative_models import (
    GenerativeModel,
    Tool,
    FunctionDeclaration,
    GenerationConfig,
)
from vertexai.generative_models import Part, Content

vertexai.init(project="clearbrook-analytics", location="us-central1")

query_database_fn = FunctionDeclaration(
    name="query_database",
    description="Execute a SQL query and return results as a list of row dicts.",
    parameters={
        "type": "object",
        "properties": {
            "sql": {"type": "string", "description": "The SQL query to execute."},
        },
        "required": ["sql"],
    },
)

summarize_results_fn = FunctionDeclaration(
    name="summarize_results",
    description="Summarize a query result set in a specified format.",
    parameters={
        "type": "object",
        "properties": {
            "data": {"type": "array", "items": {"type": "object"}},
            "format": {"type": "string", "enum": ["table", "bullets", "paragraph"]},
        },
        "required": ["data", "format"],
    },
)

get_schema_fn = FunctionDeclaration(
    name="get_schema",
    description="Return the column names and types for a database table.",
    parameters={
        "type": "object",
        "properties": {
            "table_name": {"type": "string"},
        },
        "required": ["table_name"],
    },
)

TOOLS = [Tool(function_declarations=[query_database_fn, summarize_results_fn, get_schema_fn])]

model = GenerativeModel("gemini-1.5-pro", tools=TOOLS)

def run_analytics_query(user_request: str) -> str:
    contents = [Content(role="user", parts=[Part.from_text(user_request)])]
    response = model.generate_content(contents)

    # ← PROBLEM: parts[0] is NOT guaranteed to be the FunctionCallPart.
    # When Gemini decides to emit a text preamble before calling a function
    # ("Sure, let me write a query for that."), parts[0] is a TextPart
    # and parts[1] is the FunctionCallPart.
    # Reading parts[0].function_call raises AttributeError on TextPart.
    func_call = response.candidates[0].content.parts[0]  # ← may be TextPart

    try:
        fn_name = func_call.function_call.name          # ← AttributeError if TextPart
        fn_args = dict(func_call.function_call.args)

        if fn_name == "query_database":
            result = db.execute_sql(fn_args["sql"])
            return summarize(result)
        elif fn_name == "get_schema":
            return str(db.get_schema(fn_args["table_name"]))
        else:
            return str(fn_args)

    except AttributeError:
        return "I could not complete this analysis."

Why Gemini emits a TextPart before the FunctionCallPart

Gemini 1.5 Pro is a general-purpose generative model trained to be helpful and conversational. When function calling is enabled, it can respond in one of several ways: a pure function call (only a FunctionCallPart), a mixed response with a text preamble followed by a function call, or a pure text response (if it determines a function call is not appropriate). Which pattern appears depends on the query, the system prompt, and the model’s probabilistic generation.

For direct, unambiguous queries (“get the schema for the orders table”), Gemini typically emits a pure FunctionCallPart at parts[0]. For queries that benefit from a brief acknowledgment (“Let me check the schema and then build the query”) or for queries where the model wants to explain its approach, it emits a TextPart first. The TextPart object has no function_call attribute. Reading parts[0].function_call on a TextPart raises AttributeError: 'Part' object has no attribute 'function_call'.

In the Clearbrook production system, 23% of function-call queries triggered the text preamble path. Analytical queries about multi-table joins, year-over-year comparisons, and queries where the model wanted to explain the query logic before executing it were the most frequent triggers. Development testing had used short, imperative queries that hit the pure function-call path exclusively, so the bug did not appear in QA.

# Demonstrating TextPart before FunctionCallPart in gemini-1.5-pro
# google-cloud-aiplatform 1.57.0, Python 3.11

import vertexai
from vertexai.generative_models import GenerativeModel, Tool, FunctionDeclaration, Part, Content

vertexai.init(project="clearbrook-analytics", location="us-central1")

# A query that tends to trigger a preamble
analytical_query = (
    "Compare the year-over-year revenue change for each product category "
    "for 2025 vs 2024, and flag any categories that declined more than 15%."
)

response = model.generate_content(
    [Content(role="user", parts=[Part.from_text(analytical_query)])]
)

parts = response.candidates[0].content.parts
print(f"Number of parts: {len(parts)}")  # → 2

for i, part in enumerate(parts):
    part_type = "FunctionCallPart" if hasattr(part, "function_call") and part.function_call else "TextPart"
    print(f"parts[{i}]: {part_type}")
    if part_type == "TextPart":
        print(f"  text: {part.text[:80]}...")
    else:
        print(f"  function_call.name: {part.function_call.name}")

# Output:
# Number of parts: 2
# parts[0]: TextPart
#   text: Sure, let me write a query that compares revenue by category across both years...
# parts[1]: FunctionCallPart
#   function_call.name: query_database

# Reading parts[0].function_call → AttributeError: 'Part' object has no attribute 'function_call'
# (TextPart has .text but not .function_call)

The fix: iterate all parts and find the FunctionCallPart

# analytics_agent.py — FIXED
# Clearbrook Cloud Analytics Ltd.
# google-cloud-aiplatform 1.57.0, Python 3.11

import vertexai
from vertexai.generative_models import GenerativeModel, Tool, FunctionDeclaration, Part, Content

vertexai.init(project="clearbrook-analytics", location="us-central1")

# ... (model and TOOLS setup unchanged) ...

def run_analytics_query(user_request: str) -> str:
    contents = [Content(role="user", parts=[Part.from_text(user_request)])]
    response = model.generate_content(contents)

    parts = response.candidates[0].content.parts

    # ← FIX: iterate all parts to find FunctionCallPart.
    # Do NOT assume parts[0] is the function call.
    # Gemini may emit TextPart preambles before the FunctionCallPart.
    # Use hasattr to safely identify FunctionCallPart objects.
    func_call_part = next(
        (p for p in parts if hasattr(p, "function_call") and p.function_call),
        None,
    )

    if func_call_part is None:
        # No function call in this response — Gemini returned a plain text answer.
        text_parts = [p.text for p in parts if hasattr(p, "text") and p.text]
        return " ".join(text_parts) if text_parts else "No result."

    fn_name = func_call_part.function_call.name
    fn_args = dict(func_call_part.function_call.args)

    if fn_name == "query_database":
        result = db.execute_sql(fn_args["sql"])
        return summarize(result)
    elif fn_name == "get_schema":
        return str(db.get_schema(fn_args["table_name"]))
    elif fn_name == "summarize_results":
        return summarize_with_format(fn_args["data"], fn_args.get("format", "paragraph"))
    else:
        return f"Unknown function: {fn_name}"

def run_analytics_query_parallel(user_request: str) -> str:
    """
    Handle responses with multiple parallel FunctionCallPart objects.
    Gemini 1.5 Pro can emit multiple function calls in a single response
    when the query requires parallel tool use.
    """
    contents = [Content(role="user", parts=[Part.from_text(user_request)])]
    response = model.generate_content(contents)

    parts = response.candidates[0].content.parts

    # Collect ALL function call parts (handles parallel function calls)
    func_call_parts = [
        p for p in parts
        if hasattr(p, "function_call") and p.function_call
    ]

    if not func_call_parts:
        text_parts = [p.text for p in parts if hasattr(p, "text") and p.text]
        return " ".join(text_parts) if text_parts else "No result."

    # Dispatch all function calls and collect results
    function_responses = []
    for fcp in func_call_parts:
        fn_name = fcp.function_call.name
        fn_args = dict(fcp.function_call.args)
        result = dispatch_function(fn_name, fn_args)
        function_responses.append(
            Part.from_function_response(name=fn_name, response={"result": result})
        )

    # Send all function results back in one turn
    contents.append(Content(role="model", parts=parts))
    contents.append(Content(role="user", parts=function_responses))

    final_response = model.generate_content(contents)
    return final_response.candidates[0].content.parts[0].text

Adding a CI guard against parts[0] index access

# test_function_calling.py — CI guard
# Clearbrook Cloud Analytics Ltd.
# google-cloud-aiplatform 1.57.0, Python 3.11

import pytest
import vertexai
from vertexai.generative_models import GenerativeModel, Part, Content

vertexai.init(project="clearbrook-analytics-test", location="us-central1")

CONVERSATIONAL_QUERIES = [
    "Can you please analyze the year-over-year change in revenue by category?",
    "I'd like to understand how many orders were placed per region last quarter.",
    "Could you look up the schema for the transactions table and explain the fields?",
    "Please compare Q3 2025 to Q3 2024 for the top 5 product lines.",
]

@pytest.mark.parametrize("query", CONVERSATIONAL_QUERIES)
def test_function_call_extracted_from_preamble_response(query):
    """
    Verify that function calls are correctly extracted even when Gemini
    emits a TextPart preamble before the FunctionCallPart.
    """
    response = model.generate_content(
        [Content(role="user", parts=[Part.from_text(query)])]
    )
    parts = response.candidates[0].content.parts

    # Correct extraction: iterate all parts
    func_call_part = next(
        (p for p in parts if hasattr(p, "function_call") and p.function_call),
        None,
    )

    # If Gemini decided to make a function call, it must be extractable
    if any(hasattr(p, "function_call") and p.function_call for p in parts):
        assert func_call_part is not None, (
            f"Function call present in response but not found by iteration. "
            f"Parts: {[type(p).__name__ for p in parts]}"
        )
        assert func_call_part.function_call.name in {
            "query_database", "summarize_results", "get_schema"
        }
        args = dict(func_call_part.function_call.args)
        assert isinstance(args, dict)

Validation

# Validation benchmark (October 29 2026)
# 50 analytics queries from October 28 production sessions
# google-cloud-aiplatform 1.57.0, Python 3.11, gemini-1.5-pro

# Before fix (parts[0] index access):
# Queries with TextPart preamble at parts[0]:  12 / 50 (24%)
# AttributeError on parts[0].function_call:    12 / 50 (24%)
# Fallback to error message:                   12 / 50 (24%)
# Function calls dispatched successfully:      38 / 50 (76%)

# After fix (iterate all parts with hasattr guard):
# Queries with TextPart preamble at parts[0]:  11 / 50 (22%)  ← similar rate
# AttributeError:                               0 / 50 (0%)
# Function calls dispatched successfully:      50 / 50 (100%)
# Parallel function calls handled:              4 / 50 (8%)   ← also now handled

# 30-day production projection (Clearbrook Cloud Analytics Ltd.):
# Estimated function-call queries/day: 340
# Previously failing (23% rate): ~78 queries/day returned error message
# Post-fix: 0 queries return error message from AttributeError
# 2.5h retainer engagement.

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →