diff --git a/src/strands/models/gemini.py b/src/strands/models/gemini.py index c24d91a0d..db8b56d4c 100644 --- a/src/strands/models/gemini.py +++ b/src/strands/models/gemini.py @@ -65,6 +65,10 @@ def __init__( self.client_args = client_args or {} + # Store the last thought_signature from Gemini responses for multi-turn conversations + # See: https://ai.google.dev/gemini-api/docs/thought-signatures + self.last_thought_signature: Optional[bytes] = None + @override def update_config(self, **model_config: Unpack[GeminiConfig]) -> None: # type: ignore[override] """Update the Gemini model configuration with the provided arguments. @@ -141,12 +145,15 @@ def _format_request_content_part(self, content: ContentBlock) -> genai.types.Par ) if "toolUse" in content: + # Use the last thought_signature stored from previous Gemini responses + # This is required for Gemini models that use thought signatures in multi-turn conversations return genai.types.Part( function_call=genai.types.FunctionCall( args=content["toolUse"]["input"], id=content["toolUse"]["toolUseId"], name=content["toolUse"]["name"], ), + thought_signature=self.last_thought_signature, ) raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type") @@ -170,7 +177,7 @@ def _format_request_content(self, messages: Messages) -> list[genai.types.Conten for message in messages ] - def _format_request_tools(self, tool_specs: Optional[list[ToolSpec]]) -> list[genai.types.Tool | Any]: + def _format_request_tools(self, tool_specs: Optional[list[ToolSpec]]) -> Optional[list[genai.types.Tool | Any]]: """Format tool specs into Gemini tools. - Docs: https://googleapis.github.io/python-genai/genai.html#genai.types.Tool @@ -179,8 +186,11 @@ def _format_request_tools(self, tool_specs: Optional[list[ToolSpec]]) -> list[ge tool_specs: List of tool specifications to make available to the model. Return: - Gemini tool list. + Gemini tool list, or None if no tools are provided. """ + if not tool_specs: + return None + return [ genai.types.Tool( function_declarations=[ @@ -189,7 +199,7 @@ def _format_request_tools(self, tool_specs: Optional[list[ToolSpec]]) -> list[ge name=tool_spec["name"], parameters_json_schema=tool_spec["inputSchema"]["json"], ) - for tool_spec in tool_specs or [] + for tool_spec in tool_specs ], ), ] @@ -212,9 +222,19 @@ def _format_request_config( Returns: Gemini request config. """ + # Disable thinking text output when tools are present + # Note: Setting include_thoughts=False prevents thinking text in responses but + # Gemini still returns thought_signature for function calls. As of Strands v1.18+, + # the framework properly preserves this field through the message history. + # See: https://ai.google.dev/gemini-api/docs/thought-signatures + thinking_config = None + if tool_specs: + thinking_config = genai.types.ThinkingConfig(include_thoughts=False) + return genai.types.GenerateContentConfig( system_instruction=system_prompt, tools=self._format_request_tools(tool_specs), + thinking_config=thinking_config, **(params or {}), ) @@ -268,14 +288,14 @@ def _format_chunk(self, event: dict[str, Any]) -> StreamEvent: # that name be set in the equivalent FunctionResponse type. Consequently, we assign # function name to toolUseId in our tool use block. And another reason, function_call is # not guaranteed to have id populated. + tool_use: dict[str, Any] = { + "name": event["data"].function_call.name, + "toolUseId": event["data"].function_call.name, + } + return { "contentBlockStart": { - "start": { - "toolUse": { - "name": event["data"].function_call.name, - "toolUseId": event["data"].function_call.name, - }, - }, + "start": {"toolUse": cast(Any, tool_use)}, }, } @@ -373,6 +393,7 @@ async def stream( yield self._format_chunk({"chunk_type": "content_start", "data_type": "text"}) tool_used = False + async for event in response: candidates = event.candidates candidate = candidates[0] if candidates else None @@ -380,8 +401,20 @@ async def stream( parts = content.parts if content and content.parts else [] for part in parts: + # Capture thought_signature and store it for use in subsequent requests + # According to Gemini docs, thought_signature can be on any part + # See: https://ai.google.dev/gemini-api/docs/thought-signatures + if hasattr(part, "thought_signature") and part.thought_signature: + self.last_thought_signature = part.thought_signature + if part.function_call: - yield self._format_chunk({"chunk_type": "content_start", "data_type": "tool", "data": part}) + yield self._format_chunk( + { + "chunk_type": "content_start", + "data_type": "tool", + "data": part, + } + ) yield self._format_chunk({"chunk_type": "content_delta", "data_type": "tool", "data": part}) yield self._format_chunk({"chunk_type": "content_stop", "data_type": "tool", "data": part}) tool_used = True diff --git a/tests/strands/models/test_gemini.py b/tests/strands/models/test_gemini.py index a8f5351cc..6a2ae172a 100644 --- a/tests/strands/models/test_gemini.py +++ b/tests/strands/models/test_gemini.py @@ -84,7 +84,7 @@ async def test_stream_request_default(gemini_client, model, messages, model_id): await anext(model.stream(messages)) exp_request = { - "config": {"tools": [{"function_declarations": []}]}, + "config": {}, "contents": [{"parts": [{"text": "test"}], "role": "user"}], "model": model_id, } @@ -99,7 +99,6 @@ async def test_stream_request_with_params(gemini_client, model, messages, model_ exp_request = { "config": { - "tools": [{"function_declarations": []}], "temperature": 1, }, "contents": [{"parts": [{"text": "test"}], "role": "user"}], @@ -113,7 +112,7 @@ async def test_stream_request_with_system_prompt(gemini_client, model, messages, await anext(model.stream(messages, system_prompt=system_prompt)) exp_request = { - "config": {"system_instruction": system_prompt, "tools": [{"function_declarations": []}]}, + "config": {"system_instruction": system_prompt}, "contents": [{"parts": [{"text": "test"}], "role": "user"}], "model": model_id, } @@ -146,9 +145,7 @@ async def test_stream_request_with_document(content, formatted_part, gemini_clie await anext(model.stream(messages)) exp_request = { - "config": { - "tools": [{"function_declarations": []}], - }, + "config": {}, "contents": [{"parts": [formatted_part], "role": "user"}], "model": model_id, } @@ -173,9 +170,7 @@ async def test_stream_request_with_image(gemini_client, model, model_id): await anext(model.stream(messages)) exp_request = { - "config": { - "tools": [{"function_declarations": []}], - }, + "config": {}, "contents": [ { "parts": [ @@ -214,9 +209,7 @@ async def test_stream_request_with_reasoning(gemini_client, model, model_id): await anext(model.stream(messages)) exp_request = { - "config": { - "tools": [{"function_declarations": []}], - }, + "config": {}, "contents": [ { "parts": [ @@ -251,6 +244,7 @@ async def test_stream_request_with_tool_spec(gemini_client, model, model_id, too ], }, ], + "thinking_config": {"include_thoughts": False}, }, "contents": [], "model": model_id, @@ -277,9 +271,7 @@ async def test_stream_request_with_tool_use(gemini_client, model, model_id): await anext(model.stream(messages)) exp_request = { - "config": { - "tools": [{"function_declarations": []}], - }, + "config": {}, "contents": [ { "parts": [ @@ -327,9 +319,7 @@ async def test_stream_request_with_tool_results(gemini_client, model, model_id): await anext(model.stream(messages)) exp_request = { - "config": { - "tools": [{"function_declarations": []}], - }, + "config": {}, "contents": [ { "parts": [ @@ -371,9 +361,7 @@ async def test_stream_request_with_empty_content(gemini_client, model, model_id) await anext(model.stream(messages)) exp_request = { - "config": { - "tools": [{"function_declarations": []}], - }, + "config": {}, "contents": [{"parts": [], "role": "user"}], "model": model_id, } @@ -614,7 +602,6 @@ async def test_structured_output(gemini_client, model, messages, model_id, weath exp_request = { "config": { - "tools": [{"function_declarations": []}], "response_mime_type": "application/json", "response_schema": weather_output.model_json_schema(), },