Skip to content

feat: support Tool Choice for Gemini in Python - #3551

Merged
opieter-aws merged 4 commits into
strands-agents:mainfrom
strandly-the-agent:agent-tasks/1129
Aug 4, 2026
Merged

opieter-aws merged 4 commits into
strands-agents:mainfrom
strandly-the-agent:agent-tasks/1129

Conversation

@opieter-aws

@opieter-aws opieter-aws commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

GeminiModel.stream() accepted a tool_choice and discarded it — the docstring read Note: Currently unused. and nothing downstream consumed it. The practical cost is the event loop's forced structured-output retry: it re-invokes with tool_choice={"any": {}} expecting an API-level guarantee of a tool call, and on Gemini that retry was forced in name only, leaving StructuredOutputException reachable where Anthropic and Bedrock guarantee a call. This ports the mapping that already exists in the TypeScript SDK (strands-ts/src/models/google/model.ts), closing a py↔ts parity gap.

Strands ToolChoice Gemini FunctionCallingConfigMode
{"auto": {}} AUTO
{"any": {}} ANY
{"tool": {"name": "X"}} ANY + allowed_function_names=["X"]

A tool_choice with no tool specs is a no-op, matching TS — there is nothing to choose from.

Public API Changes

No new surface: tool_choice was already on GeminiModel.stream() and on the base Model protocol. Two behavioral notes for reviewers.

An explicit tool_config in params takes precedence over a per-request tool_choice. Two earlier commits on this branch tried the other direction, then a field-level merge; both were abandoned because a Gemini ToolConfig carries fields a Strands ToolChoice cannot express (retrieval_config, stream_function_call_arguments), so each merge rule left another user-set field behind. Letting the explicit config win passes it through whole and matches the other providers and the TypeScript SDK, which all spread params last.

model = GeminiModel(model_id=..., params={"tool_config": my_tool_config})

model.stream(messages, tool_specs=specs, tool_choice={"any": {}})  # my_tool_config still wins, unchanged

tool_choice is now keyword-only, matching Model.stream() and every other provider. Gemini was the only one missing the *, so a positional fourth argument was already outside the declared interface, and was silently ignored:

# Before: accepted positionally, then discarded
model.stream(messages, tool_specs, system_prompt, {"any": {}})

# After
model.stream(messages, tool_specs, system_prompt, tool_choice={"any": {}})

Related Issues

Resolves #1129

Documentation PR

N/A

Type of Change

New feature

Testing

Summary: 64/64 test_gemini.py pass (57 pre-existing + 7 new), 265 pass across the model/event-loop/structured-output suites, ruff format --check + ruff check + mypy clean, and the same suite passes against the declared google-genai floor (1.67.0) as well as 2.14.0.

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works — this PR was written by an agent; a human reviewer needs to own this box before merge.
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly — docstrings only; no site/ page covers per-provider tool_choice
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings — asserted by test_stream_tool_choice_no_warning
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Port the tool_choice mapping from the TypeScript SDK's GoogleModel so a
Strands ToolChoice reaches the Gemini API as a tool config:

- auto -> FunctionCallingConfigMode.AUTO
- any  -> FunctionCallingConfigMode.ANY
- tool -> FunctionCallingConfigMode.ANY + allowed_function_names

Closes strands-agents#1129
…aram

A tool config supplied through the params config was winning over the
per-request tool_choice, which silently defeated the forced retry the
event loop uses for structured output. The per-request choice is the more
specific instruction, matching how BedrockModel drops a conflicting
thinking config when a tool is forced.
@opieter-aws
opieter-aws requested a review from a team as a code owner July 29, 2026 19:37
@opieter-aws
opieter-aws requested a review from zastrowm July 29, 2026 19:37
@github-actions github-actions Bot added size/m area-model Related to models or model providers area-structured-output Related to the structured output api python Pull requests that update python code bug Something isn't working strands-running labels Jul 29, 2026
@opieter-aws opieter-aws changed the title Agent tasks/1129 feat: support Tool Choice for Gemini in Python Jul 29, 2026
@opieter-aws opieter-aws removed bug Something isn't working area-structured-output Related to the structured output api labels Jul 29, 2026
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread strands-py/src/strands/models/gemini.py
@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve

Clean, focused fix that closes a real py↔ts parity gap. The ToolChoiceFunctionCallingConfigMode mapping matches the TS source and the existing AnthropicModel._format_tool_choice pattern, and the per-request-overrides-params precedence is well reasoned. I checked out the branch and verified locally: 64/64 test_gemini.py pass, ruff check/ruff format --check clean.

