@@ -547,7 +547,7 @@ async def _run_stream( # noqa: C901
547
547
async def _run_stream () -> AsyncIterator [_messages .HandleResponseEvent ]: # noqa: C901
548
548
text = ''
549
549
tool_calls : list [_messages .ToolCallPart ] = []
550
- thinking_parts : list [ _messages . ThinkingPart ] = []
550
+ invisible_parts : bool = False
551
551
552
552
for part in self .model_response .parts :
553
553
if isinstance (part , _messages .TextPart ):
@@ -558,55 +558,65 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa
558
558
# Text parts before a built-in tool call are essentially thoughts,
559
559
# not part of the final result output, so we reset the accumulated text
560
560
text = ''
561
+ invisible_parts = True
561
562
yield _messages .BuiltinToolCallEvent (part ) # pyright: ignore[reportDeprecated]
562
563
elif isinstance (part , _messages .BuiltinToolReturnPart ):
564
+ invisible_parts = True
563
565
yield _messages .BuiltinToolResultEvent (part ) # pyright: ignore[reportDeprecated]
564
566
elif isinstance (part , _messages .ThinkingPart ):
565
- thinking_parts . append ( part )
567
+ invisible_parts = True
566
568
else :
567
569
assert_never (part )
568
570
569
571
# At the moment, we prioritize at least executing tool calls if they are present.
570
572
# In the future, we'd consider making this configurable at the agent or run level.
571
573
# This accounts for cases like anthropic returns that might contain a text response
572
574
# and a tool call response, where the text response just indicates the tool call will happen.
573
- if tool_calls :
574
- async for event in self . _handle_tool_calls ( ctx , tool_calls ) :
575
- yield event
576
- elif text :
577
- # No events are emitted during the handling of text responses, so we don't need to yield anything
578
- self . _next_node = await self . _handle_text_response ( ctx , text )
579
- elif thinking_parts :
580
- # handle thinking-only responses (responses that contain only ThinkingPart instances)
581
- # this can happen with models that support thinking mode when they don't provide
582
- # actionable output alongside their thinking content.
583
- self . _next_node = ModelRequestNode [ DepsT , NodeRunEndT ](
584
- _messages .ModelRequest (
585
- parts = [ _messages . RetryPromptPart ( 'Responses without text or tool calls are not permitted.' )]
575
+ try :
576
+ if tool_calls :
577
+ async for event in self . _handle_tool_calls ( ctx , tool_calls ):
578
+ yield event
579
+ elif text :
580
+ # No events are emitted during the handling of text responses, so we don't need to yield anything
581
+ self . _next_node = await self . _handle_text_response ( ctx , text )
582
+ elif invisible_parts :
583
+ # handle responses with only thinking or built-in tool parts.
584
+ # this can happen with models that support thinking mode when they don't provide
585
+ # actionable output alongside their thinking content. so we tell the model to try again.
586
+ m = _messages .RetryPromptPart (
587
+ content = 'Responses without text or tool calls are not permitted.' ,
586
588
)
587
- )
588
- else :
589
- # we got an empty response with no tool calls, text, or thinking
590
- # this sometimes happens with anthropic (and perhaps other models)
591
- # when the model has already returned text along side tool calls
592
- # in this scenario, if text responses are allowed, we return text from the most recent model
593
- # response, if any
594
- if isinstance (ctx .deps .output_schema , _output .TextOutputSchema ):
595
- for message in reversed (ctx .state .message_history ):
596
- if isinstance (message , _messages .ModelResponse ):
597
- text = ''
598
- for part in message .parts :
599
- if isinstance (part , _messages .TextPart ):
600
- text += part .content
601
- elif isinstance (part , _messages .BuiltinToolCallPart ):
602
- # Text parts before a built-in tool call are essentially thoughts,
603
- # not part of the final result output, so we reset the accumulated text
604
- text = '' # pragma: no cover
605
- if text :
606
- self ._next_node = await self ._handle_text_response (ctx , text )
607
- return
608
-
609
- raise exceptions .UnexpectedModelBehavior ('Received empty model response' )
589
+ raise ToolRetryError (m )
590
+ else :
591
+ # we got an empty response with no tool calls, text, thinking, or built-in tool calls.
592
+ # this sometimes happens with anthropic (and perhaps other models)
593
+ # when the model has already returned text along side tool calls
594
+ # in this scenario, if text responses are allowed, we return text from the most recent model
595
+ # response, if any
596
+ if isinstance (ctx .deps .output_schema , _output .TextOutputSchema ):
597
+ for message in reversed (ctx .state .message_history ):
598
+ if isinstance (message , _messages .ModelResponse ):
599
+ text = ''
600
+ for part in message .parts :
601
+ if isinstance (part , _messages .TextPart ):
602
+ text += part .content
603
+ elif isinstance (part , _messages .BuiltinToolCallPart ):
604
+ # Text parts before a built-in tool call are essentially thoughts,
605
+ # not part of the final result output, so we reset the accumulated text
606
+ text = '' # pragma: no cover
607
+ if text :
608
+ self ._next_node = await self ._handle_text_response (ctx , text )
609
+ return
610
+
611
+ # Go back to the model request node with an empty request, which means we'll essentially
612
+ # resubmit the most recent request that resulted in an empty response,
613
+ # as the empty response and request will not create any items in the API payload,
614
+ # in the hope the model will return a non-empty response this time.
615
+ ctx .state .increment_retries (ctx .deps .max_result_retries )
616
+ self ._next_node = ModelRequestNode [DepsT , NodeRunEndT ](_messages .ModelRequest (parts = []))
617
+ except ToolRetryError as e :
618
+ ctx .state .increment_retries (ctx .deps .max_result_retries , e )
619
+ self ._next_node = ModelRequestNode [DepsT , NodeRunEndT ](_messages .ModelRequest (parts = [e .tool_retry ]))
610
620
611
621
self ._events_iterator = _run_stream ()
612
622
@@ -666,23 +676,19 @@ async def _handle_text_response(
666
676
text : str ,
667
677
) -> ModelRequestNode [DepsT , NodeRunEndT ] | End [result .FinalResult [NodeRunEndT ]]:
668
678
output_schema = ctx .deps .output_schema
669
- try :
670
- run_context = build_run_context (ctx )
671
- if isinstance (output_schema , _output .TextOutputSchema ):
672
- result_data = await output_schema .process (text , run_context )
673
- else :
674
- m = _messages .RetryPromptPart (
675
- content = 'Plain text responses are not permitted, please include your response in a tool call' ,
676
- )
677
- raise ToolRetryError (m )
679
+ run_context = build_run_context (ctx )
678
680
679
- for validator in ctx .deps .output_validators :
680
- result_data = await validator .validate (result_data , run_context )
681
- except ToolRetryError as e :
682
- ctx .state .increment_retries (ctx .deps .max_result_retries , e )
683
- return ModelRequestNode [DepsT , NodeRunEndT ](_messages .ModelRequest (parts = [e .tool_retry ]))
681
+ if isinstance (output_schema , _output .TextOutputSchema ):
682
+ result_data = await output_schema .process (text , run_context )
684
683
else :
685
- return self ._handle_final_result (ctx , result .FinalResult (result_data ), [])
684
+ m = _messages .RetryPromptPart (
685
+ content = 'Plain text responses are not permitted, please include your response in a tool call' ,
686
+ )
687
+ raise ToolRetryError (m )
688
+
689
+ for validator in ctx .deps .output_validators :
690
+ result_data = await validator .validate (result_data , run_context )
691
+ return self ._handle_final_result (ctx , result .FinalResult (result_data ), [])
686
692
687
693
__repr__ = dataclasses_no_defaults_repr
688
694
0 commit comments