Skip to content

fix(llminternal): build arrays when assembling streamed function call arguments - #1630

Open
ashrodan wants to merge 1 commit into
google:mainfrom
dashlytix:fix/stream-aggregator-array-json-path
Open

ashrodan wants to merge 1 commit into
google:mainfrom
dashlytix:fix/stream-aggregator-array-json-path

Conversation

@ashrodan

Copy link
Copy Markdown

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

Problem:

With StreamFunctionCallArguments, Vertex streams each argument leaf as a PartialArg addressed by an RFC 9535 JSON path ($.tables[0], $.data[0].label). streamingResponseAggregator split the path on . and wrote the last segment as a map key. So $.tables[0] became the literal key "tables[0]", and a string streamed over several chunks inside an array restarted from empty each time. Any tool with an array parameter gets arguments that fail its own schema. On Vertex (gemini-3.7-flash) the model retries five times and the turn ends with MALFORMED_FUNCTION_CALL and no answer.

Solution:

Only internal/llminternal/stream_aggregator.go changes, with no new dependencies and no regexp:

  • parseJSONPath splits a path into segments. It accepts exactly the selectors normalized paths use (RFC 9535 §2.7): .name, ['name'], ["name"], [index]. Anything else is reported as unparseable and the chunk is dropped: wildcard, slice, descendant (..), negative index, Vertex's value-less $.tables[] terminator, or a path the segments don't fully cover. A path the aggregator can't read never becomes a key the tool will act on.
  • setValueByPath creates maps for names and grows []any for indices, so an element that arrives out of order keeps its position and any gap stays null. Indices are bounded (maxPathIndex).
  • getValueFromPartialArg finds the string a chunk continues through the same traversal, so string continuation works inside arrays.

Behavior change

What behaves differently for someone already on the current release?

Only function calls whose arguments arrive through PartialArgs, i.e. with StreamFunctionCallArguments enabled on a streaming Vertex call. Array arguments now assemble as JSON arrays and bracket-quoted member names as their real names, where before they became literal "name[0]" / "a['b" keys. A partial argument whose path is outside the supported grammar is now dropped instead of written under a mangled key. Nothing changes for non-streamed calls, for streamed calls whose arguments arrive whole in Args, or for any path without brackets.

Testing Plan

Unit Tests:

  • All unit tests pass locally: go test -race -mod=readonly -count=1 -shuffle=on ./internal/llminternal/ gives ok. go mod tidy -diff, go build -mod=readonly ./... and golangci-lint run (v2.3.1) give 0 issues.

With your source change reverted and your tests kept, which test fails?

All six new tests in internal/llminternal/stream_aggregator_test.go. They drive the public aggregator with the chunk shapes Vertex sends and compare the assembled args as JSON:

--- FAIL: TestStreamedArrayOfObjectsArgument
     got {"data[0]":{"label":"Jan 2023","value":1250.5},"data[1]":{"label":"Feb 2023","value":990},"title":"Revenue"}
    want {"data":[{"label":"Jan 2023","value":1250.5},{"label":"Feb 2023","value":990}],"title":"Revenue"}
--- FAIL: TestStreamedArrayOfStringsArgument
     got {"tables[0]":"orders","tables[1]":"customers"}
    want {"tables":["orders","customers"]}
--- FAIL: TestStreamedArrayArgumentKeepsIndexPositions
     got {"queries[0]":"first","queries[2]":"third"}
    want {"queries":["first",null,"third"]}
--- FAIL: TestStreamedArrayArgumentScalarTypes
     got {"filters[0]":{"enabled":true,"value":null},"filters[1]":{"enabled":false},"limit":10,"options":{"stacked":true}}
    want {"filters":[{"enabled":true,"value":null},{"enabled":false}],"limit":10,"options":{"stacked":true}}
--- FAIL: TestStreamedArgumentQuotedMemberNames
     got {"$['it\\'s']":"y","$['series":{"name'][0][\"data point\"]":"x"}}
    want {"it's":"y","series.name":[{"data point":"x"}]}
