fix(gemini): enable built-in tools with function declarations - #3646
Solaris-star wants to merge 5 commits into
Conversation
|
@strandly-the-agent review this PR against the original issue |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Changes requested — the core fix is right for the common case, and the PR is well shaped: narrow, Vertex-aware, tests updated, and honestly scoped as Related: rather than Fixes so #3639 stays open for the TS half. Two problems underneath it, though: it cannot work at the dependency floor the package declares, and the dict-merge branch turns a currently-working input into a crash.
🔴 Blocker 1 — the floor still predates the field. strands-py/pyproject.toml:50 declares google-genai>=1.67.0, but include_server_side_tool_invocations only lands in 1.68.0. pyproject.toml isn't in the diff so I can't anchor this inline. On a clean google-genai==1.67.0 venv, the three new branches fail three different ways — the model_copy one is the nasty one, because it silently drops the flag and the 400 comes back invisibly:
field declared: False
gemini.py:363 (construct) -> ValidationError: Extra inputs are not permitted [extra_forbidden]
gemini.py:366 (model_copy) -> no raise; to_json_dict() = {'function_calling_config': {'mode': 'AUTO'}} # flag GONE
gemini.py:369 (dict) -> ValidationError: tool_config.include_server_side_tool_invocations
_format_request is called at gemini.py:624, outside the try: at :635, so that ValidationError is unhandled — where the original bug at least surfaced as a caught ClientError. CI can't catch this (no lockfile, resolves latest 2.x). Fix is one line: gemini = ["google-genai>=1.68.0,<3.0.0"].
🔴 Blocker 2 (camelCase regression) and 3 × 🟡 are inline. Nothing here is a design objection — the approach and the scope are both right.
What I actually verified
| ✅ | Branch pr3646 @ 5f628d52d; pytest tests/strands/models/test_gemini.py → 70 passed (your claim confirmed) |
| ✅ | Floor failure reproduced on a clean google-genai==1.67.0 venv (output above) |
| ✅ | camelCase regression reproduced before/after through the real stream() path |
| ✅ | Vertex guard is untested: deleting and not is_vertex from :358 → 70 passed |
| ✅ | Both suggestions below actually tested — the refactor keeps 70 green, the test fix fails on the mutant and passes on the fix |
| 🔴 | No Gemini credentials — everything is at the assembled-request level. The premise that this flag is what the 400 gates on is still unconfirmed (see Questions) |
Questions
❓ Blocking-ish: has anyone confirmed this against live Gemini? I raised this on #3639 and it's still open: the vendor docstring for include_server_side_tool_invocations describes it as controlling visibility of server-side tool calls in the response, not as a permission gate, and the exact 400 string isn't in the vendor docs. tests_integ/models/test_model_gemini.py is untouched, and its only built-in-tool test (test_agent_with_gemini_code_execution_tool) passes no tools= — so no integ test combines built-in + function tools, and merging this produces zero evidence the 400 is gone. opieter-aws — you observed the live 400 on 2026-08-04; could you confirm the fixed request clears it? If it doesn't, this fix is a no-op and the diagnosis needs revisiting.
❓ Non-blocking: is the built-in-tools-only case known-safe? With gemini_tools set and no tool_specs, the request still ships Tool(function_declarations=[]) next to the built-in tool and the gate skips it. Unchanged from main, so not a regression — but if the API keys off the presence of the declarations array rather than its contents, that path still 400s. The same live check would settle it.
❓ Non-blocking: worth filing a tracking issue for the TS half? builtInTools (strands-ts/src/models/google/types.ts:43, consumed at model.ts:337-341) still has the bug and there's no issue tracking it separately yet.
Appendix — non-blocking (6)
- ⚪ The new logic is largely unpinned. I mutated the fix six ways; five mutants survived the full 70-test suite: dropping
and not is_vertex;tool_specs→tool_specs is not None; deleting theelif isinstance(..., dict)branch; deleting theis Noneguard at:365(always-overwrite); and dropping theis_vertex=is_vertexpassthrough in_format_request. Only=True→=Falsewas killed (bytest_gemini.py:1035). Worth adding cases for the dict-shapedparams["tool_config"]and for an explicit caller-setFalse. - ⚪
tool_specs=[]vsNone.ToolRegistry.get_all_tool_specs()returns[], neverNone, when nothing is registered — that's what a liveAgentwith onlygemini_toolssends. Behaviour is identical today, which is exactly why thetool_specs is not Nonemutant survives silently. Atool_specs=[]case would close it. - ⚪
structured_outputdoesn't passis_vertex(gemini.py:720) → defaultsFalseeven on a Vertex client. Unreachable today (tool_specs=Noneshort-circuits the gate first), so latent only. Side note: the issue's claim that fixing_format_request_configautomatically coversstructured_outputisn't quite true — harmlessly, in this case. - ⚪ Nits. (1)
gemini.py:354rewords a pre-existing comment ("…than a ToolChoice can" → "…than a ToolChoice") — unrelated diff noise, worth reverting to keep the diff scoped. (2) None of the three tests reference #3639, which the rootAGENTS.mdevergreen-comment rule asks of a regression test. (3) The two new tests use partial dict asserts rather than the whole-requesttru_/exp_style of their neighbour at:1035— plausibly why the vacuous assertion slipped through, since a whole-request assert would have forced writing out the (correctly absent)tool_config. - ⚪ Not scope creep: hoisting
self._get_client()intogemini_clientat:623is necessary — the fix needs the client for both.vertexaiand.aio. Listed only so it isn't mistaken for drift. - Pre-existing, not this PR's problem:
update_config(gemini_tools=None)→TypeError: 'NoneType' object is not iterable(gemini.py:113via:739), identical onmain. Happy to file it separately if useful — say the word rather than fixing it here.
How to reproduce any of the above
git fetch origin pull/3646/head:pr3646 && git checkout pr3646
cd strands-py && python -m pytest tests/strands/models/test_gemini.py -q # 70 passed
# floor failure
python -m venv /tmp/v167 && /tmp/v167/bin/pip install 'google-genai==1.67.0'
/tmp/v167/bin/python -c "from google import genai; genai.types.ToolConfig(include_server_side_tool_invocations=True)"
# Vertex guard is untested
sed -i 's/ and not is_vertex:/:/' src/strands/models/gemini.py
python -m pytest tests/strands/models/test_gemini.py -q # still 70 passedSolid work overall — the diagnosis, the Vertex carve-out, and the decision to split TS out are all correct. Worth a maintainer's eyes on the live-confirmation question before this merges; I'm an AI reviewer, so treat all of the above as findings to check rather than verdicts.
Signed-off-by: Solaris-star <820622658@qq.com>
|
Addressed the review in
Verification:
I have not claimed live Gemini validation: the local tests exercise the assembled request and do not use provider credentials. The remaining live confirmation should come from the original reproducer ( |
|
Addressed the review follow-up and pushed the updated branch:
Verification: |
|
Addressed the review findings and pushed commit 27c405c.
Verification: ruff format/check, mypy src/strands/models/gemini.py, and Gemini tests — 72 passed. @opieter-aws, could you confirm the assembled request clears the live Gemini 400 from #3639 when you have a chance? I don't have Gemini credentials in this environment, so the remaining live confirmation is outside the local test suite. |
|
@strandly-the-agent do a fresh review based on the fixes added |
strandly-the-agent
left a comment
There was a problem hiding this comment.
4 of my 5 findings are fixed, and the tests went from ornamental to genuinely load-bearing — but the last commit silently reverted one of the fixes. Everything below is re-verified at 27c405c7, not read off the commit messages.
| # | Previous finding | Status at 27c405c7 |
|---|---|---|
| 1 | 🔴 google-genai floor predates the field |
✅ fixed — pyproject.toml:50 → >=1.68.0. Verified at exactly 1.68.0: field declared, and model_validate accepts the camelCase alias |
| 2 | 🔴 camelCase dict → ValidationError regression |
✅ fixed — ToolConfig.model_validate at gemini.py:366; output matches origin/main again for every shape I tried |
| 3 | 🟡 Vertex assertion was vacuous | ✅ fixed — nested assertion, and it now has teeth (kills the mutant it used to sleep through) |
| 4 | 🟡 explicit params={"tool_config": None} overridden |
✅ fixed — "tool_config" not in config_params at :363, pinned by extending the pre-existing precedence test rather than adding a near-duplicate. Nice — that's the economical version |
| 5 | 🟡 is True Vertex gate fails open |
🔁 fixed in 018d7528, then reverted in 27c405c7 — inline below |
The headline improvement is the tests. Last time 5 of 6 mutants survived the suite. I re-ran a 7-mutant battery against the new tests and all 7 die — including the Vertex guard and the tool_specs=[] case, both of which were completely unprotected before:
M1 drop `and not is_vertex` -> 1 failed (was: 70 passed / SURVIVED)
M2 tool_specs -> is not None -> 1 failed (was: SURVIVED)
M3 flag True -> False -> 1 failed
M4 drop the `is None` guard -> 1 failed (was: SURVIVED)
M5 drop is_vertex= passthrough -> 1 failed (was: SURVIVED)
M6 `not in` -> `is None` -> 1 failed (pins the explicit-None opt-out)
M7 drop model_validate() -> 1 failed (pins the camelCase fix)
One note on process, not code: thread #5 is resolved and its reply says production code "uses bool(getattr(...))", and the 27c405c comment repeats it — but 27c405c7 is the commit that reverted it. The fixture half survived; the production half didn't. Given you pushed twice five minutes apart with near-identical messages, this reads like a rebase casualty rather than a decision — but it means anyone reading the resolved threads would merge believing it's handled.
Evidence ledger — what I actually ran
| ✅ | pytest tests/strands/models/test_gemini.py -q at 27c405c7 → 72 passed (your claim confirmed) |
| ✅ | 7-mutant battery, all killed (table above); baseline 72 |
| ✅ | ruff format --check → 2 files already formatted; ruff check → All checks passed |
| ✅ | Floor bump is exactly right: clean google-genai==1.68.0 venv → field declared, ToolConfig.model_validate({"includeServerSideToolInvocations": False}) → {'include_server_side_tool_invocations': False} |
| ✅ | 17-case before/after behaviour matrix vs origin/main through the real stream() path (appendix) |
| ✅ | Re-applied bool() on top of 27c405c7 → 72 passed, so the revert has no test justification |
| ✅ | client_args={"vertexai": 1} reaches genai.Client(**client_args) at gemini.py:142; real client reports .vertexai == 1 (int, unvalidated) |
Could not run mypy — mypy 2.3.0 in my throwaway venv hits an INTERNAL ERROR unrelated to your code. Your mypy src/strands/models/gemini.py claim is unverified by me, not contradicted |
|
| 🔴 | Still no Gemini credentials — everything remains at the assembled-request level |
Review shape: this was a self-run follow-up, not the full fan-out pipeline — the delta only touches findings I filed, so I verified each fix against the diff and attacked the fixes themselves rather than re-reviewing untouched code.
Behaviour matrix — `origin/main` vs `27c405c7` (17 cases, real stream() path)
params["tool_config"] / config origin/main 27c405c7
---------------------------------------------------------------------------------------------------------
no params <ABSENT> {flag: True} <- the fix
explicit None <ABSENT> <ABSENT> ✅ opt-out honoured
camel False {flag: False} {flag: False} ✅ regression gone
camel True {flag: True} {flag: True} ✅ regression gone
snake False {flag: False} {flag: False} ✅
dict nested camelCase fcc {fcc: AUTO} {fcc: AUTO, flag: True} ✅ merged
ToolConfig obj + fcc {fcc: AUTO} {fcc: AUTO, flag: True} ✅ merged
tool_choice any {fcc: ANY} {fcc: ANY, flag: True} ✅ choice preserved
tool_choice + explicit None <ABSENT> <ABSENT> ✅ params wins
tool_specs=[] <ABSENT> <ABSENT> ✅
tool_specs=None <ABSENT> <ABSENT> ✅
BOTH casings supplied ValidationError ValidationError = (caller error)
garbage str ValidationError ValidationError = (caller error)
bogus key ValidationError ValidationError = (caller error)
vertexai=True <ABSENT> <ABSENT> ✅
vertexai=1 <ABSENT> {flag: True} 🟡 finding #5
vertexai='true' <ABSENT> {flag: True} 🟡 finding #5
Invalid input now raises from ToolConfig rather than GenerateContentConfig — same failure, one step earlier, arguably a clearer message. Not a regression.
Surfaces I attacked and cleared: no caller-state contamination (the caller's params dict and their ToolConfig object are both byte-identical after two stream() calls, and the flag does not leak onto the caller's object); function_calling_config and retrieval_config both survive the merge; tool_choice semantics survive the stamp; the setdefault precedence from #3551 is intact.
Still open (not code — no action needed from you)
❓ Live confirmation. Still the one thing standing between this and "known fixed", and you've flagged it honestly three times — credit for not overclaiming it. opieter-aws observed the original 400, so a single live run with built-in + function tools would settle both that and whether an empty function_declarations array also trips it. If the flag turns out not to be what the API gates on, this is a no-op and #3639's diagnosis needs revisiting — nothing in the diff can tell us either way.
❓ TS half of #3639 still unfixed with no tracking issue (strands-ts/src/models/google/types.ts:43). Worth filing so it isn't lost when this merges.
FYI the Codecov comment on this PR is stale — it's from the 5f628d52d push, before the tests that kill M4/M6/M7 existed.
None of my earlier nits need revisiting: the comment reword is reverted, the #3639 reference is in, and the mutation results show the coverage is substantive regardless of assertion style. Fix the one-line revert and, from where I'm sitting, this is done — as ever, a human should own the merge call.
Signed-off-by: Solaris-star <820622658@qq.com>
|
Addressed the remaining finding from the follow-up review and pushed commit The production path now uses Verification on the pushed revision:
Limitation unchanged: I do not have Gemini credentials here, so live confirmation of the built-in-plus-function-tools request remains for the original reproducer or a maintainer integration environment. The integration workflow is still pending on GitHub. |
|
@strandly-the-agent review latest revision |
strandly-the-agent
left a comment
There was a problem hiding this comment.
All five of my findings are now fixed and verified at af0f64ac. No blockers from me. The delta since my last review is exactly the one-line re-apply — but because 27c405c7 silently reverted a fix last time, I re-checked all five rather than just this one.
| # | Finding | At af0f64ac |
|---|---|---|
| 1 | 🔴 google-genai floor predates the field |
✅ pyproject.toml:50 → >=1.68.0 |
| 2 | 🔴 camelCase dict → ValidationError regression |
✅ model_validate at gemini.py:366 |
| 3 | 🟡 vacuous Vertex assertion | ✅ nested assert at test_gemini.py:1076 |
| 4 | 🟡 explicit tool_config=None overridden |
✅ "tool_config" not in config_params at :363 |
| 5 | 🟡 is True Vertex gate fails open |
✅ bool(getattr(...)) at :625 — zero is True occurrences left |
The fail-open is genuinely closed: vertexai=1 and vertexai="true" now emit no flag, where at 27c405c7 both emitted {'include_server_side_tool_invocations': True}.
One non-blocking nudge. Of my 8 mutants, 7 die — but reverting bool() back to is True still passes all 72 tests. That's the one line in this PR that has already been reverted once, silently, and nothing would catch it a third time. A four-line test closes it (verified: passes at af0f64ac, fails against both the is True revert and the removed-guard mutant):
@pytest.mark.asyncio
async def test_stream_request_with_gemini_tools_treats_truthy_vertexai_as_vertex(
gemini_client, messages, tool_spec, model_id
):
"""Guards #3639: `Client.vertexai` holds the raw constructor arg, so any truthy value means Vertex."""
gemini_client.vertexai = 1
code_execution_tool = genai.types.Tool(code_execution=genai.types.ToolCodeExecution())
model = GeminiModel(model_id=model_id, client=gemini_client, gemini_tools=[code_execution_tool])
await anext(model.stream(messages, tool_specs=[tool_spec]))
request = gemini_client.aio.models.generate_content_stream.call_args.kwargs
assert "include_server_side_tool_invocations" not in (request["config"].get("tool_config") or {})Entirely your call — the production behaviour is correct either way.
Verification ledger + 21-case behaviour matrix
| ✅ | pytest tests/strands/models/test_gemini.py -q → 72 passed |
| ✅ | ruff format --check → 2 files already formatted; ruff check → All checks passed |
| ✅ | Mutation battery M1–M7 all killed; M8 (bool()→is True) SURVIVES — the nudge above |
| ✅ | Proposed test: passes at HEAD, fails on M8 and on M1 (module load confirmed from the mutant tree) |
| ✅ | 21-case matrix vs origin/main through the real stream() path |
| ✅ | No caller-state contamination across 3 stream() calls — caller's params dict and ToolConfig object byte-identical, no flag leak |
| ✅ | CI at this SHA: Python / Lint SUCCESS, Dependency Review SUCCESS, validate-pr-title SUCCESS; unit-test matrix still running at time of writing |
| mypy still not run by me — v2.3.0 in my throwaway venv hits an unrelated INTERNAL ERROR. Your claim is unverified by me, not contradicted | |
| 🔴 | Still no Gemini credentials — all of the above is assembled-request level only |
case origin/main af0f64ac
-------------------------------------------------------------------------------------------
no params <ABSENT> {flag: True} <- the fix
explicit None <ABSENT> <ABSENT> ✅
camel False / True {flag: False} / {flag: True} same ✅
snake False {flag: False} {flag: False} ✅
dict nested camelCase fcc {fcc: AUTO} {fcc: AUTO, flag: True} ✅
ToolConfig obj + fcc {fcc: AUTO} {fcc: AUTO, flag: True} ✅
ToolConfig + retrieval {retrieval: en} {retrieval: en, flag: True} ✅ preserved
tool_choice any {fcc: ANY} {fcc: ANY, flag: True} ✅
tool_choice + expl None <ABSENT> <ABSENT> ✅ params wins
tool_specs=[] / None <ABSENT> <ABSENT> ✅
BOTH casings / str / bogus ValidationError ValidationError = caller error
vertexai=True <ABSENT> <ABSENT> ✅
vertexai=1 <ABSENT> <ABSENT> ✅ fail-open closed
vertexai='true' <ABSENT> <ABSENT> ✅ fail-open closed
vertexai=0 / '' / None <ABSENT> {flag: True} ✅ correct — falsy = not Vertex
That last row differs from main on purpose: a falsy vertexai means the Gemini Developer API, which is exactly where the flag belongs. main never set it at all — that was the bug.
The only thing still outstanding is the one I can't settle from here: nobody has confirmed against live Gemini that this flag is what the 400 gates on. You've flagged that honestly at every step and pinged opieter-aws for it, which is the right call — one live run with built-in + function tools would also answer whether an empty function_declarations array trips the same error. A human should own the merge on that basis, not my green ticks.
|
@Solaris-star can you please verify your fix E2E? |
|
Thanks for the E2E request. I verified the PR head through the repository’s integration workflow, not only mocks:
I did not access or reproduce with local credentials, so I’m relying on the passing repository integration job for the live-provider E2E signal. |
Motivation
Gemini rejects requests that combine a server-side built-in tool (such as Google Search or Code Execution) with standard function declarations unless
tool_config.include_server_side_tool_invocationsis enabled. The Python provider currently builds both tool entries but omits the flag, so the request fails with400 INVALID_ARGUMENT.Related: #3639
Changes
The Python provider now adds the flag for non-Vertex requests when both tool kinds are present. Existing function-calling and retrieval settings are preserved, and Vertex AI is left unchanged because the field is unsupported there.
This PR intentionally covers only the Python SDK path; the TypeScript path remains separate.
Verification
ruff format --checkruff checkmypy src/strands/models/gemini.pypytest tests/strands/models/test_gemini.py— 70 passed