Skip to content

Commit 9730a99

Browse files
committed
feat: add Gemini thoughtSignature capture and handling
- Capture thought_signature from Gemini function call responses - Base64 encode thought_signature for storage in message history - Decode and pass thought_signature back to Gemini in subsequent requests - Configure thinking_config to disable thinking text but preserve signatures - Add NotRequired import to content.py for type safety This complements the framework changes by implementing Gemini-specific handling of thought signatures for proper multi-turn function calling with Gemini 3 Pro. See: https://ai.google.dev/gemini-api/docs/thought-signatures
1 parent fd3eda8 commit 9730a99

File tree

3 files changed

+65
-12
lines changed

3 files changed

+65
-12
lines changed

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[build-system]
2-
requires = ["hatchling", "hatch-vcs"]
2+
requires = ["hatchling"] # Removed hatch-vcs for manual version testing
33
build-backend = "hatchling.build"
44

55

66
[project]
77
name = "strands-agents"
8-
dynamic = ["version"] # Version determined by git tags
8+
version = "1.18.0dev" # Temporary override for testing thought_signature fix
99
description = "A model-driven approach to building AI agents in just a few lines of code"
1010
readme = "README.md"
1111
requires-python = ">=3.10"
@@ -94,8 +94,8 @@ Documentation = "https://strandsagents.com"
9494
packages = ["src/strands"]
9595

9696

97-
[tool.hatch.version]
98-
source = "vcs" # Use git tags for versioning
97+
# [tool.hatch.version]
98+
# source = "vcs" # Temporarily disabled for testing - using manual version
9999

100100

101101
[tool.hatch.envs.hatch-static-analysis]

src/strands/models/gemini.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- Docs: https://ai.google.dev/api
44
"""
55

6+
import base64
67
import json
78
import logging
89
import mimetypes
@@ -141,12 +142,26 @@ def _format_request_content_part(self, content: ContentBlock) -> genai.types.Par
141142
)
142143

143144
if "toolUse" in content:
145+
thought_signature_b64 = cast(Optional[str], content["toolUse"].get("thoughtSignature"))
146+
147+
thought_signature = None
148+
if thought_signature_b64:
149+
try:
150+
thought_signature = base64.b64decode(thought_signature_b64)
151+
except Exception as e:
152+
logger.error("toolUseId=<%s> | failed to decode thoughtSignature: %s", content["toolUse"].get("toolUseId"), e)
153+
else:
154+
# thoughtSignature is now preserved by the Strands framework (as of v1.18+)
155+
# If missing, it means the model didn't provide one (e.g., older Gemini versions)
156+
logger.debug("toolUseId=<%s> | no thoughtSignature in toolUse (model may not require it)", content["toolUse"].get("toolUseId"))
157+
144158
return genai.types.Part(
145159
function_call=genai.types.FunctionCall(
146160
args=content["toolUse"]["input"],
147161
id=content["toolUse"]["toolUseId"],
148162
name=content["toolUse"]["name"],
149163
),
164+
thought_signature=thought_signature,
150165
)
151166

152167
raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type")
@@ -212,9 +227,19 @@ def _format_request_config(
212227
Returns:
213228
Gemini request config.
214229
"""
230+
# Disable thinking text output when tools are present
231+
# Note: Setting include_thoughts=False prevents thinking text in responses but
232+
# Gemini still returns thought_signature for function calls. As of Strands v1.18+,
233+
# the framework properly preserves this field through the message history.
234+
# See: https://ai.google.dev/gemini-api/docs/thought-signatures
235+
thinking_config = None
236+
if tool_specs:
237+
thinking_config = genai.types.ThinkingConfig(include_thoughts=False)
238+
215239
return genai.types.GenerateContentConfig(
216240
system_instruction=system_prompt,
217241
tools=self._format_request_tools(tool_specs),
242+
thinking_config=thinking_config,
218243
**(params or {}),
219244
)
220245

@@ -268,14 +293,24 @@ def _format_chunk(self, event: dict[str, Any]) -> StreamEvent:
268293
# that name be set in the equivalent FunctionResponse type. Consequently, we assign
269294
# function name to toolUseId in our tool use block. And another reason, function_call is
270295
# not guaranteed to have id populated.
296+
tool_use: dict[str, Any] = {
297+
"name": event["data"].function_call.name,
298+
"toolUseId": event["data"].function_call.name,
299+
}
300+
301+
# Get thought_signature from the event dict (passed from stream method)
302+
thought_sig = event.get("thought_signature")
303+
304+
if thought_sig:
305+
# Ensure it's bytes for encoding
306+
if isinstance(thought_sig, str):
307+
thought_sig = thought_sig.encode("utf-8")
308+
# Use base64 encoding for storage
309+
tool_use["thoughtSignature"] = base64.b64encode(thought_sig).decode("utf-8")
310+
271311
return {
272312
"contentBlockStart": {
273-
"start": {
274-
"toolUse": {
275-
"name": event["data"].function_call.name,
276-
"toolUseId": event["data"].function_call.name,
277-
},
278-
},
313+
"start": {"toolUse": cast(Any, tool_use)},
279314
},
280315
}
281316

@@ -373,15 +408,33 @@ async def stream(
373408
yield self._format_chunk({"chunk_type": "content_start", "data_type": "text"})
374409

375410
tool_used = False
411+
# Track thought_signature to associate with function calls
412+
# According to Gemini docs, thought_signature can be on any part
413+
last_thought_signature: Optional[bytes] = None
414+
376415
async for event in response:
377416
candidates = event.candidates
378417
candidate = candidates[0] if candidates else None
379418
content = candidate.content if candidate else None
380419
parts = content.parts if content and content.parts else []
381420

382421
for part in parts:
422+
# Check ALL parts for thought_signature (Gemini may still include it even with thinking disabled)
423+
if hasattr(part, "thought_signature") and part.thought_signature:
424+
last_thought_signature = part.thought_signature
425+
383426
if part.function_call:
384-
yield self._format_chunk({"chunk_type": "content_start", "data_type": "tool", "data": part})
427+
# Use the last thought_signature captured
428+
effective_thought_signature = last_thought_signature
429+
430+
yield self._format_chunk(
431+
{
432+
"chunk_type": "content_start",
433+
"data_type": "tool",
434+
"data": part,
435+
"thought_signature": effective_thought_signature,
436+
}
437+
)
385438
yield self._format_chunk({"chunk_type": "content_delta", "data_type": "tool", "data": part})
386439
yield self._format_chunk({"chunk_type": "content_stop", "data_type": "tool", "data": part})
387440
tool_used = True

src/strands/types/content.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from typing import Dict, List, Literal, Optional
1010

11-
from typing_extensions import TypedDict
11+
from typing_extensions import NotRequired, TypedDict
1212

1313
from .citations import CitationsContentBlock
1414
from .media import DocumentContent, ImageContent, VideoContent

0 commit comments

Comments
 (0)