Skip to content

bug(genai): nested object schemas have required overwritten and nullable flag dropped #1725

Description

Please excuse the robot-generated issue text. I've isolated and confirmed the bug, and that the monkey patches fix it.

When converting an MCP-style tool dict (or any JSON Schema with nested object properties) to Gemini's tool format, _function_utils._get_properties_from_schema corrupts nested objects in two distinct ways.

Affected versions: langchain-google-genai==4.2.2 (latest); google-genai==1.73.1.

Bug A — required is overwritten with "every property without a default"

_function_utils.py lines 573–576:

if isinstance(v_properties, dict):
    properties_item["required"] = [
        k for k, v in v_properties.items() if "default" not in v
    ]

For any nested OBJECT property, the converter discards the source schema's own required array and rebuilds it as "every property that lacks an explicit default key". A schema with required: ["name", "role"] and 6 nullable-optional fields ends up with all 8 fields marked required.

No existing issue or PR I could find addresses this.

Bug B — nullable flag dropped on the second pass

The converter is invoked twice during a single tool conversion: once by _format_json_schema_to_gapic on the outer schema, and again when _dict_to_genai_schema recurses into the inner object. The first pass correctly converts anyOf:[T, {"type":"null"}]{type: T, nullable: true}. The second pass calls _is_nullable_schema, which only recognizes anyOf shape and the NULL type — not an explicit nullable: true key. So the flag silently disappears on second pass.

PR #1470 fixes this from the other side (avoids producing the explicit nullable: true shape in the first place); my fix below patches _is_nullable_schema to recognize the shape that already gets emitted. Either approach works.

Minimum reproduction

Self-contained, copy-paste runnable. Requires only pip install langchain-google-genai==4.2.2.

from langchain_google_genai._function_utils import _format_to_genai_function_declaration

tool = {
    "name": "create_contact",
    "description": "Create an administrative contact under a group.",
    "parameters": {
        "type": "object",
        "properties": {
            "contact": {
                "type": "object",
                "properties": {
                    "name":  {"type": "string"},
                    "role":  {"type": "string"},
                    "email": {"anyOf": [{"type": "string"}, {"type": "null"}]},
                    "phone": {"anyOf": [{"type": "string"}, {"type": "null"}]},
                },
                "required": ["name", "role"],
            },
        },
        "required": ["contact"],
    },
}

fn = _format_to_genai_function_declaration(tool)
contact = fn.parameters.properties["contact"]

# Bug A: required is overwritten with all properties (not just the input list)
print("required:", contact.required)
assert contact.required == ["name", "role"], \
    f"Bug A: expected ['name', 'role'], got {contact.required}"

# Bug B: nullable flag is dropped on nested anyOf:[T, null]
print("email.nullable:", contact.properties["email"].nullable)
assert contact.properties["email"].nullable is True, \
    f"Bug B: expected nullable=True, got {contact.properties['email'].nullable}"

Output on 4.2.2:

required: ['name', 'role', 'email', 'phone']
AssertionError: Bug A: expected ['name', 'role'], got ['name', 'role', 'email', 'phone']

(Bug B asserts after Bug A is fixed; the demo shows both fail.)

Proposed fix

Two surgical changes. Running these as monkey patches in production: both bugs disappear, our 9-case regression suite (top-level happy paths + nested + the real create_contact shape) goes 4/9 → 9/9 passing.

Bug A — honor source required when present

In _get_properties_from_schema, replace lines 573–576 with:

if isinstance(v_properties, dict):
    properties_item["required"] = (
        list(v["required"])
        if isinstance(v.get("required"), list)
        else []
    )

(The discarded synthesis branch was a heuristic for "Optional fields have a default set"; in practice both Pydantic's model_json_schema() and raw JSON Schema specify required explicitly, so a missing required should be honored as "no required fields", not "everything that lacks default". My original proposed fix above kept the synthesis as a fallback, but verifying against the real production case — a nested address object whose source had no required — showed the fallback still produces the bug.)

Bug B — detect explicit nullable: true

In _is_nullable_schema, add at the top:

def _is_nullable_schema(schema: dict[str, Any]) -> bool:
    if schema.get("nullable") is True:
        return True
    # ... existing logic ...

(Or merge with PR #1470's approach, which fixes the other end of the same round-trip.)

Happy to send a PR with both fixes plus the repro converted into a regression test if it'd help — let me know.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions