From 877e2d727603acb81fbacfaab943a68738112144 Mon Sep 17 00:00:00 2001 From: strandly-the-agent Date: Wed, 29 Jul 2026 13:37:48 +0000 Subject: [PATCH 1/4] feat(gemini): implement tool_choice support 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 #1129 --- strands-py/src/strands/models/gemini.py | 60 +++++++++++-- .../tests/strands/models/test_gemini.py | 90 +++++++++++++++++++ .../tests_integ/models/test_model_gemini.py | 44 +++++++++ 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/strands-py/src/strands/models/gemini.py b/strands-py/src/strands/models/gemini.py index 7b1d7f4522..e6cfbdd7af 100644 --- a/strands-py/src/strands/models/gemini.py +++ b/strands-py/src/strands/models/gemini.py @@ -19,7 +19,7 @@ from ..types.event_loop import Usage from ..types.exceptions import ContextWindowOverflowException, ModelThrottledException, ProviderTokenCountError from ..types.streaming import StreamEvent -from ..types.tools import ToolChoice, ToolSpec +from ..types.tools import ToolChoice, ToolChoiceToolDict, ToolSpec from ._defaults import resolve_config_metadata from ._validation import _has_location_source, validate_config_keys from .model import BaseModelConfig, Model @@ -288,11 +288,46 @@ def _format_request_tools(self, tool_specs: list[ToolSpec] | None) -> list[genai tools.extend(self.config["gemini_tools"]) return tools + @staticmethod + def _format_tool_choice(tool_choice: ToolChoice | None) -> genai.types.ToolConfig | None: + """Format a tool choice into a Gemini tool config. + + - Docs: https://googleapis.github.io/python-genai/genai.html#genai.types.ToolConfig + + Args: + tool_choice: Selection strategy for tool invocation. + + Returns: + Gemini tool config, or None when no strategy is requested. + """ + if tool_choice is None: + return None + + allowed_function_names: list[str] | None = None + if "auto" in tool_choice: + mode = genai.types.FunctionCallingConfigMode.AUTO + elif "any" in tool_choice: + mode = genai.types.FunctionCallingConfigMode.ANY + elif "tool" in tool_choice: + # Gemini has no single-tool mode, so a specific tool is ANY narrowed to that one function. + mode = genai.types.FunctionCallingConfigMode.ANY + allowed_function_names = [cast(ToolChoiceToolDict, tool_choice)["tool"]["name"]] + else: + return None + + return genai.types.ToolConfig( + function_calling_config=genai.types.FunctionCallingConfig( + mode=mode, + allowed_function_names=allowed_function_names, + ), + ) + def _format_request_config( self, tool_specs: list[ToolSpec] | None, system_prompt: str | None, params: dict[str, Any] | None, + tool_choice: ToolChoice | None = None, ) -> genai.types.GenerateContentConfig: """Format Gemini request config. @@ -302,14 +337,23 @@ def _format_request_config( tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. params: Additional model parameters (e.g., temperature). + tool_choice: Selection strategy for tool invocation. Applied only alongside tool specs, since there + is nothing to choose from without them. Returns: Gemini request config. """ + config_params = dict(params or {}) + + tool_config = self._format_tool_choice(tool_choice) if tool_specs else None + if tool_config is not None: + # A tool config set directly through params is the more specific instruction, so it wins. + config_params.setdefault("tool_config", tool_config) + return genai.types.GenerateContentConfig( system_instruction=system_prompt, tools=self._format_request_tools(tool_specs), - **(params or {}), + **config_params, ) def _format_request( @@ -318,6 +362,7 @@ def _format_request( tool_specs: list[ToolSpec] | None, system_prompt: str | None, params: dict[str, Any] | None, + tool_choice: ToolChoice | None = None, ) -> dict[str, Any]: """Format a Gemini streaming request. @@ -328,12 +373,13 @@ def _format_request( tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. params: Additional model parameters (e.g., temperature). + tool_choice: Selection strategy for tool invocation. Returns: A Gemini streaming request. """ return { - "config": self._format_request_config(tool_specs, system_prompt, params).to_json_dict(), + "config": self._format_request_config(tool_specs, system_prompt, params, tool_choice).to_json_dict(), "contents": [content.to_json_dict() for content in self._format_request_content(messages)], "model": self.config["model_id"], } @@ -518,6 +564,7 @@ async def stream( messages: Messages, tool_specs: list[ToolSpec] | None = None, system_prompt: str | None = None, + *, tool_choice: ToolChoice | None = None, **kwargs: Any, ) -> AsyncGenerator[StreamEvent, None]: @@ -527,8 +574,7 @@ async def stream( messages: List of message objects to be processed by the model. tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. - tool_choice: Selection strategy for tool invocation. - Note: Currently unused. + tool_choice: Selection strategy for tool invocation. Applied only when tool specs are provided. **kwargs: Additional keyword arguments for future extensibility. Yields: @@ -537,7 +583,9 @@ async def stream( Raises: ModelThrottledException: If the request is throttled by Gemini. """ - request = self._format_request(messages, tool_specs, system_prompt, self.config.get("params")) + request = self._format_request( + messages, tool_specs, system_prompt, self.config.get("params"), tool_choice=tool_choice + ) client = self._get_client().aio diff --git a/strands-py/tests/strands/models/test_gemini.py b/strands-py/tests/strands/models/test_gemini.py index 518c7e4ed3..0c1a009d4c 100644 --- a/strands-py/tests/strands/models/test_gemini.py +++ b/strands-py/tests/strands/models/test_gemini.py @@ -1059,6 +1059,96 @@ async def test_stream_request_with_gemini_tools_and_function_tools(gemini_client gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) +@pytest.mark.parametrize( + ("tool_choice", "exp_function_calling_config"), + [ + ({"auto": {}}, {"mode": "AUTO"}), + ({"any": {}}, {"mode": "ANY"}), + ({"tool": {"name": "name"}}, {"allowed_function_names": ["name"], "mode": "ANY"}), + ], +) +@pytest.mark.asyncio +async def test_stream_request_with_tool_choice( + gemini_client, model, messages, tool_spec, model_id, tool_choice, exp_function_calling_config +): + await anext(model.stream(messages, tool_specs=[tool_spec], tool_choice=tool_choice)) + + exp_request = { + "config": { + "tools": [ + { + "function_declarations": [ + { + "description": tool_spec["description"], + "name": tool_spec["name"], + "parameters_json_schema": tool_spec["inputSchema"]["json"], + } + ] + } + ], + "tool_config": {"function_calling_config": exp_function_calling_config}, + }, + "contents": [{"parts": [{"text": "test"}], "role": "user"}], + "model": model_id, + } + gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) + + +@pytest.mark.asyncio +async def test_stream_request_with_tool_choice_and_no_tool_specs(gemini_client, model, messages, model_id): + await anext(model.stream(messages, tool_choice={"any": {}})) + + exp_request = { + "config": {}, + "contents": [{"parts": [{"text": "test"}], "role": "user"}], + "model": model_id, + } + gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) + + +@pytest.mark.asyncio +async def test_stream_request_with_tool_choice_and_tool_config_param(gemini_client, messages, tool_spec, model_id): + tool_config = genai.types.ToolConfig( + function_calling_config=genai.types.FunctionCallingConfig(mode=genai.types.FunctionCallingConfigMode.NONE) + ) + model = GeminiModel(model_id=model_id, params={"tool_config": tool_config}) + + await anext(model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) + + exp_request = { + "config": { + "tools": [ + { + "function_declarations": [ + { + "description": tool_spec["description"], + "name": tool_spec["name"], + "parameters_json_schema": tool_spec["inputSchema"]["json"], + } + ] + } + ], + "tool_config": {"function_calling_config": {"mode": "NONE"}}, + }, + "contents": [{"parts": [{"text": "test"}], "role": "user"}], + "model": model_id, + } + gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) + + +def test_format_tool_choice_unrecognized_strategy(model): + tru_tool_config = model._format_tool_choice({"unrecognized": {}}) + exp_tool_config = None + assert tru_tool_config == exp_tool_config + + +@pytest.mark.asyncio +async def test_stream_tool_choice_no_warning(model, messages, tool_spec, captured_warnings): + await anext(model.stream(messages, tool_specs=[tool_spec], tool_choice={"auto": {}})) + + assert len(captured_warnings) == 0 + + @pytest.mark.asyncio async def test_stream_handles_non_json_error(gemini_client, model, messages, alist): error_message = "Invalid API key" diff --git a/strands-py/tests_integ/models/test_model_gemini.py b/strands-py/tests_integ/models/test_model_gemini.py index 1057757dad..d7b3fa160e 100644 --- a/strands-py/tests_integ/models/test_model_gemini.py +++ b/strands-py/tests_integ/models/test_model_gemini.py @@ -204,6 +204,50 @@ def test_agent_with_gemini_code_execution_tool(gemini_tool_model): assert "5117" in str(result_turn2) +@pytest.fixture +def tool_specs(): + return [ + { + "name": "tool_time", + "description": "Get the current time for a city", + "inputSchema": {"json": {"type": "object", "properties": {"city": {"type": "string"}}}}, + }, + { + "name": "tool_weather", + "description": "Get the current weather for a city", + "inputSchema": {"json": {"type": "object", "properties": {"city": {"type": "string"}}}}, + }, + ] + + +@pytest.mark.asyncio +async def test_model_stream_tool_choice_any_forces_a_tool_use(model, tool_specs, alist): + messages = [{"role": "user", "content": [{"text": "Hello there!"}]}] + + events = await alist(model.stream(messages, tool_specs=tool_specs, tool_choice={"any": {}})) + + tru_stop_reason = next(event["messageStop"]["stopReason"] for event in events if "messageStop" in event) + exp_stop_reason = "tool_use" + assert tru_stop_reason == exp_stop_reason + + +@pytest.mark.asyncio +async def test_model_stream_tool_choice_tool_forces_the_named_tool(model, tool_specs, alist): + messages = [{"role": "user", "content": [{"text": "What is the weather in New York?"}]}] + + events = await alist( + model.stream(messages, tool_specs=tool_specs, tool_choice={"tool": {"name": "tool_time"}}), + ) + + tru_tool_names = [ + event["contentBlockStart"]["start"]["toolUse"]["name"] + for event in events + if "contentBlockStart" in event and "toolUse" in event["contentBlockStart"]["start"] + ] + exp_tool_names = ["tool_time"] + assert tru_tool_names == exp_tool_names + + def test_agent_with_reasoning_content(model, assistant_agent): """Test that reasoning content is captured in message history.""" From a720d2c8988548a0964b1c1ffa786eb90fb0e621 Mon Sep 17 00:00:00 2001 From: strandly-the-agent Date: Wed, 29 Jul 2026 14:51:45 +0000 Subject: [PATCH 2/4] fix(gemini): let a per-request tool choice override the tool config param 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. --- strands-py/src/strands/models/gemini.py | 9 ++-- .../tests/strands/models/test_gemini.py | 42 +++++++++++++++++-- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/strands-py/src/strands/models/gemini.py b/strands-py/src/strands/models/gemini.py index e6cfbdd7af..bc1beeac20 100644 --- a/strands-py/src/strands/models/gemini.py +++ b/strands-py/src/strands/models/gemini.py @@ -338,7 +338,7 @@ def _format_request_config( system_prompt: System prompt to provide context to the model. params: Additional model parameters (e.g., temperature). tool_choice: Selection strategy for tool invocation. Applied only alongside tool specs, since there - is nothing to choose from without them. + is nothing to choose from without them. Takes precedence over a tool config set in params. Returns: Gemini request config. @@ -347,8 +347,8 @@ def _format_request_config( tool_config = self._format_tool_choice(tool_choice) if tool_specs else None if tool_config is not None: - # A tool config set directly through params is the more specific instruction, so it wins. - config_params.setdefault("tool_config", tool_config) + # A per-request tool choice is more specific than the tool config in params, so it takes precedence. + config_params["tool_config"] = tool_config return genai.types.GenerateContentConfig( system_instruction=system_prompt, @@ -574,7 +574,8 @@ async def stream( messages: List of message objects to be processed by the model. tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. - tool_choice: Selection strategy for tool invocation. Applied only when tool specs are provided. + tool_choice: Selection strategy for tool invocation. Applied only when tool specs are provided, + and takes precedence over a tool config set through the params config. **kwargs: Additional keyword arguments for future extensibility. Yields: diff --git a/strands-py/tests/strands/models/test_gemini.py b/strands-py/tests/strands/models/test_gemini.py index 0c1a009d4c..7961fc8b85 100644 --- a/strands-py/tests/strands/models/test_gemini.py +++ b/strands-py/tests/strands/models/test_gemini.py @@ -1106,14 +1106,48 @@ async def test_stream_request_with_tool_choice_and_no_tool_specs(gemini_client, gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) -@pytest.mark.asyncio -async def test_stream_request_with_tool_choice_and_tool_config_param(gemini_client, messages, tool_spec, model_id): +@pytest.fixture +def tool_config_param_model(gemini_client, model_id): + _ = gemini_client + tool_config = genai.types.ToolConfig( function_calling_config=genai.types.FunctionCallingConfig(mode=genai.types.FunctionCallingConfigMode.NONE) ) - model = GeminiModel(model_id=model_id, params={"tool_config": tool_config}) + return GeminiModel(model_id=model_id, params={"tool_config": tool_config}) + + +@pytest.mark.asyncio +async def test_stream_request_tool_choice_takes_precedence_over_tool_config_param( + gemini_client, tool_config_param_model, messages, tool_spec, model_id +): + await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) + + exp_request = { + "config": { + "tools": [ + { + "function_declarations": [ + { + "description": tool_spec["description"], + "name": tool_spec["name"], + "parameters_json_schema": tool_spec["inputSchema"]["json"], + } + ] + } + ], + "tool_config": {"function_calling_config": {"mode": "ANY"}}, + }, + "contents": [{"parts": [{"text": "test"}], "role": "user"}], + "model": model_id, + } + gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) - await anext(model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) + +@pytest.mark.asyncio +async def test_stream_request_tool_config_param_without_tool_choice( + gemini_client, tool_config_param_model, messages, tool_spec, model_id +): + await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec])) exp_request = { "config": { From 29ad316b0ed40f43b69e90b4fa20310484c37d89 Mon Sep 17 00:00:00 2001 From: strandly-the-agent Date: Thu, 30 Jul 2026 13:18:05 +0000 Subject: [PATCH 3/4] fix(gemini): scope tool choice precedence to forcing choices and one 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. --- strands-py/src/strands/models/gemini.py | 21 +++++++--- .../tests/strands/models/test_gemini.py | 39 +++++++++++++++---- .../tests_integ/models/test_model_gemini.py | 7 ++-- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/strands-py/src/strands/models/gemini.py b/strands-py/src/strands/models/gemini.py index bc1beeac20..c9d9f53991 100644 --- a/strands-py/src/strands/models/gemini.py +++ b/strands-py/src/strands/models/gemini.py @@ -298,7 +298,7 @@ def _format_tool_choice(tool_choice: ToolChoice | None) -> genai.types.ToolConfi tool_choice: Selection strategy for tool invocation. Returns: - Gemini tool config, or None when no strategy is requested. + Gemini tool config, or None when no recognized strategy is requested. """ if tool_choice is None: return None @@ -338,7 +338,8 @@ def _format_request_config( system_prompt: System prompt to provide context to the model. params: Additional model parameters (e.g., temperature). tool_choice: Selection strategy for tool invocation. Applied only alongside tool specs, since there - is nothing to choose from without them. Takes precedence over a tool config set in params. + is nothing to choose from without them. A forcing tool choice takes precedence over the function + calling config of a tool config set in params. Returns: Gemini request config. @@ -347,8 +348,16 @@ def _format_request_config( tool_config = self._format_tool_choice(tool_choice) if tool_specs else None if tool_config is not None: - # A per-request tool choice is more specific than the tool config in params, so it takes precedence. - config_params["tool_config"] = tool_config + existing = config_params.get("tool_config") + if existing is None: + config_params["tool_config"] = tool_config + elif tool_choice is not None and ("any" in tool_choice or "tool" in tool_choice): + # A per-request choice that forces a tool is more specific than the tool config in params, so it + # wins - but only over the field a ToolChoice can express. An "auto" choice imposes no constraint, + # so it never clobbers an explicit params config. + config_params["tool_config"] = genai.types.ToolConfig.model_validate(existing).model_copy( + update={"function_calling_config": tool_config.function_calling_config} + ) return genai.types.GenerateContentConfig( system_instruction=system_prompt, @@ -575,7 +584,9 @@ async def stream( tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. tool_choice: Selection strategy for tool invocation. Applied only when tool specs are provided, - and takes precedence over a tool config set through the params config. + since there is nothing to choose from without them. A forcing tool choice takes precedence over + the function calling config of a tool config set through the params config; an "auto" choice + leaves it in place. **kwargs: Additional keyword arguments for future extensibility. Yields: diff --git a/strands-py/tests/strands/models/test_gemini.py b/strands-py/tests/strands/models/test_gemini.py index 7961fc8b85..b6539a9358 100644 --- a/strands-py/tests/strands/models/test_gemini.py +++ b/strands-py/tests/strands/models/test_gemini.py @@ -1111,16 +1111,33 @@ def tool_config_param_model(gemini_client, model_id): _ = gemini_client tool_config = genai.types.ToolConfig( - function_calling_config=genai.types.FunctionCallingConfig(mode=genai.types.FunctionCallingConfigMode.NONE) + function_calling_config=genai.types.FunctionCallingConfig( + mode=genai.types.FunctionCallingConfigMode.NONE, + allowed_function_names=["safe_tool"], + ), + # A field no ToolChoice can express, so a tool choice must leave it in place. + retrieval_config=genai.types.RetrievalConfig(language_code="en-GB"), ) return GeminiModel(model_id=model_id, params={"tool_config": tool_config}) +@pytest.mark.parametrize( + ("tool_choice", "exp_function_calling_config"), + [ + # No choice and an "auto" choice both leave the params tool config exactly as configured. + (None, {"mode": "NONE", "allowed_function_names": ["safe_tool"]}), + ({"auto": {}}, {"mode": "NONE", "allowed_function_names": ["safe_tool"]}), + # A forcing choice replaces the whole function calling config, allowlist included - the forced tool + # need not appear in a user's allowlist - while the fields it cannot express survive. + ({"any": {}}, {"mode": "ANY"}), + ({"tool": {"name": "name"}}, {"mode": "ANY", "allowed_function_names": ["name"]}), + ], +) @pytest.mark.asyncio -async def test_stream_request_tool_choice_takes_precedence_over_tool_config_param( - gemini_client, tool_config_param_model, messages, tool_spec, model_id +async def test_stream_request_tool_config_param_precedence( + gemini_client, tool_config_param_model, messages, tool_spec, model_id, tool_choice, exp_function_calling_config ): - await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) + await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec], tool_choice=tool_choice)) exp_request = { "config": { @@ -1135,7 +1152,10 @@ async def test_stream_request_tool_choice_takes_precedence_over_tool_config_para ] } ], - "tool_config": {"function_calling_config": {"mode": "ANY"}}, + "tool_config": { + "function_calling_config": exp_function_calling_config, + "retrieval_config": {"language_code": "en-GB"}, + }, }, "contents": [{"parts": [{"text": "test"}], "role": "user"}], "model": model_id, @@ -1144,9 +1164,11 @@ async def test_stream_request_tool_choice_takes_precedence_over_tool_config_para @pytest.mark.asyncio -async def test_stream_request_tool_config_param_without_tool_choice( +async def test_stream_forcing_tool_choice_does_not_leak_into_the_next_request( gemini_client, tool_config_param_model, messages, tool_spec, model_id ): + """A forced choice applies to its own request only, leaving the params tool config for the next one.""" + await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec])) exp_request = { @@ -1162,7 +1184,10 @@ async def test_stream_request_tool_config_param_without_tool_choice( ] } ], - "tool_config": {"function_calling_config": {"mode": "NONE"}}, + "tool_config": { + "function_calling_config": {"mode": "NONE", "allowed_function_names": ["safe_tool"]}, + "retrieval_config": {"language_code": "en-GB"}, + }, }, "contents": [{"parts": [{"text": "test"}], "role": "user"}], "model": model_id, diff --git a/strands-py/tests_integ/models/test_model_gemini.py b/strands-py/tests_integ/models/test_model_gemini.py index d7b3fa160e..70c7bcf100 100644 --- a/strands-py/tests_integ/models/test_model_gemini.py +++ b/strands-py/tests_integ/models/test_model_gemini.py @@ -239,12 +239,13 @@ async def test_model_stream_tool_choice_tool_forces_the_named_tool(model, tool_s model.stream(messages, tool_specs=tool_specs, tool_choice={"tool": {"name": "tool_time"}}), ) - tru_tool_names = [ + # ANY mode may emit more than one call, so only the narrowing to tool_time is guaranteed. + tru_tool_names = { event["contentBlockStart"]["start"]["toolUse"]["name"] for event in events if "contentBlockStart" in event and "toolUse" in event["contentBlockStart"]["start"] - ] - exp_tool_names = ["tool_time"] + } + exp_tool_names = {"tool_time"} assert tru_tool_names == exp_tool_names From 9507ef4d2ea02ec942fefbebfd04d3f1850d1f6d Mon Sep 17 00:00:00 2001 From: strandly-the-agent Date: Thu, 30 Jul 2026 14:37:13 +0000 Subject: [PATCH 4/4] fix(gemini): let a tool config in params take precedence over tool_choice 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. --- strands-py/src/strands/models/gemini.py | 23 ++----- .../tests/strands/models/test_gemini.py | 69 ++++++++++++------- 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/strands-py/src/strands/models/gemini.py b/strands-py/src/strands/models/gemini.py index c9d9f53991..6a301582d4 100644 --- a/strands-py/src/strands/models/gemini.py +++ b/strands-py/src/strands/models/gemini.py @@ -337,9 +337,7 @@ def _format_request_config( tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. params: Additional model parameters (e.g., temperature). - tool_choice: Selection strategy for tool invocation. Applied only alongside tool specs, since there - is nothing to choose from without them. A forcing tool choice takes precedence over the function - calling config of a tool config set in params. + tool_choice: Selection strategy for tool invocation. Returns: Gemini request config. @@ -348,16 +346,10 @@ def _format_request_config( tool_config = self._format_tool_choice(tool_choice) if tool_specs else None if tool_config is not None: - existing = config_params.get("tool_config") - if existing is None: - config_params["tool_config"] = tool_config - elif tool_choice is not None and ("any" in tool_choice or "tool" in tool_choice): - # A per-request choice that forces a tool is more specific than the tool config in params, so it - # wins - but only over the field a ToolChoice can express. An "auto" choice imposes no constraint, - # so it never clobbers an explicit params config. - config_params["tool_config"] = genai.types.ToolConfig.model_validate(existing).model_copy( - update={"function_calling_config": tool_config.function_calling_config} - ) + # A tool config set in params wins, matching the other providers and the TypeScript SDK. A tool + # config expresses more than a ToolChoice can, so the explicit one is left whole rather than + # merged with, or replaced by, the narrower per-request choice. + config_params.setdefault("tool_config", tool_config) return genai.types.GenerateContentConfig( system_instruction=system_prompt, @@ -584,9 +576,8 @@ async def stream( tool_specs: List of tool specifications to make available to the model. system_prompt: System prompt to provide context to the model. tool_choice: Selection strategy for tool invocation. Applied only when tool specs are provided, - since there is nothing to choose from without them. A forcing tool choice takes precedence over - the function calling config of a tool config set through the params config; an "auto" choice - leaves it in place. + since there is nothing to choose from without them, and only when params sets no tool config of + its own - an explicit tool config takes precedence. **kwargs: Additional keyword arguments for future extensibility. Yields: diff --git a/strands-py/tests/strands/models/test_gemini.py b/strands-py/tests/strands/models/test_gemini.py index b6539a9358..5c70222d55 100644 --- a/strands-py/tests/strands/models/test_gemini.py +++ b/strands-py/tests/strands/models/test_gemini.py @@ -1115,28 +1115,22 @@ def tool_config_param_model(gemini_client, model_id): mode=genai.types.FunctionCallingConfigMode.NONE, allowed_function_names=["safe_tool"], ), - # A field no ToolChoice can express, so a tool choice must leave it in place. + # A full tool config, so the assertions below pin that a tool choice leaves all of it in place. retrieval_config=genai.types.RetrievalConfig(language_code="en-GB"), ) return GeminiModel(model_id=model_id, params={"tool_config": tool_config}) @pytest.mark.parametrize( - ("tool_choice", "exp_function_calling_config"), - [ - # No choice and an "auto" choice both leave the params tool config exactly as configured. - (None, {"mode": "NONE", "allowed_function_names": ["safe_tool"]}), - ({"auto": {}}, {"mode": "NONE", "allowed_function_names": ["safe_tool"]}), - # A forcing choice replaces the whole function calling config, allowlist included - the forced tool - # need not appear in a user's allowlist - while the fields it cannot express survive. - ({"any": {}}, {"mode": "ANY"}), - ({"tool": {"name": "name"}}, {"mode": "ANY", "allowed_function_names": ["name"]}), - ], + "tool_choice", + [None, {"auto": {}}, {"any": {}}, {"tool": {"name": "name"}}], + ids=["no-choice", "auto", "any", "tool"], ) @pytest.mark.asyncio -async def test_stream_request_tool_config_param_precedence( - gemini_client, tool_config_param_model, messages, tool_spec, model_id, tool_choice, exp_function_calling_config +async def test_stream_request_tool_config_param_takes_precedence( + gemini_client, tool_config_param_model, messages, tool_spec, model_id, tool_choice ): + """An explicit tool config wins over any per-request choice, matching the other providers.""" await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec], tool_choice=tool_choice)) exp_request = { @@ -1153,7 +1147,7 @@ async def test_stream_request_tool_config_param_precedence( } ], "tool_config": { - "function_calling_config": exp_function_calling_config, + "function_calling_config": {"mode": "NONE", "allowed_function_names": ["safe_tool"]}, "retrieval_config": {"language_code": "en-GB"}, }, }, @@ -1164,12 +1158,16 @@ async def test_stream_request_tool_config_param_precedence( @pytest.mark.asyncio -async def test_stream_forcing_tool_choice_does_not_leak_into_the_next_request( - gemini_client, tool_config_param_model, messages, tool_spec, model_id +async def test_stream_tool_config_param_set_to_none_still_takes_precedence( + gemini_client, messages, tool_spec, model_id ): - """A forced choice applies to its own request only, leaving the params tool config for the next one.""" - await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) - await anext(tool_config_param_model.stream(messages, tool_specs=[tool_spec])) + """Params owns the key, so an explicit None keeps a tool choice out of the request. + + Matches the sibling providers, which spread params last and therefore let an explicit None win. + """ + model = GeminiModel(model_id=model_id, params={"tool_config": None}) + + await anext(model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) exp_request = { "config": { @@ -1184,10 +1182,35 @@ async def test_stream_forcing_tool_choice_does_not_leak_into_the_next_request( ] } ], - "tool_config": { - "function_calling_config": {"mode": "NONE", "allowed_function_names": ["safe_tool"]}, - "retrieval_config": {"language_code": "en-GB"}, - }, + }, + "contents": [{"parts": [{"text": "test"}], "role": "user"}], + "model": model_id, + } + gemini_client.aio.models.generate_content_stream.assert_called_with(**exp_request) + + +@pytest.mark.asyncio +async def test_stream_tool_choice_does_not_persist_into_the_next_request(gemini_client, messages, tool_spec, model_id): + """A tool choice configures its own request only, so it never lands in the model's own params.""" + model = GeminiModel(model_id=model_id, params={"temperature": 0.5}) + + await anext(model.stream(messages, tool_specs=[tool_spec], tool_choice={"any": {}})) + await anext(model.stream(messages, tool_specs=[tool_spec])) + + exp_request = { + "config": { + "temperature": 0.5, + "tools": [ + { + "function_declarations": [ + { + "description": tool_spec["description"], + "name": tool_spec["name"], + "parameters_json_schema": tool_spec["inputSchema"]["json"], + } + ] + } + ], }, "contents": [{"parts": [{"text": "test"}], "role": "user"}], "model": model_id,