@@ -143,6 +143,18 @@ def is_agent_node(
143
143
return isinstance (node , AgentNode )
144
144
145
145
146
+ async def _create_thinking_retry (
147
+ ctx : GraphRunContext [GraphAgentState , GraphAgentDeps [DepsT , NodeRunEndT ]],
148
+ ) -> ModelRequestNode [DepsT , NodeRunEndT ]:
149
+ # Create retry prompt
150
+ retry_prompt = 'Responses without text or tool calls are not permitted.'
151
+ retry_part = _messages .RetryPromptPart (retry_prompt )
152
+ retry_request = _messages .ModelRequest (parts = [retry_part ])
153
+
154
+ # Create new ModelRequestNode for retry (it will add the request to message history)
155
+ return ModelRequestNode [DepsT , NodeRunEndT ](request = retry_request )
156
+
157
+
146
158
@dataclasses .dataclass
147
159
class UserPromptNode (AgentNode [DepsT , NodeRunEndT ]):
148
160
"""The node that handles the user prompt and instructions."""
@@ -434,9 +446,10 @@ async def _run_stream( # noqa: C901
434
446
if self ._events_iterator is None :
435
447
# Ensure that the stream is only run once
436
448
437
- async def _run_stream () -> AsyncIterator [_messages .HandleResponseEvent ]:
449
+ async def _run_stream () -> AsyncIterator [_messages .HandleResponseEvent ]: # noqa: C901
438
450
texts : list [str ] = []
439
451
tool_calls : list [_messages .ToolCallPart ] = []
452
+
440
453
for part in self .model_response .parts :
441
454
if isinstance (part , _messages .TextPart ):
442
455
# ignore empty content for text parts, see #437
@@ -468,19 +481,30 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]:
468
481
# No events are emitted during the handling of text responses, so we don't need to yield anything
469
482
self ._next_node = await self ._handle_text_response (ctx , texts )
470
483
else :
471
- # we've got an empty response, this sometimes happens with anthropic (and perhaps other models)
472
- # when the model has already returned text along side tool calls
473
- # in this scenario, if text responses are allowed, we return text from the most recent model
474
- # response, if any
475
- if isinstance (ctx .deps .output_schema , _output .TextOutputSchema ):
476
- for message in reversed (ctx .state .message_history ):
477
- if isinstance (message , _messages .ModelResponse ):
478
- last_texts = [p .content for p in message .parts if isinstance (p , _messages .TextPart )]
479
- if last_texts :
480
- self ._next_node = await self ._handle_text_response (ctx , last_texts )
481
- return
482
-
483
- raise exceptions .UnexpectedModelBehavior ('Received empty model response' )
484
+ # we've got an empty response
485
+
486
+ thinking_parts = [p for p in self .model_response .parts if isinstance (p , _messages .ThinkingPart )]
487
+
488
+ if thinking_parts :
489
+ # handle thinking-only responses (responses that contain only ThinkingPart instances)
490
+ # this can happen with models that support thinking mode when they don't provide
491
+ # actionable output alongside their thinking content.
492
+ self ._next_node = await _create_thinking_retry (ctx )
493
+ else :
494
+ # handle empty response with no thinking
495
+ # this sometimes happens with anthropic (and perhaps other models)
496
+ # when the model has already returned text along side tool calls
497
+ # in this scenario, if text responses are allowed, we return text from the most recent model
498
+ # response, if any
499
+ if isinstance (ctx .deps .output_schema , _output .TextOutputSchema ):
500
+ for message in reversed (ctx .state .message_history ):
501
+ if isinstance (message , _messages .ModelResponse ):
502
+ last_texts = [p .content for p in message .parts if isinstance (p , _messages .TextPart )]
503
+ if last_texts :
504
+ self ._next_node = await self ._handle_text_response (ctx , last_texts )
505
+ return
506
+
507
+ raise exceptions .UnexpectedModelBehavior ('Received empty model response' )
484
508
485
509
self ._events_iterator = _run_stream ()
486
510
0 commit comments