Review notes
  • Correctness: config_params = dict(params or {}) copies before setting tool_config, so the caller's self.config["params"] isn't mutated — good. None allowed_function_names is dropped by to_json_dict(), so AUTO/ANY serialize cleanly (test-covered).
  • Testing: Unit tests use full-shape assert_called_with(**exp_request) rather than per-field asserts; the integ test forces tool_time while asking about weather, which genuinely proves the API-level constraint overrides the model. Strong coverage.
  • Intentional divergence (no change needed): Unlike anthropic.py/openai.py, this gates tool_choice on tool_specs and silently no-ops otherwise. It's documented, matches TS, and is arguably more correct for Gemini — flagging only so a human reviewer is aware of the cross-provider difference.
  • Docstring nit: left one inline note about aligning the _format_request tool_choice docstring with the fuller wording used elsewhere.

Nicely scoped PR with clear rationale in the description and matching test evidence.

@opieter-aws

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent review this pr

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

Heads up before anything else: this is a self-review. I wrote this branch (at opieter-aws's request on #1129), so I'm reviewing my own code. I mitigated that with six independent fresh-context passes and then re-verified the two strongest findings myself — but weight the evidence below, not my own rationale in the PR body.

Verdict: changes requested — one real defect, in the precedence rule I added in the second commit.

🟡 gemini.py:349-351 replaces a user's params["tool_config"] for every recognized tool_choice — including {"auto": {}}, which imposes no constraint. An explicit allowed_function_names narrowing silently vanishes, as does any orthogonal field (retrieval_config). Reproduced independently, twice. Fix is 8 lines, verified green — inline.

🟡 A new integ test asserts exact-list equality on a Gemini ANY-forced call; ANY doesn't guarantee call count, so it's a flake risk — inline.

⚪ I've endorsed the existing unresolved github-actions docstring thread rather than duplicating it.

Also: the two tests_integ cases have never been executed by anyone — no GOOGLE_API_KEY in my sandbox then or now, and the Testing section doesn't say so. Please let CI exercise them before merge. Suggested label: api/needs-review (solo-approvable, not an API meeting).

Verification ledger, three non-blocking questions, and what I suppressed

Ledger

✅ Reviewed branch agent-tasks/1129 @ a720d2c8, base 2b84dfa5
✅ Tests pytest tests/strands/models/test_gemini.py -q64/64, python 3.12.13, google-genai 2.15.0
✅ Lint/types ruff check + ruff format --check clean; mypy shows 1 error, pre-existing and byte-identical on 2b84dfa5 (count_tokens arg-type, unrelated)
✅ Repro 1 auto-clobber confirmed on a mocked client (details inline)
✅ Repro 2 retrieval_config set via params is discarded by any recognized tool_choice, incl. {"any":{}} on the forced-retry path
✅ Suggested fix applied + run: 64/64, ruff clean, mypy unchanged, both repros fixed
✅ PR's core claim accurate — a real Agent(structured_output_model=…) shows call #1 tool_config: null → call #2 (forced retry) mode: ANY; on main call #2 carried nothing
✅ kw-only * safe — no caller in the tree passes tool_choice positionally; AGENTS.md:205 mandates the *; already shipped as non-breaking for other providers
✅ Dep floor suite green against the declared floor google-genai==1.67.0
Not verified the 2 tests_integ cases — no GOOGLE_API_KEY, never run by anyone

Questions (all non-blocking)

  1. Precedence scope. gemini.py:349 makes Gemini the only provider where a per-request tool_choice beats static params — every sibling (anthropic.py:256/258, openai.py:520/521, bedrock.py:356) and TS (model.ts:344-346) let static config win. #1129 asked us to stop dropping tool_choice, not to set a conflict-resolution policy. My own first commit (877e2d72) used setdefault and matched everyone; the second reversed it. Worth an explicit sign-off plus a short DECISIONS.md entry, and a tracking issue for AnthropicModel's identical latent gap (anthropic.py:258 splats last, defeating the force at anthropic.py:541)?
  2. Shared bug with TS. model.ts:344-346 resolves this same question the opposite way, untested on either side. Rather than this PR turning a shared latent bug into a silent cross-SDK divergence, worth a strands-ts issue so both converge on one contract?
  3. PR body. The Bedrock analogy I wrote ("how BedrockModel resolves a conflicting thinking config") doesn't hold up: bedrock.py:362-388 is forcing-only and field-scoped — it removes one key, it doesn't replace the object. Worth tightening that sentence so the next provider author doesn't inherit it as precedent.

Suppressed (kept off the PR deliberately)

  • gemini_tools + tool_choice silent drop — looks like a bug, isn't: gemini.py:684-703 forbids FunctionDeclarations in gemini_tools, so there's never a function to force. Gating on tool_specs is correct.
  • Malformed {"tool": {}} raises KeyError inside the provider — convention-consistent with anthropic.py:271, unreachable via typed callers.
  • kw-only * pinned by no test (mutation survivor) — repo-wide gap; no provider suite pins it. Not this PR's job.
  • if tool_specs silent no-op vs warn_on_tool_choice_not_supported — adjudicated: keep silent. Matches TS, and the orchestrator always pairs tool_choice with tool_specs, so a warning nobody can act on is noise.
  • Private _format_request_config gaining a 4th positional arg — private helper, pay-for-play per DECISIONS.md.
  • No site/ page documents per-provider tool_choice — pre-existing and SDK-wide (no provider page mentions it); the checklist claim is accurate.
  • Fixture placement, GeminiConfig.params docstring imprecision, test granularity — style/pre-existing.

Mutation testing (separate pass): 7 mutants, 6 killed — the new tests genuinely have teeth. The one survivor is deleting the *.

Comment thread strands-py/src/strands/models/gemini.py Outdated
Comment thread strands-py/tests_integ/models/test_model_gemini.py
Comment thread strands-py/src/strands/models/gemini.py
…field

A per-request tool choice replaced the whole tool config from params, so an
"auto" choice - which imposes no constraint - discarded an explicit
allowed_function_names narrowing, and any choice discarded fields a Strands
ToolChoice cannot express, such as retrieval_config. On the framework's forced
structured-output retry that happened with no user action at all.

A choice now overrides only when it forces a tool, and only the function calling
config it can actually express; the rest of the user's tool config is carried
over. The docstrings say which half of the rule applies where, and the tool
config fixture carries an allowlist plus a field no choice can express so the
tests pin both.

Also relax an integration assertion that required exactly one tool call: Gemini's
ANY mode may emit several, and only the narrowing is guaranteed.
@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve

Follow-up review of 29ad316b ("scope tool choice precedence to forcing choices and one field"), which lands after the prior changes-requested pass. All three earlier findings are resolved, and I re-verified the behavioral one independently against the new HEAD.

What changed and how I verified it
  • auto-clobber defect (was 🟡) — Fixed. A choice now overrides only when it forces a tool (any/tool) and only replaces the function_calling_config; the rest of a params tool_config (e.g. retrieval_config) is carried over via ToolConfig.model_validate(...).model_copy(update=...). I re-ran the original repros: None/auto leave the params config fully intact, any/tool narrow correctly while retrieval_config survives, and — critically — the caller's params["tool_config"] is unchanged (same object id, byte-identical) after a forcing call, so nothing leaks into the next request.
  • integ flake risk (was 🟡) — Fixed. List equality → set equality, so parallel/duplicate ANY calls no longer flake while an empty result still fails.
  • docstrings (was ⚪) — Fixed. All three now state the accurate, conditional rule (forcing choice wins over the function-calling config; auto leaves params in place), replacing the previously-unconditional "takes precedence."
  • Tests — 67/67 test_gemini.py pass (new leak-regression + parametrized precedence tests pin auto+params and retrieval_config survival); ruff check + ruff format --check clean.

Two carry-over items for the human owner before merge (unchanged from the prior pass, not blockers): the two tests_integ cases still require CI to supply GOOGLE_API_KEY to actually run, and someone needs to own the "reviewed every line" checkbox since this branch is agent-authored. Nicely handled response to review feedback — the merge approach is exactly right (no mutation, forcing-only scope).

@opieter-aws

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent can you review again?

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

Approving 29ad316b. All three findings from my previous pass are genuinely fixed — I re-ran the original repros against the new HEAD rather than taking the bot's word for it. Still a self-review (I wrote this branch), so the evidence below is the point, not my sign-off.

One new 🟡 the fix left behind, and it's not why I'm approving anyway: the forcing branch replaces the whole function_calling_config, so a user's stream_function_call_arguments is still dropped on any/tool — the same species as the bug we just fixed, one level deeper. ~10-line fix, verified green, inline. I'm not blocking on it: it's a niche field on a narrow path, everything I actually asked for landed, and blocking twice on progressively smaller instances of one issue would be disproportionate. Your call whether it rides along or becomes a follow-up.

Verification ledger — what I re-ran, plus carry-overs and what I suppressed

Re-verified myself on 29ad316b (delta a720d2c8..HEAD only, 21 src lines)

✅ Tests 67/67 test_gemini.py; ruff check + ruff format --check clean; mypy back to 1 error, the pre-existing count_tokens one also present on base 2b84dfa5
✅ auto-clobber fixed None and {"auto":{}} now both leave the params config fully intact (mode: ANY, allowed_function_names: [safe_tool]); {"any":{}} narrows to mode: ANY; {"tool":{name}} sets the allowlist — and retrieval_config survives all four
✅ no leak params["tool_config"] byte-identical and same object id after forcing calls. 60 concurrent interleaved calls on one model → exactly 2 distinct wire shapes (40 preserved / 20 forced), zero cross-talk, config pristine
✅ dict-shaped params params={"tool_config": {...}} (a plain dict, not a ToolConfig) goes through the new model_validate path correctly and the user's dict is not mutated
✅ integ set-comparison correct relaxation, not a weakened assertion — still fails for the wrong tool, a second different tool, or no tool at all
✅ docstrings all three now state the accurate conditional rule; my earlier accuracy complaint is resolved
✅ new tests have teeth mutation battery on the new logic: 5/7 killed. Both survivors are non-defects — if not existing: is semantically inert, and dropping the tool_choice is not None guard is caught by mypy, not the suite
❓ Not verified the 2 tests_integ cases — still no GOOGLE_API_KEY, still never executed by anyone

Carry-overs for the human owner (unchanged, not blockers)

  • CI needs to actually exercise the two integ tests.
  • The api/needs-review label still isn't applied.
  • Someone needs to own the "reviewed every line" checkbox — this branch is agent-authored.

❓ Non-blocking design question

auto can no longer relax a params tool config — a caller asking for {"auto": {}} against params={"tool_config": ToolConfig(mode=NONE)} gets NONE, i.e. no tool is callable despite asking for auto. Bedrock would honour the auto (bedrock.py:317 lets the per-request choice win unconditionally). The hybrid is defensible — "per-request always wins" is exactly what destroyed retrieval_config — and the forcing predicate is character-identical to bedrock.py:379. Worth one docstring line saying auto can't be used to relax a params config?

Suppressed (attacked, survived — recorded so nobody re-runs them)

  • Aliasing is real but unexploitable: ToolConfig.model_validate(x) does return the same object and model_copy is shallow, so the copy shares retrieval_config with the user's object — but _format_request calls .to_json_dict() before anything reaches the client, so no live object escapes.
  • Garbage/partially-valid params["tool_config"]: error parity holds across 9 shapes × 3 choices — nothing that used to work now raises, nothing that used to raise now silently passes.
  • params={"tool_config": None} and an empty ToolConfig() both take a sane branch.

Comment thread strands-py/src/strands/models/gemini.py Outdated
…oice

A per-request tool choice now applies only when params sets no tool config of
its own, matching the other providers and the TypeScript SDK, which all let the
explicit config win. The merge this replaces had to decide which fields a
ToolChoice may overwrite, and each answer left another field behind - the
allowlist, then retrieval_config, then stream_function_call_arguments.

The consequence is the same as everywhere else: a tool config in params also
outranks the forced structured-output retry, so a model that answers in plain
text can still raise StructuredOutputException. That is parity rather than a
Gemini quirk, and worth fixing for every provider at once.

Tests pin the contract from both sides: a params tool config survives every
choice, an explicit None still owns the key, and a choice never persists into a
later request.
@strandly-the-agent

Copy link
Copy Markdown
Contributor

Implemented the precedence decision in 9507ef4d (replying here rather than in the thread — thread replies are blocked for me).

_format_request_config is now a single config_params.setdefault("tool_config", tool_config): a per-request tool_choice applies only when params sets no tool config, and an explicit one passes through whole rather than being merged with or replaced by the narrower choice. That also retires the stream_function_call_arguments finding without a special case — nothing is field-scoped any more. The accepted consequence is now uniform: with a params tool config the forced structured-output retry cannot override it, so StructuredOutputException stays reachable exactly as on the other providers.

I checked parity empirically rather than assuming it: setdefault decides on key presence, which is observationally identical to the spread-last ordering in anthropic.py:258, openai.py:521, bedrock.py:355 and Object.assign in strands-ts/src/models/google/model.ts:344 — including when the value is an explicit None.

Behaviour, evidence, and what still needs a human
WITH a params tool_config (untouched every time)
  None / auto / any / {tool: X}   -> mode=NONE, allowed=[safe_tool], retrieval_config kept
WITHOUT one (#1129's actual fix, still working)
  None -> no tool_config | auto -> AUTO | any -> ANY | {tool: X} -> ANY + [X]

Tests pin the contract from three sides: test_stream_request_tool_config_param_takes_precedence (parametrized over all four choices), test_stream_tool_config_param_set_to_none_still_takes_precedence (params owns the key), and test_stream_tool_choice_does_not_persist_into_the_next_request (a choice never lands in the model's own params).

  • 68/68 test_gemini.py on google-genai 2.15.0 and on the declared floor 1.67.0; 1080 passed across tests/strands/models + event_loop + tools; ruff check / format --check clean; mypy still at the single pre-existing count_tokens error that is also present on base.
  • Mutation-checked: reverting to an override, dropping the dict(params or {}) copy, removing the if tool_specs gate, or switching the key check to truthiness each fail a test.
  • Docstrings: the precedence rule is now stated once, on the public stream(), and the private helpers keep the bare one-liner — which matches anthropic.py/openai.py, where no precedence is documented at all. So the earlier request to mirror the fuller wording into _format_request is deliberately not implemented; a previous automated reply claimed that thread was addressed, which wasn't accurate.

Still needs a human before merge, unchanged from my earlier pass: the two tests_integ cases have never actually run (no GOOGLE_API_KEY in my sandbox) so CI needs to exercise them; the api/needs-review label isn't applied; someone needs to own the "reviewed every line" checkbox on an agent-authored branch; and the PR description still documents the old override behaviour — I can't edit it, so it needs an author edit before this merges.

@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve (one doc-accuracy nit)

Follow-up review of 9507ef4d ("let a tool config in params take precedence over tool_choice"). This reverses the precedence direction from the previous commit, and I think it's the right call: it directly resolves the "Precedence scope" question from the earlier pass, and it replaces the fragile field-merge (which kept leaving one more field behind — allowlist, then retrieval_config, then stream_function_call_arguments) with a single setdefault. Much simpler and no leak surface.

Verification of the reversal
  • Parity claim holds. anthropic.py:256-258 spreads **self._format_tool_choice(tool_choice) then **(self.config.get("params") or {}) last, and openai.py:520-521 does the same (**tool_choice then **params) — params wins in both. setdefault("tool_config", tool_config) matches that exactly. (Bedrock builds its toolChoice differently, so it isn't a direct analog, but the two closest providers confirm it.)
  • Behavior, verified against this HEAD: a params tool_config is left fully intact under every choice (None/auto/any/tool), including retrieval_config; an explicit params={"tool_config": None} keeps the choice out of the request; with no params tool_config the choice still maps and reaches the API (the core [FEATURE] Implement Gemini Tool Choice #1129 fix is preserved); and a choice never persists into a later request.
  • Tradeoff is acknowledged honestly in the commit message: a params tool_config now also outranks the framework's forced structured-output retry, so StructuredOutputException stays reachable when a user sets their own config — but that's exactly how the other providers behave, so it's parity rather than a Gemini quirk.
  • 68/68 test_gemini.py pass; ruff check + ruff format --check clean. New tests pin the contract from both sides.

Nit (non-blocking): the PR description's DevX Changes section is now stale — it still states the previous direction ("A per-request tool_choice takes precedence over a tool config supplied through params") with a # New: per-request choice wins example, which is the opposite of what the code now does. The in-code docstrings are correct; only the PR body needs updating so it doesn't mislead reviewers (and the squash-merge message). The BedrockModel analogy in that paragraph no longer applies either.

Same two carry-over items for the human owner before merge (unchanged, not blockers): CI needs GOOGLE_API_KEY to actually exercise the two integ tests, and a human still needs to own the "reviewed every line" checkbox on this agent-authored branch. Solid convergence over the three iterations.

@opieter-aws
opieter-aws requested review from pgrayy and removed request for zastrowm July 31, 2026 18:30
@lizradway
lizradway self-requested a review August 4, 2026 15:30
@opieter-aws
opieter-aws merged commit 0ab31d7 into strands-agents:main Aug 4, 2026
32 checks passed
poshinchen pushed a commit to poshinchen/sdk-python that referenced this pull request Aug 10, 2026
Co-authored-by: strandly-the-agent <strandly-the-agent@users.noreply.github.com>
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 python Pull requests that update python code size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Implement Gemini Tool Choice

3 participants