Skip to content

fix(gemini): enable built-in tools with function declarations - #3646

Closed
Solaris-star wants to merge 5 commits into
strands-agents:mainfrom
Solaris-star:fix/3639-gemini-built-in-function-tools
Closed

Solaris-star wants to merge 5 commits into
strands-agents:mainfrom
Solaris-star:fix/3639-gemini-built-in-function-tools

Conversation

@Solaris-star

Copy link
Copy Markdown

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_invocations is enabled. The Python provider currently builds both tool entries but omits the flag, so the request fails with 400 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 --check
  • ruff check
  • mypy src/strands/models/gemini.py
  • pytest tests/strands/models/test_gemini.py — 70 passed

@Solaris-star
Solaris-star requested a review from a team as a code owner August 5, 2026 00:11
@Solaris-star
Solaris-star requested a review from mehtarac August 5, 2026 00:11
@github-actions github-actions Bot added size/s bug Something isn't working area-model Related to models or model providers python Pull requests that update python code labels Aug 5, 2026
@opieter-aws

Copy link
Copy Markdown
Contributor

@strandly-the-agent review this PR against the original issue

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py70 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 :35870 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_specstool_specs is not None; deleting the elif isinstance(..., dict) branch; deleting the is None guard at :365 (always-overwrite); and dropping the is_vertex=is_vertex passthrough in _format_request. Only =True=False was killed (by test_gemini.py:1035). Worth adding cases for the dict-shaped params["tool_config"] and for an explicit caller-set False.
  • tool_specs=[] vs None. ToolRegistry.get_all_tool_specs() returns [], never None, when nothing is registered — that's what a live Agent with only gemini_tools sends. Behaviour is identical today, which is exactly why the tool_specs is not None mutant survives silently. A tool_specs=[] case would close it.
  • structured_output doesn't pass is_vertex (gemini.py:720) → defaults False even on a Vertex client. Unreachable today (tool_specs=None short-circuits the gate first), so latent only. Side note: the issue's claim that fixing _format_request_config automatically covers structured_output isn't quite true — harmlessly, in this case.
  • Nits. (1) gemini.py:354 rewords 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 root AGENTS.md evergreen-comment rule asks of a regression test. (3) The two new tests use partial dict asserts rather than the whole-request tru_/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() into gemini_client at :623 is necessary — the fix needs the client for both .vertexai and .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:113 via :739), identical on main. 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 passed

Solid 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.

Comment thread strands-py/src/strands/models/gemini.py Outdated
Comment thread strands-py/tests/strands/models/test_gemini.py Outdated
Comment thread strands-py/src/strands/models/gemini.py
Comment thread strands-py/src/strands/models/gemini.py Outdated
Signed-off-by: Solaris-star <820622658@qq.com>
@Solaris-star

Copy link
Copy Markdown
Author

Addressed the review in 018d7528.

  • Raised the Python SDK dependency floor to google-genai>=1.68.0.
  • Normalized dict-shaped tool_config through ToolConfig.model_validate, preserving camelCase aliases and explicit False.
  • Preserved an explicit params={"tool_config": None} as an opt-out, consistent with the existing params-wins rule.
  • Made the Vertex check use boolean coercion and set the test fixture's vertexai value explicitly.
  • Strengthened the Vertex assertion to inspect the nested config.tool_config field.
  • Added regression coverage for camelCase input and explicit None.

Verification:

  • strands-py/.venv/bin/python -m pytest strands-py/tests/strands/models/test_gemini.py -q — 72 passed
  • targeted Ruff check/format — passed
  • targeted mypy on gemini.py — no issues
  • git diff --check — passed
  • tracked-content secret scan against upstream main — no blocking new hits

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 (@opieter-aws) or a maintainer with an approved integration environment, especially for the built-in-plus-function-tools combination and the separate TypeScript half.

@yonib05 yonib05 added the complexity/high A touched function exceeds cognitive complexity 25; may be worth splitting label Aug 7, 2026
@Solaris-star

Copy link
Copy Markdown
Author

Addressed the review follow-up and pushed the updated branch:

  • explicit tool_config=None remains an intentional opt-out;
  • supplied tool-config dictionaries are normalized through the Google SDK, including REST-style aliases;
  • the Vertex client detection is now boolean-safe;
  • added coverage for empty function tools, camel-case config aliases, and the explicit opt-out.

Verification: ruff format --check, ruff check, mypy src/strands/models/gemini.py, and Gemini tests — 72 passed.

@Solaris-star

Copy link
Copy Markdown
Author

Addressed the review findings and pushed commit 27c405c.

  • Raised the Gemini extra floor to google-genai>=1.68.0, where the field exists.
  • Normalized supplied tool_config dictionaries through ToolConfig.model_validate so vendor camelCase aliases remain valid.
  • Preserved explicit params["tool_config"] = None as an intentional opt-out.
  • Coerced the Vertex client flag with bool() and set the mock fixture explicitly.
  • Added a real nested Vertex assertion, a tool_specs=[] case, camelCase config coverage, and explicit-None opt-out coverage.
  • Kept the unrelated comment change out of the final diff.

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.

@mehtarac

Copy link
Copy Markdown
Member

@strandly-the-agent do a fresh review based on the fixes added

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 fixedpyproject.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 fixedToolConfig.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 27c405c772 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 27c405c772 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.

Comment thread strands-py/src/strands/models/gemini.py Outdated
Signed-off-by: Solaris-star <820622658@qq.com>
@Solaris-star

Copy link
Copy Markdown
Author

Addressed the remaining finding from the follow-up review and pushed commit af0f64ac8dfb4a2d369902b716fb3e26ff61c469.

The production path now uses bool(getattr(gemini_client, "vertexai", False)), so truthy non-boolean client values cannot fail open and incorrectly enable the server-side invocation flag on Vertex requests. This restores the behavior that was present in 018d7528 and was reverted in 27c405c7.

Verification on the pushed revision:

  • uv run --project . --extra gemini --extra dev pytest tests/strands/models/test_gemini.py -q72 passed
  • .venv/bin/ruff format --check src/strands/models/gemini.py tests/strands/models/test_gemini.py2 files already formatted
  • .venv/bin/ruff check src/strands/models/gemini.py tests/strands/models/test_gemini.pyAll checks passed
  • .venv/bin/mypy src/strands/models/gemini.pySuccess: no issues found
  • git diff --checkpassed
  • added-line secret scan — PASS; 0 hits
  • commit — SSH signature verified locally; DCO sign-off present

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.

@opieter-aws

Copy link
Copy Markdown
Contributor

@strandly-the-agent review latest revision

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -q72 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.

@opieter-aws

Copy link
Copy Markdown
Contributor

@Solaris-star can you please verify your fix E2E?

@Solaris-star

Copy link
Copy Markdown
Author

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.

@Solaris-star Solaris-star closed this by deleting the head repository Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-model Related to models or model providers bug Something isn't working complexity/high A touched function exceeds cognitive complexity 25; may be worth splitting python Pull requests that update python code size/s

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants