Skip to content

fix(genai,vertexai): decode JSON-encoded list/dict tool args - #1939

Open
Isaac Hernández (axiom-of-choice) wants to merge 2 commits into
langchain-ai:mainfrom
axiom-of-choice:fix/gemini-json-encoded-tool-args
Open

fix(genai,vertexai): decode JSON-encoded list/dict tool args#1939
Isaac Hernández (axiom-of-choice) wants to merge 2 commits into
langchain-ai:mainfrom
axiom-of-choice:fix/gemini-json-encoded-tool-args

Conversation

@axiom-of-choice

Copy link
Copy Markdown

Fixes #1819

Summary

Gemini routinely returns list- and dict-typed tool arguments as JSON-encoded strings. Both integrations forwarded function_call.args verbatim, so StructuredTool._parse_input handed a str to Pydantic where a list/dict was expected and validation failed before the tool ever ran:

1 validation error for write_todos
todos
  Input should be a valid list [type=list_type, input_value='[{"content": "Search for...', input_type=str]

Every list/dict-typed argument is affected, across every tool, so the fix belongs at the integration boundary rather than in individual args_schema definitions.

Why this shape

The request already carries the declared parameter types (config.tools[].function_declarations[].parameters.properties in genai, request.tools in vertexai). The fix threads those types into the response parser and decodes an argument only when both hold:

  1. the tool declared that argument as ARRAY or OBJECT, and
  2. the decoded value is that container.

That keeps the coercion tight. A str-typed argument holding a JSON payload (write_file(content='{"k": "v"}')) is untouched, because it was never declared as a container. Anything that fails to decode, or decodes to the wrong shape, is passed through unchanged so the tool still raises its usual validation error rather than a confusing new one.

An alternative I rejected: decoding any string that happens to parse as JSON. That needs no schema plumbing, but it silently rewrites string arguments carrying JSON documents, which is a plausible agent workload.

Coercion is applied to the args dict before it is re-serialized into function_call["arguments"], so the streaming and non-streaming paths are both covered by one call site per package.

Changes

libs/genai

  • _function_utils.py: get_container_arg_types() builds {function: {arg: ARRAY|OBJECT}} from the formatted tool declarations (including container types nested under any_of, i.e. list[T] | None); coerce_json_encoded_args() applies the decoding.
  • chat_models.py: _parse_response_candidate() and _response_to_result() take a new keyword-only container_arg_types; _generate/_agenerate/_stream/_astream derive it from the prepared request.

libs/vertexai

  • functions_utils.py: the same two helpers, against gapic.Schema (type_ instead of type).
  • chat_models.py: _parse_response_candidate(), _gemini_response_to_chat_result() and _gemini_chunk_to_generation_chunk() take the same keyword-only argument; _generate_gemini/_agenerate_gemini/_stream_gemini/_astream derive it from request.tools.

_agenerate_gemini now builds its request into a local before the call, matching _generate_gemini, so the tool declarations are reachable after the response comes back. No behavior change.

Tests

12 new unit tests per package, no network:

  • helpers: the type map is built correctly, built-in tools without function declarations are skipped, containers are decoded, and pass-through holds for malformed JSON, truncated JSON, a JSON object where an array was declared, a bare JSON string, and null.
  • wiring, through a mocked client: bind_tools(...).invoke(...) and .stream(...) decode the container arg and leave the str-typed arg alone; with no tools bound, nothing is decoded.

Each wiring test was confirmed to fail when the coercion is disabled.

libs/genai     394 passed
libs/vertexai  313 passed, 1 skipped

make lint (ruff + mypy) passes on both packages.

Risk

Low, and bounded by the schema.

  • Backwards compatible. Every new parameter is keyword-only with a None default. Callers that don't pass it get the current behavior exactly.
  • No effect without tools. With no function declarations in the request the map is empty and args are returned untouched.
  • The one behavior change is that a container-declared argument arriving as a JSON string now becomes a list/dict. Code that worked around the bug by calling json.loads on that value itself will now receive an already-parsed value. That is the bug being fixed, but it is worth calling out for anyone running a monkey-patch like the one in MALFORMED_FUNCTION_CALL seems to be easily triggered by the model (Gemini) due to writing todo deepagents#119.

Areas that want careful review

  • _agenerate_gemini reordering in libs/vertexai/chat_models.py: the request is now built before kwargs.pop("timeout", ...). This matches what _generate_gemini already does, so _prepare_request_gemini was already receiving timeout in **kwargs on the sync path, but it is the only non-additive edit in the diff.
  • any_of handling: _container_type_from_schema returns the first container found among the variants. For list[T] | dict (unusual) it picks whichever the converter emitted first.
  • Nesting: only top-level arguments are decoded. A dict argument that arrives parsed but with a JSON-string value nested inside it is not walked. That was not in the reported failures; happy to extend if you'd prefer it schema-walked.

Pre-merge

  • Integration tests against live Gemini were not run (no credentials on my side). The unit coverage exercises the parse path with the payload shape from the issue.
  • Touches two packages because the issue is labeled for both and the conversion path is duplicated. Happy to split into two PRs if that's preferred.

Post-merge

None. No migration, no config, no deprecation.


Part of the work was done using an AI coding assistant, but tests and quality were manually reviewed.

Gemini routinely returns list- and dict-typed tool arguments as
JSON-encoded strings. Both integrations forwarded `function_call.args`
verbatim, so `StructuredTool._parse_input` handed a `str` to Pydantic
where a `list`/`dict` was expected and validation failed before the tool
ran:

    1 validation error for write_todos - todos: Input should be a valid
    list [type=list_type, input_value='[{"content": "Search for...',
    input_type=str]

Every list/dict-typed argument is affected across every tool, so the fix
belongs at the integration boundary rather than in individual
`args_schema` definitions.

The request already carries the declared parameter types, so pass them
into the response parser and decode an arg only when the tool declared it
as an array or object and the decoded value is that container. Args
declared as strings are untouched even when they hold a JSON payload, and
anything that fails to decode is passed through so the tool still raises
its usual validation error.

Coercion happens on the args dict before it is re-serialized, which
covers both the streaming and non-streaming paths.

Fixes langchain-ai#1819
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gemini list/dict tool arguments arrive as JSON-encoded strings instead of parsed values

1 participant