Skip to content

Commit d5a3d54

Browse files
strawgateclaude
andauthored
fix: GoogleGenaiSamplingHandler leaks thought parts and gives unhelpful errors on empty responses (#3849)
* Fix GoogleGenaiSamplingHandler thought part leaking and unhelpful errors - Filter thought parts (part.thought=True) from response content instead of leaking them as TextContent in _response_to_result_with_tools - Include finish_reason in error messages when no content is found, so safety-filtered responses (SAFETY, RECITATION, etc.) are distinguishable - Add specific error message for thinking-only responses in _response_to_create_message_result Fixes #3846 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add tests for thought part filtering and error message improvements Tests cover: - Thought parts filtered from tool-path responses - Thought-only responses produce descriptive errors - Safety-filtered responses include finish_reason in error - Normal responses (text + function calls) unaffected Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix ruff format and ty check issues Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix ty check errors in tests Remove unused ty: ignore comments from lines where isinstance() narrows the type, and add correct ty: ignore[invalid-argument-type] and ty: ignore[not-subscriptable] comments on lines in newly added test functions where ty cannot infer the union type is a list. Also apply ruff format fix in test_task_return_types.py. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix: use all() not any() for thinking-only detection Addresses review feedback: any() would misclassify mixed responses (thought + function_call) as thinking-only, hiding the real error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f71680c commit d5a3d54

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

src/fastmcp/client/sampling/handlers/google_genai.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,18 @@ def _response_to_create_message_result(
322322
"""Convert Google GenAI response to CreateMessageResult (no tools)."""
323323
if not (text := response.text):
324324
candidate = _get_candidate_from_response(response)
325-
msg = f"No content in response: {candidate.finish_reason}"
325+
# Check if the response only contained thinking
326+
has_thoughts = (
327+
candidate.content
328+
and candidate.content.parts
329+
and all(getattr(p, "thought", False) for p in candidate.content.parts)
330+
)
331+
if has_thoughts:
332+
msg = (
333+
"Model returned only thinking/reasoning content with no response text."
334+
)
335+
else:
336+
msg = f"No content in response (finish_reason={candidate.finish_reason})"
326337
raise ValueError(msg)
327338

328339
return CreateMessageResult(
@@ -365,7 +376,7 @@ def _response_to_result_with_tools(
365376
if candidate.content and candidate.content.parts:
366377
for part in candidate.content.parts:
367378
# Note: Skip thought parts from thinking_config - not relevant for MCP responses
368-
if part.text:
379+
if part.text and not part.thought:
369380
content.append(TextContent(type="text", text=part.text))
370381
elif part.function_call is not None:
371382
fc = part.function_call
@@ -380,7 +391,9 @@ def _response_to_result_with_tools(
380391
)
381392

382393
if not content:
383-
raise ValueError("No content in response from completion")
394+
finish = candidate.finish_reason if candidate else "unknown"
395+
msg = f"No content in response from completion (finish_reason={finish})"
396+
raise ValueError(msg)
384397

385398
return CreateMessageResultWithTools(
386399
content=content,

tests/client/sampling/handlers/test_google_genai_handler.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,142 @@ def test_convert_tool_strips_titles():
460460
assert "title" not in schema
461461
assert "title" not in schema["properties"]["query"]
462462
assert schema["properties"]["query"]["type"] == "string"
463+
464+
465+
# ────────────────────────────────────────────────────────────
466+
# Tests for thought-part filtering and improved error messages
467+
# (PR #3849)
468+
# ────────────────────────────────────────────────────────────
469+
470+
471+
def test_thought_parts_filtered_on_tool_path():
472+
"""Thought parts should be excluded; only the real text part should appear."""
473+
mock_candidate = MagicMock(spec=Candidate)
474+
mock_candidate.content = MagicMock()
475+
mock_candidate.content.parts = [
476+
Part(text="thinking about the problem...", thought=True),
477+
Part(text="Here is the real answer"),
478+
]
479+
mock_candidate.finish_reason = "STOP"
480+
481+
mock_response = MagicMock(spec=GenerateContentResponse)
482+
mock_response.candidates = [mock_candidate]
483+
484+
result = _response_to_result_with_tools(mock_response, model="gemini-2.5-flash")
485+
486+
assert len(result.content) == 1 # ty: ignore[invalid-argument-type]
487+
assert isinstance(result.content[0], TextContent) # ty: ignore[not-subscriptable]
488+
assert result.content[0].text == "Here is the real answer" # ty: ignore[not-subscriptable]
489+
490+
491+
def test_thought_only_response_on_tool_path_raises():
492+
"""When the response contains ONLY thought parts the tool path should raise
493+
a ValueError whose message includes the finish_reason."""
494+
mock_candidate = MagicMock(spec=Candidate)
495+
mock_candidate.content = MagicMock()
496+
mock_candidate.content.parts = [
497+
Part(text="deep thinking...", thought=True),
498+
]
499+
mock_candidate.finish_reason = "STOP"
500+
501+
mock_response = MagicMock(spec=GenerateContentResponse)
502+
mock_response.candidates = [mock_candidate]
503+
504+
with pytest.raises(ValueError, match="finish_reason=STOP"):
505+
_response_to_result_with_tools(mock_response, model="gemini-2.5-flash")
506+
507+
508+
def test_thought_only_response_on_non_tool_path_raises():
509+
"""When the non-tool path receives only thoughts the error message should
510+
mention 'thinking/reasoning content'."""
511+
mock_candidate = MagicMock(spec=Candidate)
512+
mock_candidate.content = MagicMock()
513+
mock_candidate.content.parts = [
514+
Part(text="internal reasoning...", thought=True),
515+
]
516+
mock_candidate.finish_reason = "STOP"
517+
518+
mock_response = MagicMock(spec=GenerateContentResponse)
519+
mock_response.text = None # No real text available
520+
mock_response.candidates = [mock_candidate]
521+
522+
with pytest.raises(ValueError, match="thinking/reasoning content"):
523+
_response_to_create_message_result(mock_response, model="gemini-2.5-flash")
524+
525+
526+
def test_safety_filtered_response_on_tool_path_raises():
527+
"""A safety-filtered response (no parts) should raise with the finish_reason
528+
included in the error message."""
529+
mock_candidate = MagicMock(spec=Candidate)
530+
mock_candidate.content = MagicMock()
531+
mock_candidate.content.parts = [] # Empty parts after safety filtering
532+
mock_candidate.finish_reason = "SAFETY"
533+
534+
mock_response = MagicMock(spec=GenerateContentResponse)
535+
mock_response.candidates = [mock_candidate]
536+
537+
with pytest.raises(ValueError, match="finish_reason=SAFETY"):
538+
_response_to_result_with_tools(mock_response, model="gemini-2.5-flash")
539+
540+
541+
def test_safety_filtered_response_on_non_tool_path_raises():
542+
"""A safety-filtered response on the non-tool path should include
543+
finish_reason in the error."""
544+
mock_candidate = MagicMock(spec=Candidate)
545+
mock_candidate.content = MagicMock()
546+
mock_candidate.content.parts = []
547+
mock_candidate.finish_reason = "SAFETY"
548+
549+
mock_response = MagicMock(spec=GenerateContentResponse)
550+
mock_response.text = None
551+
mock_response.candidates = [mock_candidate]
552+
553+
with pytest.raises(ValueError, match="finish_reason=SAFETY"):
554+
_response_to_create_message_result(mock_response, model="gemini-2.5-flash")
555+
556+
557+
def test_normal_response_text_and_function_call():
558+
"""A normal response with both real text and a function call should
559+
produce both TextContent and ToolUseContent in the result."""
560+
mock_candidate = MagicMock(spec=Candidate)
561+
mock_candidate.content = MagicMock()
562+
mock_candidate.content.parts = [
563+
Part(text="Let me look that up."),
564+
Part(function_call=FunctionCall(name="lookup", args={"q": "test"})),
565+
]
566+
mock_candidate.finish_reason = "STOP"
567+
568+
mock_response = MagicMock(spec=GenerateContentResponse)
569+
mock_response.candidates = [mock_candidate]
570+
571+
result = _response_to_result_with_tools(mock_response, model="gemini-2.5-flash")
572+
573+
assert len(result.content) == 2 # ty: ignore[invalid-argument-type]
574+
assert isinstance(result.content[0], TextContent) # ty: ignore[not-subscriptable]
575+
assert result.content[0].text == "Let me look that up." # ty: ignore[not-subscriptable]
576+
assert isinstance(result.content[1], ToolUseContent) # ty: ignore[not-subscriptable]
577+
assert result.content[1].name == "lookup" # ty: ignore[not-subscriptable]
578+
assert result.content[1].input == {"q": "test"} # ty: ignore[not-subscriptable]
579+
assert result.stopReason == "toolUse"
580+
581+
582+
def test_thought_with_function_call_keeps_function_call():
583+
"""When thinking parts accompany a function call, only the function call
584+
should appear in the content (thought parts filtered out)."""
585+
mock_candidate = MagicMock(spec=Candidate)
586+
mock_candidate.content = MagicMock()
587+
mock_candidate.content.parts = [
588+
Part(text="reasoning about what tool to use...", thought=True),
589+
Part(function_call=FunctionCall(name="get_weather", args={"city": "NYC"})),
590+
]
591+
mock_candidate.finish_reason = "STOP"
592+
593+
mock_response = MagicMock(spec=GenerateContentResponse)
594+
mock_response.candidates = [mock_candidate]
595+
596+
result = _response_to_result_with_tools(mock_response, model="gemini-2.5-flash")
597+
598+
assert len(result.content) == 1 # ty: ignore[invalid-argument-type]
599+
assert isinstance(result.content[0], ToolUseContent) # ty: ignore[not-subscriptable]
600+
assert result.content[0].name == "get_weather" # ty: ignore[not-subscriptable]
601+
assert result.stopReason == "toolUse"

0 commit comments

Comments
 (0)