Skip to content

Commit f003b74

Browse files
committed
add handling for thinking-only requests (currently causes UnexpectedModelBehavior)
1 parent f25a4e1 commit f003b74

File tree

2 files changed

+72
-14
lines changed

2 files changed

+72
-14
lines changed

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ def is_agent_node(
143143
return isinstance(node, AgentNode)
144144

145145

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+
146158
@dataclasses.dataclass
147159
class UserPromptNode(AgentNode[DepsT, NodeRunEndT]):
148160
"""The node that handles the user prompt and instructions."""
@@ -434,9 +446,10 @@ async def _run_stream( # noqa: C901
434446
if self._events_iterator is None:
435447
# Ensure that the stream is only run once
436448

437-
async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]:
449+
async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa: C901
438450
texts: list[str] = []
439451
tool_calls: list[_messages.ToolCallPart] = []
452+
440453
for part in self.model_response.parts:
441454
if isinstance(part, _messages.TextPart):
442455
# ignore empty content for text parts, see #437
@@ -468,19 +481,30 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]:
468481
# No events are emitted during the handling of text responses, so we don't need to yield anything
469482
self._next_node = await self._handle_text_response(ctx, texts)
470483
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')
484508

485509
self._events_iterator = _run_stream()
486510

tests/test_agent.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4087,3 +4087,37 @@ def bar() -> str:
40874087
assert run.result.output == snapshot(Foo(a=0, b='a'))
40884088
assert test_model.last_model_request_parameters is not None
40894089
assert [t.name for t in test_model.last_model_request_parameters.function_tools] == snapshot(['bar'])
4090+
4091+
4092+
async def test_thinking_only_response_retry():
4093+
"""Test that thinking-only responses trigger a retry mechanism."""
4094+
from pydantic_ai.messages import ThinkingPart
4095+
from pydantic_ai.models.function import FunctionModel
4096+
4097+
call_count = 0
4098+
4099+
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
4100+
nonlocal call_count
4101+
call_count += 1
4102+
4103+
if call_count == 1:
4104+
# First call: return thinking-only response
4105+
return ModelResponse(
4106+
parts=[ThinkingPart(content='Let me think about this...')],
4107+
model_name='thinking-test-model',
4108+
)
4109+
else:
4110+
# Second call: return proper response
4111+
return ModelResponse(
4112+
parts=[TextPart(content='Final answer')],
4113+
model_name='thinking-test-model',
4114+
)
4115+
4116+
model = FunctionModel(model_function)
4117+
agent = Agent(model, system_prompt='You are a helpful assistant.')
4118+
4119+
result = await agent.run('Hello')
4120+
4121+
# Should have made exactly 2 calls and produced the expected result
4122+
assert call_count == 2
4123+
assert result.output == 'Final answer'

0 commit comments

Comments
 (0)