--- FAIL: TestStreamedArgumentUnsupportedPathsDropped
     got {"":{"data":"descendant"},"data[*]":"wildcard","data[-1]":"negative","data[0:2]":"slice","data[]":"end of array","title":"kept"}
    want {"title":"kept"}

Manual End-to-End (E2E) Tests:

Runner with StreamingModeSSE, Vertex global endpoint, gemini-3.7-flash, and StreamFunctionCallArguments = true set on streamed calls only (from a production agent; tool names as declared there). Its tools take arrays: describe_tables(tables: string[]), query_gold_table(queries: object[]), and present_ui, whose chart presentation carries props.data: object[]. The prompt asked for a bar chart of revenue by month.

Chunks as received:

{"id":"vMwRX2nx","name":"describe_tables","willContinue":true}
{"partialArgs":[{"jsonPath":"$.tables[0]","stringValue":"sales_vs_budget_water","willContinue":true}],"willContinue":true}
{"partialArgs":[{"jsonPath":"$.tables[0]","stringValue":"co","willContinue":true}],"willContinue":true}
{"partialArgs":[{"jsonPath":"$.tables[0]"}],"willContinue":true}
{"partialArgs":[{"jsonPath":"$.tables[1]","stringValue":"sales_vs_budget_davey","willContinue":true}],"willContinue":true}
{"partialArgs":[{"jsonPath":"$.tables[1]"}],"willContinue":true}
{"partialArgs":[{"jsonPath":"$.tables[]"}]}

Before (v2.2.0):

FunctionCall describe_tables args={"tables[0]":"sales_vs_budget_waterco","tables[1]":"sales_vs_budget_davey"}
FunctionResponse error: validating root: unexpected additional properties ["tables[0]" "tables[1]"]
... 5 retries ...
finishReason: MALFORMED_FUNCTION_CALL (no answer)

After:

FunctionCall describe_tables args={"tables":["sales_vs_budget_waterco","sales_vs_budget_davey"]}
FunctionCall query_gold_table args={"queries":[{…},{…},{…},{…}]}
FunctionCall present_ui args={"presentation":"core.chart","props":{"data":[{"month":"Apr 2026","total_revenue":11280907.64},…6 items]},…}
finishReason: STOP, 0 error events

For every call, the first named chunk arrived 0.5 to 5.0 s before the aggregated call. A recursive walk of every aggregated argument in the run found no key containing [ or ].

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

adk-python's StreamingResponseAggregator already parses these paths into typed components (_parse_json_path in src/google/adk/utils/streaming_utils.py); this brings the Go aggregator in line.

… arguments

The JSON Path that addresses a streamed argument is RFC 9535, so an array
element arrives as "$.tables[0]" or "$.data[0].label". setValueByJSONPath
split that path on "." alone and wrote the last segment as a map key, so
"$.tables[0]" became the key "tables[0]" and "$.data[0].label" became
{"data[0]": {"label": ...}}. getValueFromPartialArg walked the same split
path to find the string it was appending a chunk to, so a string streamed
in several chunks inside an array restarted at every chunk.

Any tool whose parameters include an array is therefore uncallable with
StreamFunctionCallArguments enabled: the assembled arguments fail the
tool's own schema, the model retries, and the turn ends in
MALFORMED_FUNCTION_CALL.

Parse the path into segments instead — ".name", "['name']" and "[index]",
the selectors a normalized path is built from — and use the same traversal
to write a value and to find the string a chunk continues. A path outside
that grammar is dropped rather than written to an invented key, which also
covers the value-less "$.tables[]" chunk Vertex closes an array with.
@google-cla

google-cla Bot commented Sep 23, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@ashrodan

ashrodan commented Sep 23, 2026

Copy link
Copy Markdown
Author

@googlebot I signed it! @google-cla

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.

internal/llminternal: streamed function-call args misparse RFC 9535 array and quoted JSON paths

1 participant