fix(genai,vertexai): decode JSON-encoded list/dict tool args - #1939
Open
Isaac Hernández (axiom-of-choice) wants to merge 2 commits into
Open
fix(genai,vertexai): decode JSON-encoded list/dict tool args#1939Isaac Hernández (axiom-of-choice) wants to merge 2 commits into
Isaac Hernández (axiom-of-choice) wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1819
Summary
Gemini routinely returns list- and dict-typed tool arguments as JSON-encoded strings. Both integrations forwarded
function_call.argsverbatim, soStructuredTool._parse_inputhanded astrto Pydantic where alist/dictwas expected and validation failed before the tool ever ran:Every list/dict-typed argument is affected, across every tool, so the fix belongs at the integration boundary rather than in individual
args_schemadefinitions.Why this shape
The request already carries the declared parameter types (
config.tools[].function_declarations[].parameters.propertiesin genai,request.toolsin vertexai). The fix threads those types into the response parser and decodes an argument only when both hold:ARRAYorOBJECT, andThat 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 underany_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-onlycontainer_arg_types;_generate/_agenerate/_stream/_astreamderive it from the prepared request.libs/vertexaifunctions_utils.py: the same two helpers, againstgapic.Schema(type_instead oftype).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/_astreamderive it fromrequest.tools._agenerate_gemininow 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:
null.bind_tools(...).invoke(...)and.stream(...)decode the container arg and leave thestr-typed arg alone; with no tools bound, nothing is decoded.Each wiring test was confirmed to fail when the coercion is disabled.
make lint(ruff + mypy) passes on both packages.Risk
Low, and bounded by the schema.
Nonedefault. Callers that don't pass it get the current behavior exactly.list/dict. Code that worked around the bug by callingjson.loadson 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 inMALFORMED_FUNCTION_CALLseems to be easily triggered by the model (Gemini) due to writing todo deepagents#119.Areas that want careful review
_agenerate_geminireordering inlibs/vertexai/chat_models.py: the request is now built beforekwargs.pop("timeout", ...). This matches what_generate_geminialready does, so_prepare_request_geminiwas already receivingtimeoutin**kwargson the sync path, but it is the only non-additive edit in the diff.any_ofhandling:_container_type_from_schemareturns the first container found among the variants. Forlist[T] | dict(unusual) it picks whichever the converter emitted first.Pre-merge
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.